From 775887191785a6ed658dbe368c5a57e817eba0e9 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 16:05:40 +0300 Subject: [PATCH 001/229] fix: add venv to goga lint ignore --- .goga/config.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.goga/config.yml b/.goga/config.yml index f4c2f8b0..dceba610 100644 --- a/.goga/config.yml +++ b/.goga/config.yml @@ -20,6 +20,11 @@ pipeline: <<: *claude-env ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3[1m]" +lint: + ignore: + - build/ + - .venv/ + codemanifest: usages: convention: .goga/usages/conventions.md @@ -30,7 +35,3 @@ codemanifest: - Debugging and testing - Organizing the test infrastructure - Understanding the general principles and rules of development and testing in the project - -lint: - ignore: - - build/ From 162ec1cdce03ec0693caf7f31adb652baf728229 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 16:41:02 +0300 Subject: [PATCH 002/229] feat: goga-review dispatch by artifact filename under .goga/history --- goga/assets/skills/goga-review/SKILL.md | 39 ++++++++++++++----------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/goga/assets/skills/goga-review/SKILL.md b/goga/assets/skills/goga-review/SKILL.md index 8cbdc01e..dbea5d9d 100644 --- a/goga/assets/skills/goga-review/SKILL.md +++ b/goga/assets/skills/goga-review/SKILL.md @@ -10,15 +10,20 @@ Arguments: $ARGUMENTS ### Review Type Detection -1. **Arguments contain a path** — derive the review type by matching path segments: - - Path contains `docs/arch/` → **architecture** - - Path contains `docs/design/` → **design** - - Path contains `docs/plans/` → **plan** - - Path contains `docs/tasks/` → **task** - - Path does not contain `docs/` (or points to another location) → **cell** - - Extract `` from the path: - - For `docs/arch/javascript-contract.md` → `` = `javascript-contract` +1. **Arguments contain a path under `.goga/history/`** — the path must match + `.goga/history///.md`: + - `` must be exactly 4 digits (`\d{4}`). A path like `.goga/history/26//...` is NOT a valid artifact path → treat as **cell**. + - Derive the review type by `` (the filename without `.md`): + - `prd.md` → **prd** + - `adr.md` → **adr** + - `task.md` → **task** + - `arch.md` → **architecture** + - `design.md` → **design** + - `plan.md` → **plan** + - Any other filename, or a path outside `.goga/history/` → **cell**. + + Extract `` (the topic): + - For `.goga/history/2026/javascript-contract/arch.md` → `` = `javascript-contract` - For `src/cell/my-cell` → `` = `src/cell/my-cell` - For `my-cell` → `` = `my-cell` @@ -27,26 +32,26 @@ Arguments: $ARGUMENTS - **header**: "Review type" - **multiSelect**: false - **options**: - - **label**: "architecture", **description**: "Review an architecture plan from docs/arch/" - - **label**: "design", **description**: "Review a design document from docs/design/" - - **label**: "plan", **description**: "Review an implementation plan from docs/plans/" + - **label**: "architecture", **description**: "Review an architecture plan from .goga/history/" + - **label**: "design", **description**: "Review a design document from .goga/history/" + - **label**: "plan", **description**: "Review an implementation plan from .goga/history/" - **label**: "cell", **description**: "Review a cell (CODEMANIFEST and file structure)" - - **label**: "task", **description**: "Review a task from docs/tasks/" + - **label**: "task", **description**: "Review a task from .goga/history/" ### Type-Based Routing #### architecture -Verify that `docs/arch/.md` exists. +Verify that `.goga/history///arch.md` exists (search `.goga/history/*//arch.md` for a `` that is exactly 4 digits). 1. **Not found** — stop execution and report to the user. 2. **Found** — invoke skill `goga-review-arch` via the **Skill tool**, passing `` as the argument. #### design -Verify that `docs/design/.md` exists. +Verify that `.goga/history///design.md` exists (search `.goga/history/*//design.md` for a `` that is exactly 4 digits). 1. **Not found** — stop execution and report to the user. 2. **Found** — invoke skill `goga-review-design` via the **Skill tool**, passing `` as the argument. #### plan -Verify that `docs/plans/.md` exists. +Verify that `.goga/history///plan.md` exists (search `.goga/history/*//plan.md` for a `` that is exactly 4 digits). 1. **Not found** — stop execution and report to the user. 2. **Found** — invoke skill `goga-review-plan` via the **Skill tool**, passing `` as the argument. @@ -56,6 +61,6 @@ Verify that directory `` and file `/CODEMANIFEST` both exist. 2. **Found** — invoke skill `goga-review-cell` via the **Skill tool**, passing `` as the argument. #### task -Verify that `docs/tasks/.md` exists. +Verify that `.goga/history///task.md` exists (search `.goga/history/*//task.md` for a `` that is exactly 4 digits). 1. **Not found** — stop execution and report to the user. 2. **Found** — invoke skill `goga-review-task` via the **Skill tool**, passing `` as the argument. From 8487282d4af98d3115410acbf0e3a9b33099ac61 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 16:41:41 +0300 Subject: [PATCH 003/229] feat: goga-review-arch reads artifacts from .goga/history --- goga/assets/skills/goga-review-arch/SKILL.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/goga/assets/skills/goga-review-arch/SKILL.md b/goga/assets/skills/goga-review-arch/SKILL.md index 10ee82a6..4e48898d 100644 --- a/goga/assets/skills/goga-review-arch/SKILL.md +++ b/goga/assets/skills/goga-review-arch/SKILL.md @@ -6,7 +6,7 @@ description: Review an architecture plan for semantic correctness ## Objective -Validate the architecture plan (`docs/arch/.md`) for **semantic correctness** — assess model cohesion, domain boundary +Validate the architecture plan (`.goga/history///arch.md`) for **semantic correctness** — assess model cohesion, domain boundary soundness, and requirement sufficiency for implementation. The agent **analyzes** the architecture plan, **reports** findings, and **applies fixes** when issues are detected (subject to user approval). @@ -22,8 +22,8 @@ all types, domains, and requirements as a unified whole — not each CODEMANIFES ## Input -- **Required**: architecture plan at `docs/arch/.md` -- **Optional**: task file at `docs/tasks/.md` — when present, used to verify requirements coverage +- **Required**: architecture plan at `.goga/history///arch.md` +- **Optional**: task file at `.goga/history///task.md` — when present, used to verify requirements coverage. The year may differ from the architecture plan's year; locate the task file as `.goga/history/*//task.md` with a 4-digit year segment. --- @@ -31,7 +31,7 @@ all types, domains, and requirements as a unified whole — not each CODEMANIFES ### Phase 1: Context Loading -1. Read the architecture plan from `docs/arch/.md` +1. Read the architecture plan from `.goga/history///arch.md` 2. Load the DSL specification and DSL application principles: - Invoke `goga-cell` via **Skill tool** — to understand DSL rules (signature syntax, Import/Usage/Annotation rules, types, mutations, embeddings, constraints) @@ -49,7 +49,7 @@ all types, domains, and requirements as a unified whole — not each CODEMANIFES - Classify plan cells: newly created vs. modified 6. Read the existing CODEMANIFESTs of cells the plan marks for modification 7. Read the existing `.usages/` files of cells marked for modification -8. If the task file `docs/tasks/.md` exists — read it for subsequent requirements coverage verification +8. If the task file `.goga/history/*//task.md` exists (4-digit year; the year may differ from the architecture plan's year) — read it for subsequent requirements coverage verification --- @@ -233,7 +233,7 @@ Missing edge cases — log as **Medium**. #### Step 4. Task Requirements Coverage -If the task file `docs/tasks/.md` exists: +If the task file `.goga/history///task.md` exists: - Each requirement from the "Description" section must map to type(s) in the plan that fulfill it - Each acceptance criterion must have a contractual basis in the plan — the plan must enable fulfilling the criterion From bdcd84db6b26aeb14391b94ef0089b2a486f0cd8 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 16:42:18 +0300 Subject: [PATCH 004/229] feat: goga-review-design reads design from .goga/history --- goga/assets/skills/goga-review-design/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/goga/assets/skills/goga-review-design/SKILL.md b/goga/assets/skills/goga-review-design/SKILL.md index 2e8c436b..2fab9db9 100644 --- a/goga/assets/skills/goga-review-design/SKILL.md +++ b/goga/assets/skills/goga-review-design/SKILL.md @@ -43,7 +43,7 @@ All CODEMANIFEST edits must be **proposed to the user** before applying. (document structure, signature syntax, Imports rules, Usages rules, Annotations rules, types, mutations, embeddings, constraints) - Use the **Skill tool** to invoke `goga-cookbook` — for understanding cell design principles and CODEMANIFEST (when to use Entity vs Routine, when to apply mutations and embeddings, usage file authoring principles, cell granularity) -2. Read the design document from `docs/design/.md` +2. Read the design document from `.goga/history///design.md` 3. Read all relevant CODEMANIFEST files referenced by the design 4. Read existing source files referenced by the design (if any) 5. Execute `goga schema --help` to understand the command, then execute `goga schema` to obtain the full project dependency graph. Use `--depends-on ` to discover cells that depend on cells modified by the design. This ensures the review covers all affected cells. From fadd5f39aca787bf481e1969ab0903bb0b7eb37b Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 16:42:18 +0300 Subject: [PATCH 005/229] feat: goga-review-plan reads plan and design from .goga/history --- goga/assets/skills/goga-review-plan/SKILL.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/goga/assets/skills/goga-review-plan/SKILL.md b/goga/assets/skills/goga-review-plan/SKILL.md index f23dc8d8..f78dcf8a 100644 --- a/goga/assets/skills/goga-review-plan/SKILL.md +++ b/goga/assets/skills/goga-review-plan/SKILL.md @@ -6,7 +6,7 @@ description: Verify execution plan completeness and correctness ## Objective -Verify the execution plan (`docs/plans/.md`) for **completeness and correctness** against the design document and `CODEMANIFEST` contracts before passing to ralphex. +Verify the execution plan (`.goga/history///plan.md`) for **completeness and correctness** against the design document and `CODEMANIFEST` contracts before passing to ralphex. You **verify** the plan, **report** findings, and **fix** the plan upon discovery of issues (subject to user approval). @@ -18,7 +18,7 @@ You **verify** the plan, **report** findings, and **fix** the plan upon discover ## Verifiable Artifact -- Plan file at `docs/plans/.md` — the execution plan, verified against sources of truth +- Plan file at `.goga/history///plan.md` — the execution plan, verified against sources of truth --- @@ -30,8 +30,8 @@ You **verify** the plan, **report** findings, and **fix** the plan upon discover The language skill defines implementation conventions: cell structure, facade, signature rules, **naming**. Examples in other skills may use naming conventions of one language (e.g., snake_case), while the target language requires different conventions (e.g., PascalCase) — the language skill is the authoritative source for the target language. -2. Read the plan from `docs/plans/.md` -3. Read the design document from `docs/design/.md` +2. Read the plan from `.goga/history///plan.md` +3. Read the design document from `.goga/history///design.md` — same topic directory; the year may differ, so search `.goga/history/*//design.md` with a 4-digit year 4. Read all relevant `CODEMANIFEST` files referenced by the design document 5. Load the DSL specification and DSL application principles: - Invoke `goga-cell` via the **Skill tool** — obtain the DSL reference @@ -228,7 +228,7 @@ Use AskUserQuestion with options: #### Step 3. Apply the Decision -- **Apply suggested fix**: update the plan file at `docs/plans/.md`, then re-verify that the fix introduces no new issues (re-run the relevant checks). Briefly report the re-verification result. +- **Apply suggested fix**: update the plan file at `.goga/history///plan.md`, then re-verify that the fix introduces no new issues (re-run the relevant checks). Briefly report the re-verification result. - **Skip**: record the finding as "skipped" and proceed. - **Suggest alternative**: discuss the alternative with the user, agree on a fix, apply it, and re-verify. From 23c733e4a135d2fab2d6aba484b12713a0a7eee1 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 16:42:18 +0300 Subject: [PATCH 006/229] feat: goga-review-task reads task from .goga/history --- goga/assets/skills/goga-review-task/SKILL.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/goga/assets/skills/goga-review-task/SKILL.md b/goga/assets/skills/goga-review-task/SKILL.md index 0ffe58cc..205a829e 100644 --- a/goga/assets/skills/goga-review-task/SKILL.md +++ b/goga/assets/skills/goga-review-task/SKILL.md @@ -6,7 +6,7 @@ description: Review a task for completeness, correctness, and consistency ## Objective -Validates a task (`docs/tasks/.md`) for **completeness, correctness, and consistency** — ensuring the task is formulated clearly enough to proceed to architecture (`goga-brainstorm`). +Validates a task (`.goga/history///task.md`) for **completeness, correctness, and consistency** — ensuring the task is formulated clearly enough to proceed to architecture (`goga-brainstorm`). You **verify** the task, **report** findings, and **fix** the task when issues are discovered (with user approval). @@ -24,7 +24,7 @@ You **verify** the task, **report** findings, and **fix** the task when issues a ## Verifiable Artifact -- Task file at `docs/tasks/.md` — a formulated task being verified for completeness and correctness +- Task file at `.goga/history///task.md` — a formulated task being verified for completeness and correctness --- @@ -32,7 +32,7 @@ You **verify** the task, **report** findings, and **fix** the task when issues a ### Phase 1: Load Context -1. Read the task from `docs/tasks/.md` +1. Read the task from `.goga/history///task.md` 2. Load the DSL specification and DSL application principles: - Use the **Skill tool** to invoke `goga-cell` — for understanding cell terminology and CODEMANIFEST when verifying the "Existing Architecture" section - Use the **Skill tool** to invoke `goga-cookbook` — for understanding cell interaction principles when verifying the correctness of affected cells description From 9ae5d3507c8096d3900e0437f93849f2078c9ebf Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 16:44:10 +0300 Subject: [PATCH 007/229] feat: goga-apply reads architecture plan from .goga/history --- goga/assets/skills/goga-apply/SKILL.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/goga/assets/skills/goga-apply/SKILL.md b/goga/assets/skills/goga-apply/SKILL.md index 39aee88f..9cd96b3d 100644 --- a/goga/assets/skills/goga-apply/SKILL.md +++ b/goga/assets/skills/goga-apply/SKILL.md @@ -2,7 +2,7 @@ name: goga-apply description: Materialize an architectural plan into the cells file structure --- -You are an architectural plan materialization engineer. You transform plans from `docs/arch/.md` into a cells file structure (CODEMANIFEST, `.usages/`). +You are an architectural plan materialization engineer. You transform plans from `.goga/history///arch.md` into a cells file structure (CODEMANIFEST, `.usages/`). ## Dispatch @@ -19,7 +19,7 @@ Retain the original arguments for the duration of the session. Resolve ``: 1. **Arguments supplied** — use the arguments as ``. -2. **No arguments** — scan the `docs/arch/` directory: +2. **No arguments** — scan `.goga/history/*/` topic directories for `arch.md` (year segment must be 4 digits) and present the list via **AskUserQuestion**: - **Directory missing or empty** — halt and report the error. - **Single file** — use its filename (without extension) as ``. - **Multiple files** — present the list via AskUserQuestion and prompt for selection. @@ -40,6 +40,6 @@ If the command is unavailable — halt and notify the user. Use the **Skill tool** to invoke `goga-cells-by-brainstorm` with `` as the argument. -The skill reads the plan from `docs/arch/.md` and materializes it into a cells file structure (CODEMANIFEST, `.usages/`). +The skill reads the plan from `.goga/history///arch.md` and materializes it into a cells file structure (CODEMANIFEST, `.usages/`). --- From e8248d9acd9d2bd484f1c8e88bf1713a7291c685 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 16:44:10 +0300 Subject: [PATCH 008/229] feat: goga-define saves PRD to .goga/history --- goga/assets/skills/goga-define-prd/SKILL.md | 4 +++- goga/assets/skills/goga-define/SKILL.md | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/goga/assets/skills/goga-define-prd/SKILL.md b/goga/assets/skills/goga-define-prd/SKILL.md index a72edbab..a3cc578c 100644 --- a/goga/assets/skills/goga-define-prd/SKILL.md +++ b/goga/assets/skills/goga-define-prd/SKILL.md @@ -344,7 +344,9 @@ The PRD must contain only validated product decisions. The final artifact will be saved by the orchestrator to: ```text -docs/defines/.md +.goga/history///prd.md + + is the current year in `YYYY` format (4 digits, zero-padded). Create the directory lazily if it does not exist. ``` Do not create additional PRD files. diff --git a/goga/assets/skills/goga-define/SKILL.md b/goga/assets/skills/goga-define/SKILL.md index 32739ebb..87633290 100644 --- a/goga/assets/skills/goga-define/SKILL.md +++ b/goga/assets/skills/goga-define/SKILL.md @@ -357,7 +357,9 @@ Once the product definition is validated: 4. save it as: ```text -docs/defines/.md +.goga/history///prd.md + + is the current year in `YYYY` format (4 digits, zero-padded). Create the directory lazily if it does not exist. ``` `` should be derived from the product change using a concise, filesystem-safe topic name. @@ -406,7 +408,9 @@ The orchestrator must not formulate or resolve the decision itself. The primary output of `goga-define` is one Markdown file: ```text -docs/defines/.md +.goga/history///prd.md + + is the current year in `YYYY` format (4 digits, zero-padded). Create the directory lazily if it does not exist. ``` The orchestrator should provide a concise completion message containing: From 7a678d3a165abb24f2d692c6bb6cb295ceda732f Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 16:44:10 +0300 Subject: [PATCH 009/229] feat: goga-discover saves ADR to .goga/history --- goga/assets/skills/goga-discover/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/goga/assets/skills/goga-discover/SKILL.md b/goga/assets/skills/goga-discover/SKILL.md index 3802c4b0..b598a557 100644 --- a/goga/assets/skills/goga-discover/SKILL.md +++ b/goga/assets/skills/goga-discover/SKILL.md @@ -23,7 +23,7 @@ Finding _facts_ is your job, never the user's. When a frontier question needs a The interview is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not write the ADR until the user confirms you have reached a shared understanding. -Once confirmed, write the ADR to `docs/proposals/.md` (slug name, lowercase kebab-case; create the directory lazily if needed), following `adr-template.md` from the current skill directory. +Once confirmed, write the ADR to `.goga/history///adr.md` (`` is a slug name, lowercase kebab-case; `` is the current year in `YYYY` format, 4 digits, zero-padded; create the directory lazily if needed), following `adr-template.md` from the current skill directory. ## Research From 218bf8aed96031b41945846465633e74867d2714 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 16:45:56 +0300 Subject: [PATCH 010/229] feat: task-formulation skills save task to .goga/history --- goga/assets/skills/goga-brainstorm-intake/SKILL.md | 2 +- goga/assets/skills/goga-propose/SKILL.md | 2 +- goga/assets/skills/goga-task-by-proposing/SKILL.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/goga/assets/skills/goga-brainstorm-intake/SKILL.md b/goga/assets/skills/goga-brainstorm-intake/SKILL.md index 87d29401..e53553d8 100644 --- a/goga/assets/skills/goga-brainstorm-intake/SKILL.md +++ b/goga/assets/skills/goga-brainstorm-intake/SKILL.md @@ -21,7 +21,7 @@ Classify the description into one of: - **Brief** — one sentence or a feature name - **Detailed** — a complete specification with requirements, constraints, examples -- **Task file** — path to `docs/tasks/.md` +- **Task file** — path to `.goga/history///task.md` ### Phase 3. Read the task file if provided diff --git a/goga/assets/skills/goga-propose/SKILL.md b/goga/assets/skills/goga-propose/SKILL.md index 652d89d6..41bb1721 100644 --- a/goga/assets/skills/goga-propose/SKILL.md +++ b/goga/assets/skills/goga-propose/SKILL.md @@ -20,6 +20,6 @@ Use the **Skill tool** to invoke `goga-task-by-proposing` with the arguments as Arguments: $ARGUMENTS -The skill formulates the task and saves the artifact to `docs/tasks/.md`. +The skill formulates the task and saves the artifact to `.goga/history///task.md`. --- diff --git a/goga/assets/skills/goga-task-by-proposing/SKILL.md b/goga/assets/skills/goga-task-by-proposing/SKILL.md index 12ce3ac4..622ed3eb 100644 --- a/goga/assets/skills/goga-task-by-proposing/SKILL.md +++ b/goga/assets/skills/goga-task-by-proposing/SKILL.md @@ -7,7 +7,7 @@ description: Interactive task formulation from a raw request ## Purpose Transforms a raw user request (e.g., "add authorization") into a **formulated task** — a structured document containing -the description, technology stack, dependencies, and scope estimate. The output is persisted to `docs/tasks/.md` +the description, technology stack, dependencies, and scope estimate. The output is persisted to `.goga/history///task.md` and serves as input for the `goga-brainstorm` skill. --- @@ -164,7 +164,7 @@ If all external dependencies are covered by current usage files, skip this phase ### Phase 7: Task Persistence -**Objective:** Save the formulated task to `docs/tasks/.md` using the template. +**Objective:** Save the formulated task to `.goga/history///task.md` using the template. `` is a short name derived from the task topic (from the user's Phase 1 description). From 871afb5fd45e1f9c122e126da1a16f3eb55ae127 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 16:45:56 +0300 Subject: [PATCH 011/229] feat: brainstorm pipeline writes arch plan to .goga/history --- goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md | 4 ++-- .../skills/goga-brainstorm-plan-verification/SKILL.md | 2 +- .../assets/skills/goga-brainstorm-primary-analysis/SKILL.md | 2 +- goga/assets/skills/goga-brainstorm/SKILL.md | 6 +++--- goga/assets/skills/goga-cells-by-brainstorm/SKILL.md | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md b/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md index cfcf3d5b..f67ae016 100644 --- a/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md +++ b/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md @@ -62,7 +62,7 @@ What to check after implementing each artifact. ### Phase 4. Save the plan -Save the plan to `docs/arch/.md`. +Save the plan to `.goga/history///arch.md`. ## WAIT @@ -76,7 +76,7 @@ Fill every section. No empty sections. # [ARCHITECTURE_PLAN] ## Topic -[Short name and the docs/arch/.md path] +[Short name and the .goga/history///arch.md path] ## Implementation Order [Ordered list of cells, leaves to root, with rationale per cell] diff --git a/goga/assets/skills/goga-brainstorm-plan-verification/SKILL.md b/goga/assets/skills/goga-brainstorm-plan-verification/SKILL.md index 503f976f..17dc8b80 100644 --- a/goga/assets/skills/goga-brainstorm-plan-verification/SKILL.md +++ b/goga/assets/skills/goga-brainstorm-plan-verification/SKILL.md @@ -19,7 +19,7 @@ Use these skills for verification: Use this artifact for its specific purpose: -- **`[ARCHITECTURE_PLAN]`** (at `docs/arch/.md`) — use it as the **object of verification**: its implementation +- **`[ARCHITECTURE_PLAN]`** (at `.goga/history///arch.md`) — use it as the **object of verification**: its implementation order, per-cell CODEMANIFESTs and `.usages/` files, dependency map, and verification checklist, against which the DSL checks are run, failures are fixed in place, and the report is produced. diff --git a/goga/assets/skills/goga-brainstorm-primary-analysis/SKILL.md b/goga/assets/skills/goga-brainstorm-primary-analysis/SKILL.md index 18454112..121020f6 100644 --- a/goga/assets/skills/goga-brainstorm-primary-analysis/SKILL.md +++ b/goga/assets/skills/goga-brainstorm-primary-analysis/SKILL.md @@ -34,7 +34,7 @@ Task file) sets the expected depth — Brief input yields more dark zones; Detai constraints and acceptance criteria. - **Topic** — a short topic name derived from the `[INTAKE_REPORT]` task summary (used by plan-assembly for - `docs/arch/.md`) + `.goga/history///arch.md`) - **Acceptance criteria** — if task-file input, folded verbatim/condensed from the `[INTAKE_REPORT]` "Acceptance Criteria" section; otherwise N/A - **Stack & external dependencies** — if task-file input, folded from the `[INTAKE_REPORT]` "Stack and Dependencies" diff --git a/goga/assets/skills/goga-brainstorm/SKILL.md b/goga/assets/skills/goga-brainstorm/SKILL.md index 83cd06bc..6578886c 100644 --- a/goga/assets/skills/goga-brainstorm/SKILL.md +++ b/goga/assets/skills/goga-brainstorm/SKILL.md @@ -11,7 +11,7 @@ strict order, never performing design work yourself — each stage is delegated ## Mission -Produce an architecture plan (`docs/arch/.md`) describing which cells, CODEMANIFEST files, and `.usages/` files +Produce an architecture plan (`.goga/history///arch.md`) describing which cells, CODEMANIFEST files, and `.usages/` files need to be created and in what order — designed collaboratively with the user through exploration, discussion, and refinement. @@ -152,13 +152,13 @@ Execute each phase strictly sequentially — one phase at a time. After each pha ### Phase 9. Plan Assembly - Invoke: **goga-brainstorm-plan-assembly** - Reads: [CELL_ASSEMBLY_REPORT] + [PRIMARY_ANALYSIS_REPORT] -- Output: [ARCHITECTURE_PLAN] written to `docs/arch/.md` +- Output: [ARCHITECTURE_PLAN] written to `.goga/history///arch.md` - WAIT: present plan to user, obtain confirmation - STOP if: plan incomplete ### Phase 10. Plan Verification - Invoke: **goga-brainstorm-plan-verification** -- Reads: [ARCHITECTURE_PLAN] (`docs/arch/.md`) +- Reads: [ARCHITECTURE_PLAN] (`.goga/history///arch.md`) - Output: [VERIFICATION_REPORT] - WAIT: present the final (fixed) plan and [VERIFICATION_REPORT] to the user, obtain final confirmation - STOP if: unresolved DSL errors; any verification gate failed diff --git a/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md b/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md index 47c061cb..5b48c2c8 100644 --- a/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md +++ b/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md @@ -6,7 +6,7 @@ description: Creation and modification of cells by architecture plan ## Purpose -Creates new cells and modifies existing cells based on the architecture plan defined in `docs/arch/.md`. Materializes the plan into the cell file structure: +Creates new cells and modifies existing cells based on the architecture plan defined in `.goga/history///arch.md`. Materializes the plan into the cell file structure: CODEMANIFEST, `.usages/`. --- @@ -51,8 +51,8 @@ Apply the loaded DSL specifications, DSL application principles, and language ru #### Step 1. Locate the plan file - If the argument contains a file path — use it directly -- If the argument contains only `` — search for the file `docs/arch/.md` -- If no argument is provided — discover all files in `docs/arch/` and present the list via **AskUserQuestion** +- If the argument contains only `` — search for the file `.goga/history///arch.md` +- If no argument is provided — discover all `arch.md` files under `.goga/history/*/` topic directories and present the list via **AskUserQuestion** - If the file is not found — halt and report the error to the user #### Step 2. Parse the plan structure From 2d049ad7f44b6cf9a2815989b5f95b433a359c81 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 16:45:56 +0300 Subject: [PATCH 012/229] feat: design and plan skills use .goga/history paths --- goga/assets/skills/goga-design-by-changes/SKILL.md | 4 ++-- .../goga-design-by-changes/design-doc-template.md | 4 ++-- goga/assets/skills/goga-plan-by-design/SKILL.md | 8 ++++---- .../skills/goga-plan-by-design/output-template.md | 4 ++-- goga/assets/skills/goga-plan/SKILL.md | 10 +++++----- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/goga/assets/skills/goga-design-by-changes/SKILL.md b/goga/assets/skills/goga-design-by-changes/SKILL.md index 70db4c91..d353935e 100644 --- a/goga/assets/skills/goga-design-by-changes/SKILL.md +++ b/goga/assets/skills/goga-design-by-changes/SKILL.md @@ -326,10 +326,10 @@ Write results to a file using the template from `design-doc-template.md`. #### Step 2: Save -Path: `docs/design/.md`. +Path: `.goga/history///design.md`. - Prompt for the feature name if not obvious -- Create the `docs/design/` directory if it does not exist +- Create the `.goga/history///` directory lazily if it does not exist - Overwrite if the file already exists --- diff --git a/goga/assets/skills/goga-design-by-changes/design-doc-template.md b/goga/assets/skills/goga-design-by-changes/design-doc-template.md index fedc79ce..8fbdfd88 100644 --- a/goga/assets/skills/goga-design-by-changes/design-doc-template.md +++ b/goga/assets/skills/goga-design-by-changes/design-doc-template.md @@ -1,12 +1,12 @@ # Design Document Template -The agent persists this document at `docs/design/.md`. +The agent persists this document at `.goga/history///design.md`. This is a **complete architectural specification** — every detail fully elaborated. --- -# Design Document: `` +# Design Document: `` ## Contract Changes diff --git a/goga/assets/skills/goga-plan-by-design/SKILL.md b/goga/assets/skills/goga-plan-by-design/SKILL.md index 2312fb1b..bc66471c 100644 --- a/goga/assets/skills/goga-plan-by-design/SKILL.md +++ b/goga/assets/skills/goga-plan-by-design/SKILL.md @@ -58,7 +58,7 @@ Use for: #### Step 6: Load Design Document -Read the file from `docs/design/.md`. `` is taken from skill arguments. +Read the file from `.goga/history///design.md`. `` is taken from skill arguments; search across `.goga/history/*//design.md` for a 4-digit year. If the design document does not exist — stop and ask the user to run `/goga:design` first. --- @@ -101,11 +101,11 @@ Use the `goga-cell` skill for correct interpretation of DSL elements during comp #### Step 3: Save the Plan -Write the plan to `docs/plans/.md` using the template from `output-template.md`. +Write the plan to `.goga/history///plan.md` using the template from `output-template.md`. -`` — a short descriptive feature name (e.g., `http-client`, `auth-module`). +`` — a short descriptive feature name (e.g., `http-client`, `auth-module`). The name should reflect the plan's scope, not the Cell name. -Create the `docs/plans/` directory if it does not exist. +Create the `.goga/history///` directory lazily if it does not exist. --- diff --git a/goga/assets/skills/goga-plan-by-design/output-template.md b/goga/assets/skills/goga-plan-by-design/output-template.md index fe5eea5e..4ea90be9 100644 --- a/goga/assets/skills/goga-plan-by-design/output-template.md +++ b/goga/assets/skills/goga-plan-by-design/output-template.md @@ -1,12 +1,12 @@ # Plan Output Template Result of Phase 1 (structure) + Phase 2 (Usages calibration). -Saved to `docs/plans/.md`. +Saved to `.goga/history///plan.md`. This format is compatible with ralphex execution. --- -# Plan: `` +# Plan: `` ## Purpose diff --git a/goga/assets/skills/goga-plan/SKILL.md b/goga/assets/skills/goga-plan/SKILL.md index deaddc91..cf462b0e 100644 --- a/goga/assets/skills/goga-plan/SKILL.md +++ b/goga/assets/skills/goga-plan/SKILL.md @@ -12,14 +12,14 @@ Retain the original arguments for the entire session. ### Design document identification -Determine ``: +Determine ``: 1. **Arguments provided** — use them as the function name. -2. **Arguments empty** — scan the `docs/design/` directory: +2. **Arguments empty** — scan `.goga/history/*/` topic directories for `design.md` (4-digit year) and present the list via **AskUserQuestion**: - **Directory does not exist or is empty** — stop and ask the user to run `/goga:design` first. - - **Single file** — use its name (without extension) as ``. + - **Single file** — use its name (without extension) as ``. - **Multiple files** — display the list via AskUserQuestion and prompt the user to select one. -Check if `docs/design/.md` exists. +Check if `.goga/history///design.md` exists (search `.goga/history/*//design.md` for a 4-digit year). **Does not exist** — stop and ask the user to run `/goga:design` first. -**Exists** — call `goga-plan-by-design` via the **Skill tool** with `` as the argument. +**Exists** — call `goga-plan-by-design` via the **Skill tool** with `` as the argument. From 0b00f5e950773ab3e970c228ed454e1c4175ae24 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 16:46:39 +0300 Subject: [PATCH 013/229] feat: refinement pipeline artifacts under .goga/history --- goga/assets/pipelines/refinement.yml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/goga/assets/pipelines/refinement.yml b/goga/assets/pipelines/refinement.yml index 905105d7..fc318b5d 100644 --- a/goga/assets/pipelines/refinement.yml +++ b/goga/assets/pipelines/refinement.yml @@ -6,7 +6,8 @@ description: "Task refinement process" title: "Product definition & create PRD" communication: true prompt: | - Save the PRD file as `docs/defines/.md` + Save the PRD file as `.goga/history///prd.md` + where `` is the current year in `YYYY` format (4 digits, zero-padded). Constrains: - Don't research a project until you receive the task @@ -17,9 +18,10 @@ description: "Task refinement process" title: "Technical discovery & create ADR" communication: true prompt: | - Save the ADR file as `docs/proposals/.md` + Save the ADR file as `.goga/history///adr.md` + where `` is the current year in `YYYY` format (4 digits, zero-padded). - Use `docs/defines/.md` as the PRD file, if it exists. + Use `.goga/history///prd.md` as the PRD file, if it exists (any 4-digit year under `.goga/history/`). If PRD file does not exist — ask user about task. Communication: @@ -31,10 +33,11 @@ description: "Task refinement process" title: "Task decomposition & create Task(s)" communication: true prompt: | - Save the task file as `docs/tasks/.md` + Save the task file as `.goga/history///task.md` + where `` is the current year in `YYYY` format (4 digits, zero-padded). - Use `docs/proposals/.md` as the task file, if it exists. - If propose does not exist — try `docs/defines/.md` as the PRD file. + Use `.goga/history///adr.md` as the task file, if it exists (any 4-digit year under `.goga/history/`). + If ADR does not exist — try `.goga/history///prd.md` (any 4-digit year) as the PRD file. If PRD file does not exists — ask user about task. skills: - goga-propose @@ -43,6 +46,6 @@ description: "Task refinement process" title: "Review of the created task" communication: true prompt: | - Review the task `docs/tasks/.md` + Review the task `.goga/history///task.md` (any 4-digit year under `.goga/history/`) skills: - goga-review-task From 81179ccdb79388915f21605ca7873f048b1fa383 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 16:47:04 +0300 Subject: [PATCH 014/229] feat: development pipeline and workflow use .goga/history artifacts --- .goga/workflows/development.yml | 2 +- goga/assets/pipelines/development.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.goga/workflows/development.yml b/.goga/workflows/development.yml index fd2718af..2f587aeb 100644 --- a/.goga/workflows/development.yml +++ b/.goga/workflows/development.yml @@ -38,5 +38,5 @@ extend: after: - commit-changes timeout: "8h" - script: python3 -m goga.build docs/plans/$(git branch --show-current).md + script: python3 -m goga.build .goga/history/$(date +%Y)/$(git branch --show-current)/plan.md after_script: rm -rf .ralphex diff --git a/goga/assets/pipelines/development.yml b/goga/assets/pipelines/development.yml index 6d06a059..a221701c 100644 --- a/goga/assets/pipelines/development.yml +++ b/goga/assets/pipelines/development.yml @@ -6,7 +6,7 @@ description: "Development process" title: "Task-based architecture development" communication: true prompt: | - Use the task `docs/tasks/.md`, if it exists + Use the task `.goga/history///task.md`, if it exists (any 4-digit year under `.goga/history/`) Save the architecture plan as `.md` **CODEMANIFEST files** must be described at a functional and business-logic level, remaining strictly implementation-agnostic: @@ -85,7 +85,7 @@ description: "Development process" Commit all added and modified files. Constraints: - - Except changes in `docs/`. + - Except changes in `.goga/history/`. - name: accept-result title: "Contracts & coverage audit" From 985d31b87cce0ec478e314522c6b9fd02d976b96 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 16:54:51 +0300 Subject: [PATCH 015/229] feat: development pipeline stage prompts use full .goga/history paths --- goga/assets/pipelines/development.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/goga/assets/pipelines/development.yml b/goga/assets/pipelines/development.yml index a221701c..6816080b 100644 --- a/goga/assets/pipelines/development.yml +++ b/goga/assets/pipelines/development.yml @@ -7,7 +7,8 @@ description: "Development process" communication: true prompt: | Use the task `.goga/history///task.md`, if it exists (any 4-digit year under `.goga/history/`) - Save the architecture plan as `.md` + Save the architecture plan as `.goga/history///arch.md` + where `` is the current year in `YYYY` format (4 digits, zero-padded). **CODEMANIFEST files** must be described at a functional and business-logic level, remaining strictly implementation-agnostic: - Focus on defining "what" the system should achieve (expected behavior, business rules, inputs, and outputs) rather than "how" to code it. @@ -34,14 +35,14 @@ description: "Development process" title: "Review of the created architectural plan" communication: true prompt: | - Review the architecture plan `.md` + Review the architecture plan `.goga/history///arch.md` (any 4-digit year under `.goga/history/`) skills: - goga-review-arch - name: apply-architecture title: "Apply the created architectural plan" prompt: | - Apply the architecture plan `.md` + Apply the architecture plan `.goga/history///arch.md` (any 4-digit year under `.goga/history/`) skills: - goga-apply @@ -49,7 +50,8 @@ description: "Development process" title: "Designing architecture into code" communication: true prompt: | - Save the design document as `.md` + Save the design document as `.goga/history///design.md` + where `` is the current year in `YYYY` format (4 digits, zero-padded). skills: - goga-design @@ -57,7 +59,7 @@ description: "Development process" title: "Review of the created design plan" communication: true prompt: | - Review the design document `.md` + Review the design document `.goga/history///design.md` (any 4-digit year under `.goga/history/`) skills: - goga-review-design @@ -65,8 +67,9 @@ description: "Development process" title: "Create the coding plan" communication: true prompt: | - Use the design document `.md` - Save the plan as `.md` + Use the design document `.goga/history///design.md` (any 4-digit year under `.goga/history/`) + Save the plan as `.goga/history///plan.md` + where `` is the current year in `YYYY` format (4 digits, zero-padded). skills: - goga-plan @@ -74,7 +77,7 @@ description: "Development process" title: "Review of the created coding plan" communication: true prompt: | - Review the plan `.md` + Review the plan `.goga/history///plan.md` (any 4-digit year under `.goga/history/`) skills: - goga-review-plan From b538bd08f3d1e793bb931faf32d2d416be351b66 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 17:09:48 +0300 Subject: [PATCH 016/229] fix: topic resolution uses topic directory name under .goga/history --- goga/assets/skills/goga-apply/SKILL.md | 2 +- goga/assets/skills/goga-plan/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/goga/assets/skills/goga-apply/SKILL.md b/goga/assets/skills/goga-apply/SKILL.md index 9cd96b3d..7d0b4e09 100644 --- a/goga/assets/skills/goga-apply/SKILL.md +++ b/goga/assets/skills/goga-apply/SKILL.md @@ -21,7 +21,7 @@ Resolve ``: 1. **Arguments supplied** — use the arguments as ``. 2. **No arguments** — scan `.goga/history/*/` topic directories for `arch.md` (year segment must be 4 digits) and present the list via **AskUserQuestion**: - **Directory missing or empty** — halt and report the error. - - **Single file** — use its filename (without extension) as ``. + - **Single file** — use its topic directory name as ``. - **Multiple files** — present the list via AskUserQuestion and prompt for selection. ## Pre-flight check: goga availability diff --git a/goga/assets/skills/goga-plan/SKILL.md b/goga/assets/skills/goga-plan/SKILL.md index cf462b0e..f659f259 100644 --- a/goga/assets/skills/goga-plan/SKILL.md +++ b/goga/assets/skills/goga-plan/SKILL.md @@ -17,7 +17,7 @@ Determine ``: 1. **Arguments provided** — use them as the function name. 2. **Arguments empty** — scan `.goga/history/*/` topic directories for `design.md` (4-digit year) and present the list via **AskUserQuestion**: - **Directory does not exist or is empty** — stop and ask the user to run `/goga:design` first. - - **Single file** — use its name (without extension) as ``. + - **Single file** — use its topic directory name as ``. - **Multiple files** — display the list via AskUserQuestion and prompt the user to select one. Check if `.goga/history///design.md` exists (search `.goga/history/*//design.md` for a 4-digit year). From 340aab7c5765de9baf12948535bf65a7e99d5a0e Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 21:11:55 +0300 Subject: [PATCH 017/229] refactor: unify artifact path grammar and topic slug rule across skills and pipelines --- goga/assets/pipelines/development.yml | 22 ++++++++++-------- goga/assets/pipelines/refinement.yml | 23 ++++++++++--------- goga/assets/skills/goga-apply/SKILL.md | 2 +- .../goga-brainstorm-plan-assembly/SKILL.md | 2 +- goga/assets/skills/goga-define-prd/SKILL.md | 2 +- goga/assets/skills/goga-define/SKILL.md | 6 ++--- goga/assets/skills/goga-discover/SKILL.md | 2 +- .../skills/goga-plan-by-design/SKILL.md | 4 ++-- goga/assets/skills/goga-plan/SKILL.md | 2 +- goga/assets/skills/goga-propose/SKILL.md | 2 +- goga/assets/skills/goga-review-arch/SKILL.md | 6 ++--- goga/assets/skills/goga-review-plan/SKILL.md | 2 +- goga/assets/skills/goga-review/SKILL.md | 16 +++++++++---- .../skills/goga-task-by-proposing/SKILL.md | 6 ++--- 14 files changed, 54 insertions(+), 43 deletions(-) diff --git a/goga/assets/pipelines/development.yml b/goga/assets/pipelines/development.yml index 6816080b..cedad760 100644 --- a/goga/assets/pipelines/development.yml +++ b/goga/assets/pipelines/development.yml @@ -6,9 +6,9 @@ description: "Development process" title: "Task-based architecture development" communication: true prompt: | - Use the task `.goga/history///task.md`, if it exists (any 4-digit year under `.goga/history/`) + Use the task `.goga/history///task.md`, if it exists Save the architecture plan as `.goga/history///arch.md` - where `` is the current year in `YYYY` format (4 digits, zero-padded). + (`` = current year, `YYYY`; create the directory lazily) **CODEMANIFEST files** must be described at a functional and business-logic level, remaining strictly implementation-agnostic: - Focus on defining "what" the system should achieve (expected behavior, business rules, inputs, and outputs) rather than "how" to code it. @@ -35,14 +35,16 @@ description: "Development process" title: "Review of the created architectural plan" communication: true prompt: | - Review the architecture plan `.goga/history///arch.md` (any 4-digit year under `.goga/history/`) + Review the architecture plan `.goga/history///arch.md` + (`` = current year, `YYYY`) skills: - goga-review-arch - name: apply-architecture title: "Apply the created architectural plan" prompt: | - Apply the architecture plan `.goga/history///arch.md` (any 4-digit year under `.goga/history/`) + Apply the architecture plan `.goga/history///arch.md` + (`` = current year, `YYYY`) skills: - goga-apply @@ -51,7 +53,7 @@ description: "Development process" communication: true prompt: | Save the design document as `.goga/history///design.md` - where `` is the current year in `YYYY` format (4 digits, zero-padded). + (`` = current year, `YYYY`; create the directory lazily) skills: - goga-design @@ -59,7 +61,8 @@ description: "Development process" title: "Review of the created design plan" communication: true prompt: | - Review the design document `.goga/history///design.md` (any 4-digit year under `.goga/history/`) + Review the design document `.goga/history///design.md` + (`` = current year, `YYYY`) skills: - goga-review-design @@ -67,9 +70,9 @@ description: "Development process" title: "Create the coding plan" communication: true prompt: | - Use the design document `.goga/history///design.md` (any 4-digit year under `.goga/history/`) + Use the design document `.goga/history///design.md` Save the plan as `.goga/history///plan.md` - where `` is the current year in `YYYY` format (4 digits, zero-padded). + (`` = current year, `YYYY`; create the directory lazily) skills: - goga-plan @@ -77,7 +80,8 @@ description: "Development process" title: "Review of the created coding plan" communication: true prompt: | - Review the plan `.goga/history///plan.md` (any 4-digit year under `.goga/history/`) + Review the plan `.goga/history///plan.md` + (`` = current year, `YYYY`) skills: - goga-review-plan diff --git a/goga/assets/pipelines/refinement.yml b/goga/assets/pipelines/refinement.yml index fc318b5d..1af9c91b 100644 --- a/goga/assets/pipelines/refinement.yml +++ b/goga/assets/pipelines/refinement.yml @@ -7,7 +7,7 @@ description: "Task refinement process" communication: true prompt: | Save the PRD file as `.goga/history///prd.md` - where `` is the current year in `YYYY` format (4 digits, zero-padded). + (`` = current year, `YYYY`; create the directory lazily) Constrains: - Don't research a project until you receive the task @@ -18,12 +18,12 @@ description: "Task refinement process" title: "Technical discovery & create ADR" communication: true prompt: | - Save the ADR file as `.goga/history///adr.md` - where `` is the current year in `YYYY` format (4 digits, zero-padded). - - Use `.goga/history///prd.md` as the PRD file, if it exists (any 4-digit year under `.goga/history/`). + Use `.goga/history///prd.md` as the PRD file, if it exists (4-digit year). If PRD file does not exist — ask user about task. + Save the ADR file as `.goga/history///adr.md` + (`` = current year, `YYYY`; create the directory lazily) + Communication: - You **MUST** follow the `File-Based Dialog Protocol` for every round of questions. skills: @@ -33,12 +33,12 @@ description: "Task refinement process" title: "Task decomposition & create Task(s)" communication: true prompt: | - Save the task file as `.goga/history///task.md` - where `` is the current year in `YYYY` format (4 digits, zero-padded). - - Use `.goga/history///adr.md` as the task file, if it exists (any 4-digit year under `.goga/history/`). - If ADR does not exist — try `.goga/history///prd.md` (any 4-digit year) as the PRD file. + Use the ADR `.goga/history///adr.md` as the input for task formulation, if it exists (4-digit year). + If ADR does not exist — try `.goga/history///prd.md` (4-digit year) as the PRD file. If PRD file does not exists — ask user about task. + + Save the task file as `.goga/history///task.md` + (`` = current year, `YYYY`; create the directory lazily) skills: - goga-propose @@ -46,6 +46,7 @@ description: "Task refinement process" title: "Review of the created task" communication: true prompt: | - Review the task `.goga/history///task.md` (any 4-digit year under `.goga/history/`) + Review the task `.goga/history///task.md` + (`` = current year, `YYYY`) skills: - goga-review-task diff --git a/goga/assets/skills/goga-apply/SKILL.md b/goga/assets/skills/goga-apply/SKILL.md index 7d0b4e09..4f2235e1 100644 --- a/goga/assets/skills/goga-apply/SKILL.md +++ b/goga/assets/skills/goga-apply/SKILL.md @@ -19,7 +19,7 @@ Retain the original arguments for the duration of the session. Resolve ``: 1. **Arguments supplied** — use the arguments as ``. -2. **No arguments** — scan `.goga/history/*/` topic directories for `arch.md` (year segment must be 4 digits) and present the list via **AskUserQuestion**: +2. **No arguments** — scan `.goga/history/*/` topic directories for `arch.md` (4-digit year) and present the list via **AskUserQuestion**: - **Directory missing or empty** — halt and report the error. - **Single file** — use its topic directory name as ``. - **Multiple files** — present the list via AskUserQuestion and prompt for selection. diff --git a/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md b/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md index f67ae016..edb18bcd 100644 --- a/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md +++ b/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md @@ -24,7 +24,7 @@ Use these reports for its specific purpose: ### Phase 1. Determine the topic -Determine `` — a short name from the **Topic** section of the `[PRIMARY_ANALYSIS_REPORT]`. +Determine `` — a lowercase kebab-case slug from the **Topic** section of the `[PRIMARY_ANALYSIS_REPORT]`. ### Phase 2. Assemble the plan structure diff --git a/goga/assets/skills/goga-define-prd/SKILL.md b/goga/assets/skills/goga-define-prd/SKILL.md index a3cc578c..dba8ba3d 100644 --- a/goga/assets/skills/goga-define-prd/SKILL.md +++ b/goga/assets/skills/goga-define-prd/SKILL.md @@ -346,7 +346,7 @@ The final artifact will be saved by the orchestrator to: ```text .goga/history///prd.md - is the current year in `YYYY` format (4 digits, zero-padded). Create the directory lazily if it does not exist. +`` = current year, `YYYY`; create the directory lazily ``` Do not create additional PRD files. diff --git a/goga/assets/skills/goga-define/SKILL.md b/goga/assets/skills/goga-define/SKILL.md index 87633290..a0c089e5 100644 --- a/goga/assets/skills/goga-define/SKILL.md +++ b/goga/assets/skills/goga-define/SKILL.md @@ -359,10 +359,10 @@ Once the product definition is validated: ```text .goga/history///prd.md - is the current year in `YYYY` format (4 digits, zero-padded). Create the directory lazily if it does not exist. +`` = current year, `YYYY`; create the directory lazily ``` -`` should be derived from the product change using a concise, filesystem-safe topic name. +`` — lowercase kebab-case slug derived from the product change. Do not overwrite an unrelated existing PRD. @@ -410,7 +410,7 @@ The primary output of `goga-define` is one Markdown file: ```text .goga/history///prd.md - is the current year in `YYYY` format (4 digits, zero-padded). Create the directory lazily if it does not exist. +`` = current year, `YYYY`; create the directory lazily ``` The orchestrator should provide a concise completion message containing: diff --git a/goga/assets/skills/goga-discover/SKILL.md b/goga/assets/skills/goga-discover/SKILL.md index b598a557..9ec68360 100644 --- a/goga/assets/skills/goga-discover/SKILL.md +++ b/goga/assets/skills/goga-discover/SKILL.md @@ -23,7 +23,7 @@ Finding _facts_ is your job, never the user's. When a frontier question needs a The interview is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not write the ADR until the user confirms you have reached a shared understanding. -Once confirmed, write the ADR to `.goga/history///adr.md` (`` is a slug name, lowercase kebab-case; `` is the current year in `YYYY` format, 4 digits, zero-padded; create the directory lazily if needed), following `adr-template.md` from the current skill directory. +Once confirmed, write the ADR to `.goga/history///adr.md` (`` — lowercase kebab-case slug; `` = current year, `YYYY`; create the directory lazily), following `adr-template.md` from the current skill directory. ## Research diff --git a/goga/assets/skills/goga-plan-by-design/SKILL.md b/goga/assets/skills/goga-plan-by-design/SKILL.md index bc66471c..81644ad2 100644 --- a/goga/assets/skills/goga-plan-by-design/SKILL.md +++ b/goga/assets/skills/goga-plan-by-design/SKILL.md @@ -58,7 +58,7 @@ Use for: #### Step 6: Load Design Document -Read the file from `.goga/history///design.md`. `` is taken from skill arguments; search across `.goga/history/*//design.md` for a 4-digit year. +Read the file from `.goga/history/*//design.md` (4-digit year). `` is taken from skill arguments. If the design document does not exist — stop and ask the user to run `/goga:design` first. --- @@ -103,7 +103,7 @@ Use the `goga-cell` skill for correct interpretation of DSL elements during comp Write the plan to `.goga/history///plan.md` using the template from `output-template.md`. -`` — a short descriptive feature name (e.g., `http-client`, `auth-module`). +`` — lowercase kebab-case slug (e.g., `http-client`, `auth-module`). The name should reflect the plan's scope, not the Cell name. Create the `.goga/history///` directory lazily if it does not exist. diff --git a/goga/assets/skills/goga-plan/SKILL.md b/goga/assets/skills/goga-plan/SKILL.md index f659f259..4dce218d 100644 --- a/goga/assets/skills/goga-plan/SKILL.md +++ b/goga/assets/skills/goga-plan/SKILL.md @@ -20,6 +20,6 @@ Determine ``: - **Single file** — use its topic directory name as ``. - **Multiple files** — display the list via AskUserQuestion and prompt the user to select one. -Check if `.goga/history///design.md` exists (search `.goga/history/*//design.md` for a 4-digit year). +Check if `.goga/history/*//design.md` exists (4-digit year). **Does not exist** — stop and ask the user to run `/goga:design` first. **Exists** — call `goga-plan-by-design` via the **Skill tool** with `` as the argument. diff --git a/goga/assets/skills/goga-propose/SKILL.md b/goga/assets/skills/goga-propose/SKILL.md index 41bb1721..fd9540b7 100644 --- a/goga/assets/skills/goga-propose/SKILL.md +++ b/goga/assets/skills/goga-propose/SKILL.md @@ -20,6 +20,6 @@ Use the **Skill tool** to invoke `goga-task-by-proposing` with the arguments as Arguments: $ARGUMENTS -The skill formulates the task and saves the artifact to `.goga/history///task.md`. +The skill formulates the task and saves the artifact to `.goga/history///task.md` (`` = current year, `YYYY`; create the directory lazily). --- diff --git a/goga/assets/skills/goga-review-arch/SKILL.md b/goga/assets/skills/goga-review-arch/SKILL.md index 4e48898d..309844f7 100644 --- a/goga/assets/skills/goga-review-arch/SKILL.md +++ b/goga/assets/skills/goga-review-arch/SKILL.md @@ -23,7 +23,7 @@ all types, domains, and requirements as a unified whole — not each CODEMANIFES ## Input - **Required**: architecture plan at `.goga/history///arch.md` -- **Optional**: task file at `.goga/history///task.md` — when present, used to verify requirements coverage. The year may differ from the architecture plan's year; locate the task file as `.goga/history/*//task.md` with a 4-digit year segment. +- **Optional**: task file at `.goga/history/*//task.md` (4-digit year) — when present, used to verify requirements coverage --- @@ -49,7 +49,7 @@ all types, domains, and requirements as a unified whole — not each CODEMANIFES - Classify plan cells: newly created vs. modified 6. Read the existing CODEMANIFESTs of cells the plan marks for modification 7. Read the existing `.usages/` files of cells marked for modification -8. If the task file `.goga/history/*//task.md` exists (4-digit year; the year may differ from the architecture plan's year) — read it for subsequent requirements coverage verification +8. If the task file `.goga/history/*//task.md` exists (4-digit year) — read it for subsequent requirements coverage verification --- @@ -233,7 +233,7 @@ Missing edge cases — log as **Medium**. #### Step 4. Task Requirements Coverage -If the task file `.goga/history///task.md` exists: +If the task file `.goga/history/*//task.md` exists (4-digit year): - Each requirement from the "Description" section must map to type(s) in the plan that fulfill it - Each acceptance criterion must have a contractual basis in the plan — the plan must enable fulfilling the criterion diff --git a/goga/assets/skills/goga-review-plan/SKILL.md b/goga/assets/skills/goga-review-plan/SKILL.md index f78dcf8a..205031cf 100644 --- a/goga/assets/skills/goga-review-plan/SKILL.md +++ b/goga/assets/skills/goga-review-plan/SKILL.md @@ -31,7 +31,7 @@ You **verify** the plan, **report** findings, and **fix** the plan upon discover Examples in other skills may use naming conventions of one language (e.g., snake_case), while the target language requires different conventions (e.g., PascalCase) — the language skill is the authoritative source for the target language. 2. Read the plan from `.goga/history///plan.md` -3. Read the design document from `.goga/history///design.md` — same topic directory; the year may differ, so search `.goga/history/*//design.md` with a 4-digit year +3. Read the design document from `.goga/history/*//design.md` (4-digit year) 4. Read all relevant `CODEMANIFEST` files referenced by the design document 5. Load the DSL specification and DSL application principles: - Invoke `goga-cell` via the **Skill tool** — obtain the DSL reference diff --git a/goga/assets/skills/goga-review/SKILL.md b/goga/assets/skills/goga-review/SKILL.md index dbea5d9d..ac2fbfd6 100644 --- a/goga/assets/skills/goga-review/SKILL.md +++ b/goga/assets/skills/goga-review/SKILL.md @@ -12,7 +12,7 @@ Arguments: $ARGUMENTS 1. **Arguments contain a path under `.goga/history/`** — the path must match `.goga/history///.md`: - - `` must be exactly 4 digits (`\d{4}`). A path like `.goga/history/26//...` is NOT a valid artifact path → treat as **cell**. + - `` must be 4 digits (`\d{4}`); otherwise the path is not a valid artifact path → treat as **cell**. - Derive the review type by `` (the filename without `.md`): - `prd.md` → **prd** - `adr.md` → **adr** @@ -40,18 +40,24 @@ Arguments: $ARGUMENTS ### Type-Based Routing +**prd** and **adr** have no dedicated review skills — there is nothing to route them to yet. + +#### prd / adr +There is no review skill for this artifact kind yet. +1. Stop execution and report to the user that PRD/ADR review is not supported. + #### architecture -Verify that `.goga/history///arch.md` exists (search `.goga/history/*//arch.md` for a `` that is exactly 4 digits). +Verify that `.goga/history///arch.md` exists — search `.goga/history/*//arch.md` (4-digit year). 1. **Not found** — stop execution and report to the user. 2. **Found** — invoke skill `goga-review-arch` via the **Skill tool**, passing `` as the argument. #### design -Verify that `.goga/history///design.md` exists (search `.goga/history/*//design.md` for a `` that is exactly 4 digits). +Verify that `.goga/history///design.md` exists — search `.goga/history/*//design.md` (4-digit year). 1. **Not found** — stop execution and report to the user. 2. **Found** — invoke skill `goga-review-design` via the **Skill tool**, passing `` as the argument. #### plan -Verify that `.goga/history///plan.md` exists (search `.goga/history/*//plan.md` for a `` that is exactly 4 digits). +Verify that `.goga/history///plan.md` exists — search `.goga/history/*//plan.md` (4-digit year). 1. **Not found** — stop execution and report to the user. 2. **Found** — invoke skill `goga-review-plan` via the **Skill tool**, passing `` as the argument. @@ -61,6 +67,6 @@ Verify that directory `` and file `/CODEMANIFEST` both exist. 2. **Found** — invoke skill `goga-review-cell` via the **Skill tool**, passing `` as the argument. #### task -Verify that `.goga/history///task.md` exists (search `.goga/history/*//task.md` for a `` that is exactly 4 digits). +Verify that `.goga/history///task.md` exists — search `.goga/history/*//task.md` (4-digit year). 1. **Not found** — stop execution and report to the user. 2. **Found** — invoke skill `goga-review-task` via the **Skill tool**, passing `` as the argument. diff --git a/goga/assets/skills/goga-task-by-proposing/SKILL.md b/goga/assets/skills/goga-task-by-proposing/SKILL.md index 622ed3eb..b0b670fe 100644 --- a/goga/assets/skills/goga-task-by-proposing/SKILL.md +++ b/goga/assets/skills/goga-task-by-proposing/SKILL.md @@ -7,7 +7,7 @@ description: Interactive task formulation from a raw request ## Purpose Transforms a raw user request (e.g., "add authorization") into a **formulated task** — a structured document containing -the description, technology stack, dependencies, and scope estimate. The output is persisted to `.goga/history///task.md` +the description, technology stack, dependencies, and scope estimate. The output is persisted to `.goga/history///task.md` (`` = current year, `YYYY`; create the directory lazily) and serves as input for the `goga-brainstorm` skill. --- @@ -164,9 +164,9 @@ If all external dependencies are covered by current usage files, skip this phase ### Phase 7: Task Persistence -**Objective:** Save the formulated task to `.goga/history///task.md` using the template. +**Objective:** Save the formulated task to `.goga/history///task.md` using the template (`` = current year, `YYYY`; create the directory lazily). -`` is a short name derived from the task topic (from the user's Phase 1 description). +`` — lowercase kebab-case slug derived from the task topic (from the user's Phase 1 description). 1. Read the `task-template.md` template from the current skill directory and apply its structure. From f9025b9458f6bae8ecc731e27253d837b17ce16f Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 21:20:43 +0300 Subject: [PATCH 018/229] fix: goga apply skill --- goga/assets/skills/goga-apply/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/goga/assets/skills/goga-apply/SKILL.md b/goga/assets/skills/goga-apply/SKILL.md index 4f2235e1..784846be 100644 --- a/goga/assets/skills/goga-apply/SKILL.md +++ b/goga/assets/skills/goga-apply/SKILL.md @@ -19,7 +19,7 @@ Retain the original arguments for the duration of the session. Resolve ``: 1. **Arguments supplied** — use the arguments as ``. -2. **No arguments** — scan `.goga/history/*/` topic directories for `arch.md` (4-digit year) and present the list via **AskUserQuestion**: +2. **No arguments** — scan `.goga/history/*/` topic directories for `arch.md` (4-digit year): - **Directory missing or empty** — halt and report the error. - **Single file** — use its topic directory name as ``. - **Multiple files** — present the list via AskUserQuestion and prompt for selection. From 713cbf322850e6e7160738cedc5c3496817cf91d Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 23:45:33 +0300 Subject: [PATCH 019/229] feat: separated review and goga history in gitignore --- .gitignore | 8 ++------ .goga/config.yml | 5 +++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 8865e97b..f9d8856e 100644 --- a/.gitignore +++ b/.gitignore @@ -222,11 +222,7 @@ __marimo__/ .ralphex/ docs/plans/ docs/design/ -docs/arch/ -docs/tasks/ -docs/defines/ -docs/proposals/ docs/superpowers/ -# Orchestration run artifacts -/plan.md +# Goga +.goga/history diff --git a/.goga/config.yml b/.goga/config.yml index dceba610..435011ed 100644 --- a/.goga/config.yml +++ b/.goga/config.yml @@ -13,6 +13,11 @@ build: env: <<: *claude-env ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" + review_executor: + agent: claude + env: + <<: *claude-env + ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3[1m]" pipeline: agent: claude From e0e33fa8c65496727d97fcf99cb5cfd64c7f2850 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 22:25:02 +0000 Subject: [PATCH 020/229] feat: contracts for pipeline -b/--branch preparation and install post-install hooks --- goga/commands/CODEMANIFEST | 9 + goga/commands/install/.usages/install.md | 262 ++++++------ goga/commands/install/CODEMANIFEST | 374 ++++++++++++------ .../pipeline/.usages/pipeline-command.md | 47 ++- goga/commands/pipeline/CODEMANIFEST | 216 +++++++++- 5 files changed, 664 insertions(+), 244 deletions(-) diff --git a/goga/commands/CODEMANIFEST b/goga/commands/CODEMANIFEST index 031bce18..082b303f 100644 --- a/goga/commands/CODEMANIFEST +++ b/goga/commands/CODEMANIFEST @@ -28,6 +28,8 @@ Imports: From: goga/commands/init - Types: - pipeline + Usages: + - pipeline-command From: goga/commands/pipeline - Types: - upgrade @@ -37,6 +39,7 @@ Imports: - uninstall Usages: - uninstall-usage + - install AS install-usage From: goga/commands/install Usages: @@ -54,6 +57,12 @@ Annotations: | command: confirmation behavior, flags, exit codes, and the post-removal re-sync. + Use the `pipeline-command` practice for consumer scenarios of the + pipeline command: the five forms, the branch preparation flow of + -b/--branch, and the flag behavior per form. + Use the `install-usage` practice for consumer scenarios of the install + command: the four modes, the post-install hooks, and the exit codes. + --- ->lint: {} diff --git a/goga/commands/install/.usages/install.md b/goga/commands/install/.usages/install.md index 42d35823..2822e78e 100644 --- a/goga/commands/install/.usages/install.md +++ b/goga/commands/install/.usages/install.md @@ -3,141 +3,174 @@ ## Overview The `goga install` command adds goga-tool packages into the current runtime -interpreter — the exact Python that runs goga — and then activates every -already-connected agent so the freshly installed skills and pipelines appear in -`~/.goga/` and in each agent's symlink tree. +interpreter — the exact Python that runs goga — then runs each installed +tool's optional post-install hook, and finally activates every +already-connected agent so the freshly installed skills and pipelines +appear in `~/.goga/` and in each agent's symlink tree. The command operates in four modes: -- **Single mode** (`goga install `): install one tool. `--version` resolves - via the four-form grammar; the project config is ignored. -- **Bulk mode** (`goga install`): install every tool declared in the `tools` - section of `.goga/config.yml`, in a single pip invocation, in YAML order. +- **Single mode** (`goga install `): install one tool. `--version` + resolves via the four-form grammar; the project config is ignored. +- **Bulk mode** (`goga install`): install every tool declared in the + `tools` section of `.goga/config.yml`, in a single pip invocation, in + YAML order. - **Empty mode** (`goga install` with no `tools` section): no-op, prints `Nothing to install`, exits 0. -- **Local mode** (`goga install --local ` / `-l `): pip-install a - local directory; mutually exclusive with `name` and `--version`. +- **Local mode** (`goga install --local ` / `-l `): pip-install + a local directory; mutually exclusive with `name` and `--version`. The + value is `` or `:`. -After a successful pip in single, local, and bulk mode, the command runs a post-install -activation: it re-syncs every agent recorded in `~/.goga/connect.yml` (using each -agent's persisted `force_overwrite`) so the new tool's skills and pipelines are -linked into place. Pass `--no-connect` to skip activation — the command performs -the install only (useful in CI/Docker where a non-zero activation must not fail -the install). Empty mode performs neither pip nor activation. +After a successful pip in single, local, and bulk mode the command runs +the post-install hooks (see below), then the activation re-sync of every +agent recorded in `~/.goga/connect.yml`. Pass `--no-connect` to skip the +activation only — the hooks still run. ## CLI Usage ### Single mode -```bash -# Plain install + activation — current user, no sudo, latest version -goga install foo + # Plain install + activation — current user, no sudo, latest version + goga install foo -# Install a specific concrete version, then activate -goga install foo --version 1.0.1 + # Install a specific concrete version, then activate + goga install foo --version 1.0.1 -# Short alias form — identical to the line above -goga install foo -v 1.0.1 + # Short alias form — identical to the line above + goga install foo -v 1.0.1 -# Install within a minor x-range (>=1.0.0, <1.1.0) -goga install foo --version 1.0.x + # Install within a minor x-range (>=1.0.0, <1.1.0) + goga install foo --version 1.0.x -# Install with sudo (system-Python installs requiring root); activation runs -# without sudo against the preserved $HOME -goga install foo --sudo + # Install with sudo (system-Python installs requiring root); hooks and + # activation run without sudo, the hook receives SUDO_USER + goga install foo --sudo -# Install only — skip activation (escape-hatch for CI/Docker) -goga install foo --no-connect -``` + # Install only — skip activation; the post-install hook still runs + goga install foo --no-connect ### Bulk mode Declare tools in `.goga/config.yml`: -```yaml -tools: - viewer: latest # → no specifier (pip selects newest) - afm: 1.0.x # → ~=1.0.0 (minor x-range) - ralphex: 1.x # → ~=1.0 (major x-range) - go: 1.0.1 # → ==1.0.1 (concrete pin) -``` + tools: + viewer: latest # → no specifier (pip selects newest) + afm: 1.0.x # → ~=1.0.0 (minor x-range) + ralphex: 1.x # → ~=1.0 (major x-range) + go: 1.0.1 # → ==1.0.1 (concrete pin) -Then install and activate in a single command: +Then install in a single command: -```bash -# Install every declared tool, then one activation pass over connect.yml -goga install + # Install every declared tool, run each tool's hook in YAML order, + # then one activation pass + goga install -# Same, but pip under sudo with HOME preserved -goga install --sudo + # Same, but pip under sudo with HOME preserved + goga install --sudo -# Bulk install only, no activation -goga install --no-connect -``` + # Bulk install with hooks, no activation + goga install --no-connect -Bulk mode issues **exactly one** `pip install` call whose argv contains every -resolved `goga-tool-` in YAML order, followed by one activation pass. +Bulk mode issues **exactly one** `pip install` call whose argv contains +every resolved `goga-tool-` in YAML order; hooks run per tool +in the same order, then one activation pass. ### Local mode Install a goga-tool from a local source directory instead of PyPI: -```bash -# Install the package located at ./my-tool, then activate -goga install --local ./my-tool + # Install the package located at ./my-tool, then activation. + # No hook runs — a warning names the way to enable it + goga install --local ./my-tool -# Short alias form — identical -goga install -l ./my-tool + # Same path with the : suffix — the post-install hook of + # goga_tool_mytool runs after pip + goga install --local ./my-tool:mytool -# Local install only, no activation -goga install --local ./my-tool --no-connect + # Short alias form — identical + goga install -l ./my-tool:mytool -# Local install under sudo (system-Python); activation runs without sudo -goga install --local ./my-tool --sudo -``` + # Local install only, no activation; the hook still runs (suffix present) + goga install --local ./my-tool:mytool --no-connect + + # Local install under sudo (system-Python); hooks and activation run + # without sudo, the hook receives SUDO_USER + goga install --local ./my-tool:mytool --sudo Local mode issues exactly one `pip install -U` against the current -interpreter, then activates every agent in ~/.goga/connect.yml by the same rules -as single/bulk mode (suppressed by `--no-connect`). Pip's return code is -translated unchanged — a missing or non-installable path surfaces as pip's own -non-zero exit code. +interpreter, then the hook of the suffixed tool (when given), then +activation by the same rules as single/bulk mode (suppressed by +`--no-connect`). Pip's return code is translated unchanged — a missing or +non-installable path surfaces as pip's own non-zero exit code. Constraints: -- `name` and `--local` are mutually exclusive (`goga install foo --local ./x` - exits 1). +- `name` and `--local` are mutually exclusive (`goga install foo --local + ./x` exits 1). - `--version` is rejected in local mode (`goga install --local ./x -v 1.0.1` exits 1) — versions apply to PyPI packages only. +- A malformed suffix (empty tool name, a path separator, an extra colon) + exits 1 before any pip. - Editable installs (`-e`) are not performed. ### Empty mode When the `tools` section is absent or empty in `.goga/config.yml`: -```bash -goga install -# stdout: Nothing to install -# exit code: 0 -``` + goga install + # stdout: Nothing to install + # exit code: 0 -Neither pip nor activation is invoked. +Neither pip, nor hooks, nor activation is invoked. ## Options | Option | Type | Default | Purpose | |---|---|---|---| | `name` (positional, optional) | string | None | Tool name without the goga-tool- / goga_tool_ prefix. When absent, bulk/empty mode runs from `config.tools`. | -| `--sudo` | flag | False | Run pip under `sudo --preserve-env=HOME` (Unix-only). Applies to pip only; activation never uses sudo. | -| `--version
`, `-v ` | string | None | Version form in the four-form grammar. Used by single mode only; ignored in bulk mode. Both forms are aliases on the same option — `-v 1.0.x` is identical to `--version 1.0.x`. | -| `--local `, `-l ` | string | None | Path to a pip-installable local directory. When set (and `name` is absent), installs from the local path instead of PyPI. Mutually exclusive with `name`; `--version` is rejected in this mode. `-l ./my-tool` is identical to `--local ./my-tool`. | -| `--no-connect` | flag | False | Skip post-install activation. When set, the command performs the install only and the exit code is pip's. | +| `--sudo` | flag | False | Run pip under `sudo --preserve-env=HOME` (Unix-only). Applies to pip only; hooks and activation never use sudo. | +| `--version `, `-v ` | string | None | Version form in the four-form grammar. Single mode only. Both forms are aliases on the same option — `-v 1.0.x` is identical to `--version 1.0.x`. | +| `--local [:]`, `-l` | string | None | Pip-installable local directory, optionally followed by `:` — the name of the tool whose `install()` hook should run. Without the suffix no hook runs (a warning names the suffix as the way to enable it). `-l ./x:foo` is identical to `--local ./x:foo`. | +| `--no-connect` | flag | False | Skip the post-install activation re-sync only. Hooks still run after a successful pip. | + +## Post-install Hooks + +After a successful pip install (single, local with a `:` +suffix, and bulk), the command imports each installed tool's facade module +`goga_tool_` and calls its `install` callable when one exists: + +- No facade module or no callable `install` → quiet skip (the hook is + optional; existing tools without a hook install exactly as before). +- The hook's signature declares a keyword-capable parameter `user` → it is + called as `install(user=)`; otherwise it is called with + no arguments. +- The initiating user is `SUDO_USER` when the install runs under sudo, + otherwise the current OS user — the actual initiator, not root. What the + tool does with the string (chown, per-user config, git identity) is the + tool's business; goga does not re-execute the hook as that user. +- Hook failure (an exception from the hook body): non-zero exit with the + tool name and the hook message; the pip package stays installed (no + rollback); the activation re-sync does not run. In bulk mode the + sequence stops at the first failing hook — the remaining tools' hooks + are not called. +- Hooks run before the activation re-sync; `--no-connect` suppresses only + the re-sync. +- Hooks are not run by `uninstall` or `upgrade`. + +A tool with a hook looks like this: + + # inside the goga_tool_mytool facade package + def install(user: str | None = None) -> None: + ... # tool-owned setup; `user` receives the initiating user + # only when the parameter is declared keyword-capable ## Post-install Activation -When pip succeeds in single, local, or bulk mode and `--no-connect` is not set, the -command activates every agent listed in `~/.goga/connect.yml`, each with its own -recorded `force_overwrite`. Activation is a local operation on `$HOME` and never -runs under `--sudo`. A missing or empty registry is a no-op that returns 0: the -tool is installed on the interpreter but not yet linked to any agent — connect an +When pip and hooks succeed in single, local, or bulk mode and +`--no-connect` is not set, the command activates every agent listed in +`~/.goga/connect.yml`, each with its own recorded `force_overwrite`. +Activation is a local operation on `$HOME` and never runs under `--sudo`. +A missing or empty registry is a no-op that returns 0: the tool is +installed on the interpreter but not yet linked to any agent — connect an agent later with `goga connect ` and the tool will be picked up. ## Version Form Grammar @@ -154,53 +187,47 @@ specifiers as follows: | Latest marker | `latest` | *no specifier* | pip selects newest under `-U` | | (absent `--version`) | — | *no specifier* | same as `latest` (single mode only) | -Rejected forms: operator-prefixed (`==1.0`, `>=1.0`), malformed (`1.x.0`), and -YAML-null `tools` values (e.g. `viewer:`) — each exits non-zero with a clear error. +Rejected forms: operator-prefixed (`==1.0`, `>=1.0`), malformed (`1.x.0`), +and YAML-null `tools` values (e.g. `viewer:`) — each exits non-zero with a +clear error. ## Python API -```python -from goga.commands.install.install import install + from goga.commands.install.install import install -# Click commands are normally invoked via the CLI. For testing or programmatic -# invocation, use click.testing.CliRunner — see .goga/usages/cooks/click.md. -``` + # Click commands are normally invoked via the CLI. For testing or + # programmatic invocation, use click.testing.CliRunner to drive the + # command in-process. ## Return Values | Exit code | Condition | |---|---| -| 0 | pip succeeded and activation succeeded (or registry missing/empty), or empty mode no-op | -| non-zero (pip) | pip failed — its returncode propagated verbatim; activation is not run | -| non-zero (activation) | pip succeeded but activation failed for one or more agents — the first non-zero per-agent failure is returned | -| 1 (`ClickException`) | a version form was rejected, or `load_project_config` failed in bulk/empty mode | -| 1 (ClickException) | `name` + `--local` combined, or `--version` supplied in local mode | - -With `--no-connect`, the exit code is always pip's (install-only semantics). +| 0 | pip succeeded, all hooks succeeded (or none applied), and activation succeeded (or registry missing/empty, or `--no-connect`); or empty mode no-op | +| non-zero (pip) | pip failed — its returncode propagated verbatim; no hooks, no activation | +| non-zero (hook) | pip succeeded but a hook raised — tool name + message; no rollback, no activation; bulk stops at the first failing hook | +| non-zero (activation) | pip and hooks succeeded but activation failed for one or more agents — the first non-zero per-agent failure is returned | +| 1 (`ClickException`) | a version form was rejected; `load_project_config` failed in bulk/empty mode; `name` + `--local` combined; `--version` in local mode; malformed `--local` suffix (empty tool name, path separator, extra colon) | ## Side Effects -- Runs pip as a subprocess of the current interpreter (network/disk activity; - may require root under system-Python installs). -- The installed package(s) become importable in the running interpreter's - environment. +- Runs pip as a subprocess of the current interpreter (network/disk + activity; may require root under system-Python installs). +- Runs each installed tool's `install()` hook — arbitrary code provided by + the installed package, with the trust level of any pip package. - On success (and without `--no-connect`), activates every agent in - `~/.goga/connect.yml`: recreates central assets, downloads `dsl.md`, creates - agent symlinks, and refreshes pipelines — via the shared activation routine. -- During activation, emits a `Re-syncing registered agent(s): ` banner - to stderr followed by a `Connecting agent: ` line per agent. A missing - or empty registry is a silent no-op (no banner). -- Bulk mode performs exactly one `subprocess.run` regardless of how many tools - are declared. + `~/.goga/connect.yml` via the shared activation routine. +- A local install without the `:` suffix logs a warning that no + hook will run and how to enable it. ## Preconditions - The current interpreter (`sys.executable`) must be the one where goga is installed. -- The caller must have write access to the site-packages directory (or pass - `--sudo`) and to `~/.goga/` for activation. -- For bulk mode, `.goga/config.yml` must exist and contain a `tools` section - (otherwise empty mode runs — a no-op, not an error). +- The caller must have write access to the site-packages directory (or + pass `--sudo`) and to `~/.goga/` for activation. +- For bulk mode, `.goga/config.yml` must exist and contain a `tools` + section (otherwise empty mode runs — a no-op, not an error). - On Windows, `--sudo` is unavailable (sudo is Unix-only). ## Anti-patterns @@ -210,9 +237,16 @@ With `--no-connect`, the exit code is always pip's (install-only semantics). - Do not declare `tools:` values with YAML-null (`viewer:`) — write `viewer: latest`. - Do not bypass the command and call `pip` with `sudo` directly without - `--preserve-env=HOME`: post-install activation depends on reading the caller's - `$HOME`. -- Do not expect `--version` to apply in bulk mode — it is ignored when `name` - is absent. -- In CI/Docker where a transient activation failure must not fail the install, - pass `--no-connect` to keep install-only exit semantics. + `--preserve-env=HOME`: the hook's initiating-user resolution and the + post-install activation depend on the caller's `$HOME`. +- Do not expect `--version` to apply in bulk mode — it is ignored when + `name` is absent. +- Do not expect a hook for a local install without the suffix — goga does + not guess the tool name from the path; pass + `--local ./my-tool:mytool` explicitly. +- Do not expect `--no-connect` to suppress hooks — it suppresses the + activation re-sync only. +- Do not rely on a pip rollback after a hook failure — the package stays + installed; fix the tool or uninstall it. +- In CI/Docker where a transient activation failure must not fail the + install, pass `--no-connect` (hooks still gate the exit code). diff --git a/goga/commands/install/CODEMANIFEST b/goga/commands/install/CODEMANIFEST index edc467ac..3b47495b 100644 --- a/goga/commands/install/CODEMANIFEST +++ b/goga/commands/install/CODEMANIFEST @@ -54,41 +54,60 @@ Annotations: | pip invocation through the current interpreter, HOME resolution by user name, and goga-home path construction. + After a successful pip install in single, local, and bulk mode, every + installed tool gets its post-install hook: the goga_tool_ facade is + imported and its install callable, when present, runs with the initiating + user injected by signature. The hook step sits between pip and the agent + re-sync; the --no-connect flag suppresses only the re-sync — the hook runs + regardless. A hook failure fails the command with a non-zero exit, leaves + the pip install in place, and skips the re-sync; in bulk mode the sequence + stops at the first failing hook. + --- "install(ctx: click.Context, name: str | None, sudo: bool, version: str | None, local: str | None, no_connect: bool = False) -> exit_code: int": location: install.py annotations: | - Install one or more goga-tool packages into the current runtime interpreter - via pip and, on success, activate every already-connected agent. Branches - across four paths: single (one named tool from PyPI with an optional - four-form version), bulk (every tool declared in config.tools in a single - pip invocation), empty (Nothing to install), and LOCAL (one pip-installable - local directory). After a successful pip in single, local, and bulk mode, - runs the activation re-sync unless `no_connect` is set. Propagates pip's - returncode as the exit code whenever pip is invoked or re-sync is skipped; - otherwise propagates the re-sync outcome (first non-zero per-agent failure). - - `exit_code`: pip's outcome when pip is invoked or re-sync is skipped; the - first non-zero per-agent re-sync failure otherwise; 0 in the empty path. + Install one or more goga-tool packages into the current runtime + interpreter via pip, run every installed tool's post-install hook, and + on success activate every already-connected agent. Branches across four + paths: single (one named tool from PyPI with an optional four-form + version), bulk (every tool declared in config.tools in a single pip + invocation), empty (Nothing to install), and LOCAL (one pip-installable + local directory). After a successful pip in single, local, and bulk + mode the post-install hooks run, then the activation re-sync unless + `no_connect` is set. Propagates pip's returncode as the exit code + whenever pip is invoked or re-sync is skipped; a hook failure is a + user-facing non-zero exit; otherwise propagates the re-sync outcome + (first non-zero per-agent failure). + + `exit_code`: pip's outcome when pip failed or re-sync is skipped; a + user-facing non-zero code when a hook raised; the first non-zero + per-agent re-sync failure otherwise; 0 in the empty path. `ctx`: Click execution context used to control process exit codes. - `name`: optional tool identifier without the goga-tool- / goga_tool_ prefix - (CLI positional argument). When present, the single path runs and the - config is ignored. When absent, the bulk/empty path runs from cfg.tools. + `name`: optional tool identifier without the goga-tool- / goga_tool_ + prefix (CLI positional argument). When present, the single path runs + and the config is ignored. When absent, the bulk/empty path runs from + cfg.tools. `sudo`: when True, run pip under sudo with HOME preserved (Unix-only). - `version`: optional version-form string in the four-form grammar. Used by - the single path only (ignored in the bulk path). Resolved by - `resolve_version`; operator-prefixed or malformed forms raise ValueError - at resolution time. The CLI flag binding the callback's `version` - parameter MUST expose both forms: the primary long form --version and - the secondary short alias -v — Click receives them on a single Option - so both deserialise into the same parameter. - `local`: optional path to a pip-installable local directory. When set (and - `name` is None), the LOCAL path runs: pip installs from the local - directory instead of resolving goga-tool- from PyPI. Mutually - exclusive with `name`; `version` is rejected in this mode. - `no_connect`: when True, skip the post-install activation re-sync — the - command performs the pip install only. Defaults to False (re-sync enabled). + Hooks and activation never run under sudo. + `version`: optional version-form string in the four-form grammar. Used + by the single path only (ignored in the bulk path). Resolved by + `resolve_version`; operator-prefixed or malformed forms raise + ValueError at resolution time. The CLI flag binding the callback's + `version` parameter MUST expose both forms: the primary long form + --version and the secondary short alias -v — Click receives them on a + single Option so both deserialise into the same parameter. + `local`: optional local source value: a pip-installable directory path + optionally followed by the : suffix naming the tool whose + post-install hook should run. When set (and `name` is None), the + LOCAL path runs: pip installs from the local directory instead of + resolving goga-tool- from PyPI. Mutually exclusive with `name`; + `version` is rejected in this mode. Without the suffix no hook runs + for the local install (a warning names the suffix as the way to + enable it). + `no_connect`: when True, skip the post-install activation re-sync only + — the hooks still run after a successful pip. Defaults to False. Algorithm: 0. VALIDATIONS (first, before any path): @@ -98,121 +117,238 @@ Annotations: | 0.2. If `local` is not None AND `version` is not None -> raise a user-facing ClickException (--version applies to the SINGLE path only and is meaningless for a local source); exit non-zero. + 0.3. If the `local` value carries the : suffix with an + empty name, a path separator, or another colon -> raise a + user-facing ClickException (malformed suffix); exit non-zero. 1. If `name` is not None → SINGLE PATH: 1.1. Resolve `version` via `resolve_version`; on rejection, surface a user-facing CLI exception with a non-zero exit - 1.2. Compose the package identifier from `name` and the resolved specifier - (empty when `resolve_version` returned None) - 1.3. Issue one pip install invocation against the current interpreter with - the composed identifier and an upgrade request; apply sudo with HOME - preservation when `sudo` is set - 1.4. Propagate pip's outcome as the pip exit code + 1.2. Compose the package identifier from `name` and the resolved + specifier (empty when `resolve_version` returned None) + 1.3. Issue one pip install invocation against the current + interpreter with the composed identifier and an upgrade + request; apply sudo with HOME preservation when `sudo` is set + 1.4. Propagate pip's outcome as the pip exit code; hooks target + list = [`name`] 2. Else if `local` is not None -> LOCAL PATH: - 2.1. Issue one pip install invocation against the current interpreter with - the local directory path as the install target and an upgrade request; - apply sudo with HOME preservation when `sudo` is set. Do NOT validate - path existence at the CLI layer — pip owns that error and its return - code is translated unchanged. - 2.2. Propagate pip's outcome as the pip exit code. + 2.1. Issue one pip install invocation against the current + interpreter with the local directory path as the install target + and an upgrade request; apply sudo with HOME preservation when + `sudo` is set. Do NOT validate path existence at the CLI layer + — pip owns that error and its return code is translated + unchanged. + 2.2. Propagate pip's outcome as the pip exit code. Hooks target + list = the suffix value, or an empty list with a + warning that the hook step is skipped and the : + suffix enables it. 3. If `name` is None → BULK / EMPTY PATH: - 3.1. Load configuration via `load_project_config`; the result is a `ProjectConfig` instance. - Loader exceptions (OSError, KeyError, ValueError, yaml.YAMLError) - MUST be wrapped into a user-facing ClickException (non-zero exit) — - never let a raw loader exception surface as a traceback. + 3.1. Load configuration via `load_project_config`; the result is a + `ProjectConfig` instance. Loader exceptions + (OSError, KeyError, ValueError, yaml.YAMLError) MUST be wrapped + into a user-facing ClickException (non-zero exit) — never let a + raw loader exception surface as a traceback. 3.2. Read the tools mapping from cfg.tools (treat None as empty) - 3.3. If the mapping is empty → EMPTY PATH: print "Nothing to install" to - stdout and exit 0 without invoking pip and without activation - 3.4. Else → BULK PATH: for each (tool_name, form) preserving insertion - order, resolve the form via `resolve_version` (surface rejection as a - user-facing CLI exception), compose the identifier, collect it; issue - exactly one pip install invocation with every collected identifier - and an upgrade request; apply sudo with HOME preservation when `sudo` - is set; propagate pip's outcome as the pip exit code - 4. ACTIVATION (single, local, and bulk paths only, after pip): - (LOCAL participates by the same rules as single/bulk) - 4.1. If `no_connect` is True → keep the pip exit code and stop - 4.2. If the pip exit code is non-zero → keep the pip exit code and stop - 4.3. Otherwise call `resync_registered_agents` with the current user's goga - home (~/.goga); the routine reads ~/.goga/connect.yml and re-activates - every recorded agent - 4.4. The final exit code becomes the re-sync outcome (0 on full success or - a missing/empty registry; otherwise the first non-zero per-agent failure) - - Apply `click` for the command shape, the optional positional argument, the - flags, the options (including the --local/-l secondary short alias on a - single Option, like --version/-v), the mutex/version ClickException, - ctx.pass_context, ctx.exit, click.echo for the empty-path message, and - exit-code propagation. Apply `convention` for the CLI command docstring rule - (--help rendered verbatim by Click; omit Args/Returns/Raises), import - discipline, and structured logging. Apply `project-configuration` for - `load_project_config` semantics and the no-validation contract on cfg.tools; - `project-configuration` is NOT used by the LOCAL path (config is ignored, - same as SINGLE). + 3.3. If the mapping is empty → EMPTY PATH: print "Nothing to + install" to stdout and exit 0 without invoking pip, without + hooks, and without activation + 3.4. Else → BULK PATH: for each (tool_name, form) preserving + insertion order, resolve the form via `resolve_version` (surface + rejection as a user-facing CLI exception), compose the + identifier, collect it; issue exactly one pip install + invocation with every collected identifier and an upgrade + request; apply sudo with HOME preservation when `sudo` is set; + propagate pip's outcome as the pip exit code. Hooks target list + = the config keys in YAML order. + 4. HOOKS (single, local, and bulk paths only, after a successful pip — + pip exit 0): + 4.1. Run `run_install_hooks` with the path's hooks target list + 4.2. A hook exception -> raise a user-facing ClickException carrying + the tool name and the hook message; exit non-zero; the pip + package stays installed; the activation re-sync does not run; + in bulk the sequence stops at the first failing hook — the + remaining tools' hooks are not called + 5. ACTIVATION (single, local, and bulk paths, after pip and hooks): + 5.1. If `no_connect` is True → keep the pip exit code and stop (the + hooks already ran in step 4) + 5.2. If the pip exit code is non-zero → keep the pip exit code and + stop (hooks did not run) + 5.3. Otherwise call `resync_registered_agents` with the current + user's goga home (~/.goga); the routine reads + ~/.goga/connect.yml and re-activates every recorded agent + 5.4. The final exit code becomes the re-sync outcome (0 on full + success or a missing/empty registry; otherwise the first + non-zero per-agent failure) + + Apply `click` for the command shape, the optional positional argument, + the flags, the options (paired long/short forms on a single Option), + the mutex/version ClickException, ctx.pass_context, ctx.exit, + click.echo for the empty-path message, and exit-code propagation. + Apply `convention` for the CLI command docstring rule (--help rendered + verbatim by Click; omit Args/Returns/Raises), import discipline, and + structured logging. Apply `project-configuration` for + `load_project_config` semantics and the no-validation contract on + cfg.tools; `project-configuration` is NOT used by the LOCAL path + (config is ignored, same as SINGLE). Requirements: - The single path MUST ignore cfg.tools entirely — name + flags fully determine the call - - The bulk path MUST issue exactly one pip invocation whose argv contains - every resolved goga-tool- in YAML order + - The bulk path MUST issue exactly one pip invocation whose argv + contains every resolved goga-tool- in YAML order - The empty path MUST print "Nothing to install" to stdout and exit 0 - without invoking pip and without activation + without invoking pip, without hooks, and without activation - The --version option is used by the single path only; the bulk path MUST NOT consult it - - The version flag MUST be registered with both the long form --version - and the short alias -v on the same Click Option (Click secondary - flag); both forms bind the callback's `version` parameter identically - - --sudo MUST apply sudo with HOME preservation to the (single) pip argv in - both single and bulk modes; activation never runs under sudo - - Activation MUST run only when pip succeeded (exit 0) in single or bulk - mode and `no_connect` is False; it MUST NOT run in the empty path or after - a non-zero pip - - The final exit code MUST equal the pip outcome when pip failed, when - `no_connect` is set, or in the empty path; otherwise it MUST equal the - activation re-sync outcome + - The version flag MUST be registered with both the long form + --version and the short alias -v on the same Click Option + - --sudo MUST apply sudo with HOME preservation to the (single) pip + argv in single, local, and bulk modes; hooks and activation never run + under sudo + - The `local` value grammar is or :; the suffix + tool name selects the facade module goga_tool_ for the + hooks step; a malformed suffix (empty name, path separator, extra + colon) is a validation error before any pip + - The hooks step MUST run in every pip path after a successful pip, + regardless of `no_connect` — the flag suppresses only the activation + re-sync + - A hook failure MUST produce a user-facing non-zero exit carrying the + tool name and the hook message; the pip package MUST NOT be rolled + back; the re-sync MUST NOT run + - In bulk mode the hooks sequence MUST stop at the first failing hook — + the remaining tools' hooks are not called + - A local install without the suffix MUST log a warning naming the way + to enable the hook (the : suffix) + - The final exit code MUST equal the pip outcome when pip failed or + `no_connect` is set, a user-facing non-zero code when a hook raised, + or the empty-path 0; otherwise it MUST equal the activation re-sync + outcome - pip MUST be invoked through the current interpreter with an upgrade request present in every invocation - - The LOCAL path MUST install exactly one local directory via a single pip - invocation with an upgrade request; the local path replaces the PyPI source + - The LOCAL path MUST install exactly one local directory via a single + pip invocation with an upgrade request and MUST translate pip's + return code unchanged - `name` and --local MUST be mutually exclusive — combining them is a user-facing error (non-zero exit) - - --version MUST be rejected in the LOCAL path (non-zero exit); SINGLE only - - The LOCAL path MUST participate in post-install activation by the same - rules as SINGLE/BULK: activation runs when pip succeeded (exit 0) and - `no_connect` is False; --no-connect suppresses it - - The LOCAL path MUST translate pip's return code unchanged (including pip's - own errors for a missing/non-installable path) - - --sudo MUST apply sudo with HOME preservation to the single pip argv in - the LOCAL path; activation never runs under sudo - - The --local/-l flag MUST be registered with both the long form --local - and the short alias -l on the same Click Option + - --version MUST be rejected in the LOCAL path (non-zero exit); SINGLE + only + - The --local/-l flag MUST be registered with both forms on the same + Click Option Constraints: - - Do NOT validate, parse, or modify `version` outside `resolve_version` — - `resolve_version` is the sole owner of the grammar and the single point - where malformed forms raise ValueError - - Do NOT accept operator-prefixed forms in either --version or cfg.tools — - they raise ValueError at resolution time + - Do NOT validate, parse, or modify `version` outside `resolve_version` + - Do NOT accept operator-prefixed forms in either --version or + cfg.tools - Do NOT install packages sequentially in the bulk path — all resolved packages MUST land in one pip argv - Do NOT auto-select sudo — the caller opts in via --sudo - - Do NOT run activation under sudo — activation operates on the local user - home; only pip honors --sudo + - Do NOT run hooks or activation under sudo — both operate on the local + user context; only pip honors --sudo - Do NOT write ~/.goga/connect.yml directly — activation goes through - `resync_registered_agents`; this command never writes the registry + `resync_registered_agents` - Do NOT probe whether the package is already installed — the upgrade request handles it - - Do NOT chunk the bulk argv — even a long argv is issued as a single pip - invocation + - Do NOT chunk the bulk argv — even a long argv is issued as a single + pip invocation - On Windows, --sudo is unavailable (sudo is Unix-only) - - Do NOT install in editable mode (-e) in the LOCAL path — install the - local directory the same way SINGLE/BULK install from PyPI (regular - install with -U) + - Do NOT install in editable mode (-e) in the LOCAL path - Do NOT validate the local path's existence at the CLI layer — let pip surface the error and translate its exit code - - Do NOT resolve a goga-tool- identifier in the LOCAL path — the local - directory is the install target as-is - - Do NOT consult `version` in the LOCAL path — it is rejected at validation - time + - Do NOT guess the tool name from the local path — no suffix, no hook + - Do NOT run the hook under a different user or re-execute it — goga + hands the initiating user over as a string only + - Do NOT run install hooks for uninstall or upgrade — the hook belongs + to the install command alone + - Do NOT roll the pip install back when a hook fails + +"resolve_initiating_user() -> user: str": + location: hook.py + annotations: | + Resolve the user who initiated the installation — the actual person, + not the root account the installer may run under. + + `user`: initiating user name + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. SUDO_USER is set and non-empty in the environment -> return its + value (the installation runs under sudo) + 2. Otherwise -> return the operating-system user name of the current + process + + Requirements: + - The value is only handed to the hook — the tool decides what to do + with it (ownership changes, per-user configuration); goga never + re-executes the hook on behalf of another user + + Constraints: + - Do not substitute a fallback name when a source cannot resolve — the + operating-system user name is the canonical answer + +"run_install_hooks(tools: list[str]) -> none: None": + location: hook.py + annotations: | + Run the post-install hook for every freshly installed tool. + + `tools`: names of the installed tools in installation order + + Apply the `convention` practice for docstring style, intra-package + imports, and structured logging. + + Algorithm: + 1. Resolve the initiating user once via `resolve_initiating_user` + 2. For each name in `tools` in order: run `call_install_hook` with the + name and the user; log the outcome — hook invoked or skipped + 3. A hook exception propagates: the loop stops and the hooks of the + remaining tools are not run + + Requirements: + - One initiating user for the whole call — every hook of the run sees + the same value + - An empty list is a quiet no-op + + Constraints: + - Do not swallow or downgrade a hook exception — the caller turns it + into the command failure + - Do not run the agent re-sync here — activation stays the command's + own step + +"call_install_hook(tool: str, user: str) -> invoked: bool": + location: hook.py + annotations: | + Run the post-install hook of one installed tool when it provides one. + + `tool`: tool name without the goga_tool_ prefix + `user`: initiating user to inject when the hook asks for it + `invoked`: True when the hook ran, False when it was skipped + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Import the facade module goga_tool_; a missing module -> False + (a quiet skip — a package without the facade convention is normal) + 2. A callable install on the facade is absent -> False (a quiet skip — + the hook is optional for every tool) + 3. The hook signature declares a keyword-capable parameter named user + -> call install(user=user); otherwise call install() + 4. An exception raised by the hook propagates unchanged + 5. A completed call -> True + + Requirements: + - The injection follows the signature-projection mechanism: only a + declared keyword-capable user parameter receives the value — the + offered-name set is the single source of the opt-in + - A skip is silent for the user and visible in the debug log only + + Constraints: + - Do not pass any argument other than user — the hook contract is a + bare call or a user-only call + - Do not isolate or sandbox the hook — it runs with the trust level of + the installed package + - Do not treat a missing facade module as an error — it is a normal + skip, not a hook failure "uninstall(ctx: click.Context, name: str, sudo: bool = False, yes: bool = False, target_user: str | None = None) -> exit_code: int": location: uninstall.py @@ -329,7 +465,7 @@ CreatedAt: 13/07/26 Description: | Lifecycle of goga-tool packages in the current runtime interpreter: - installation of one, many, or a local source via pip, and removal of one - tool via a confirmed forced pip uninstall. Every successful pip outcome is - followed by the shared agent re-sync, and pip's exit code always propagates - unchanged. + installation of one, many, or a local source via pip — each successful + install followed by the tool's optional post-install hook and the shared + agent re-sync — and removal of one tool via a confirmed forced pip + uninstall. pip's exit code always propagates unchanged. diff --git a/goga/commands/pipeline/.usages/pipeline-command.md b/goga/commands/pipeline/.usages/pipeline-command.md index ea6bb1a2..fde0b512 100644 --- a/goga/commands/pipeline/.usages/pipeline-command.md +++ b/goga/commands/pipeline/.usages/pipeline-command.md @@ -14,6 +14,7 @@ boundary to goga/pipeline is docker. | `goga pipeline --list --info` / `-l -i` | overview: every pipeline as a `* ` bullet block with indented `name:`/`description:` field lines | | `goga pipeline NAME --info` / `-i` | card of one pipeline: `name:`/`description:` fields, a `---` separator, then `* :` stage bullets with indented `title:` lines in execution order; nothing runs | | `goga pipeline NAME` | run | +| `goga pipeline -b BRANCH NAME` | prepare the branch, then run on it | `--list` and a name together is an error (mutually exclusive, clean message, exit 1). `--info` is a modifier, not a mode: without a name and without @@ -25,6 +26,7 @@ exit 1). `--info` is a modifier, not a mode: without a name and without |---|---|---| | -l / --list | flag | select the listing forms | | -i / --info | flag | show instead of act (overview with --list, card with NAME) | +| -b / --branch NAME | str | prepare a fresh branch + history topic before the run; run form only | | -w / --workflow NAME | str | apply an explicit workflow (run and card); the file must exist (early host validation) | | --no-workflow | flag | disable workflow resolution (run and card) | | -p / --parallel N | int | max concurrently executing stages; run only | @@ -32,10 +34,52 @@ exit 1). `--info` is a modifier, not a mode: without a name and without | -c / --clean | flag | wipe persistent afm state before launch; run only | | -u / --update | flag | refresh the image before the flat list and the run; no-op in the info forms | +## Branch preparation (-b/--branch) + +Run form only. `-l`, `-l -i`, and `NAME -i` silently skip the whole +procedure — passing `-b` there is not an error and does nothing. + +Order: the branch procedure runs after the argument-form validation and +before any docker activity (no image refresh, no first-run build, no +launch). An argument-form error (for example a missing pipeline name) wins: +no branch is created. + +The entered name plays two roles: + +- **branch name** — used exactly as entered when creating and switching + (`git switch -c `; git rejects invalid names itself); +- **history topic slug** — the normalized form that names the topic folder + `.goga/history///`: lowercase, non-ASCII dropped, anything + outside `[a-z0-9]` becomes `-`, repeat hyphens collapse, edge hyphens + trim (`Feature/Foo_Bar` → `feature-foo-bar`, `release/1.3.0` → + `release-1-3-0`). + +Occupancy = a local branch with the entered name, OR a remote-tracking +branch with the entered name, OR an existing `.goga/history///` +folder for the current year. + +- Interactive terminal: the reason is printed and a new name is prompted + until the name is free (Ctrl-C aborts, nothing is created); a fully + non-ASCII name (empty slug) is treated as invalid input and re-asked the + same way. +- No terminal (CI/scripts): the reason plus the hint to pass another name + via `-b` goes to stderr, exit code is non-zero, the pipeline does not + start. +- Already on the target branch (slug of the entered name equals the slug of + the current branch): nothing happens, the pipeline just runs. + +When the procedure completes (a created-and-switched branch or the +already-on-branch case), goga prints `Pipeline running on branch ` +to stdout once, before the launch; the list/info forms print no branch +line. + +After a successful `-b` run you stay on the new branch — goga does not +switch back. + ## Flag behavior in the list/info forms - Ignored (no-op, no side effects): `-e/--env`, `--proxy`, `-c/--clean`, - `-s/--skip`, `-p/--parallel`, `--add-host`. + `-s/--skip`, `-p/--parallel`, `--add-host`, `-b/--branch`. - `-u/--update`: works in `--list` without `--info`; no-op in both `--info` forms. - `-w/--workflow` and `--no-workflow`: validated as usual (exclusivity and, @@ -63,6 +107,7 @@ The user never authors the docker -p. ## Threading chains goga pipeline NAME → run (full shape) + goga pipeline -b feat/x NAME → ensure branch feat/x → run (full shape) goga pipeline --list → minimal shape: list goga pipeline --list --info → minimal shape: list --info goga pipeline NAME --info → minimal shape: run NAME --info [-w WF | --no-workflow] diff --git a/goga/commands/pipeline/CODEMANIFEST b/goga/commands/pipeline/CODEMANIFEST index e4674131..5051f0b3 100644 --- a/goga/commands/pipeline/CODEMANIFEST +++ b/goga/commands/pipeline/CODEMANIFEST @@ -43,6 +43,12 @@ Usages: afm: .goga/usages/cooks/afm.md agent-wrappers: .goga/usages/cooks/agent-as-claude-wrappers.md docker-auth-mounts: .goga/usages/cooks/docker-auth-mounts.md + git: | + External git binary invoked via subprocess.run (check=True, + capture_output=True). Set GIT_TERMINAL_PROMPT=0 in the env to suppress + interactive prompts. Read-only inspection (current branch name, branch ref + existence) plus one host-side mutation (create-and-switch to a new branch). + Mock the subprocess call in tests per `convention`. Annotations: | The `convention` practice is used for: @@ -103,14 +109,30 @@ Annotations: | check; the `docker-image-version` practice covers the image-side version probe. + The optional -b/--branch flag prepares a fresh branch and a fresh history + topic before the run form launches: the entered name is normalized into the + topic slug, occupancy is checked against three oracles (a local branch, a + remote-tracking branch, the history topic folder), and a free name creates + the branch on the host and switches to it. The procedure runs after the + argument-form validation and before any docker activity. The listing and + info forms silently skip the whole branch procedure. + + Use the `git` practice for every git invocation of the branch procedure — + read-only inspection and the single create-and-switch mutation. + Use the `click` practice for the -b/--branch option: a long form and a short + alias sharing a single Option, click.prompt for the re-ask cycle, and + exit-code propagation. + --- -"pipeline(ctx: click.Context, name: str | None, list_requested: bool, info: bool, extra_env: tuple[str, ...], proxy: str | None, add_host: tuple[str, ...], clean: bool, update: bool, workflow: str | None, no_workflow: bool, skip: tuple[str, ...], parallel: int | None)": +"pipeline(ctx: click.Context, name: str | None, list_requested: bool, info: bool, branch: str | None, extra_env: tuple[str, ...], proxy: str | None, add_host: tuple[str, ...], clean: bool, update: bool, workflow: str | None, no_workflow: bool, skip: tuple[str, ...], parallel: int | None)": location: pipeline.py annotations: | Single CLI command "goga pipeline" with five explicit forms. Every form launches the goga Docker container and invokes the in-container - entrypoint inside it; the host never reads pipeline files directly. + entrypoint inside it; the host never reads pipeline files directly. The + optional -b/--branch flag prepares a fresh git branch and a fresh + history topic before the run form starts. `ctx`: Click execution context, used to propagate exit codes (per the `click` practice) @@ -121,6 +143,12 @@ Annotations: | `info`: flag from the --info/-i click option — a modifier meaning "show instead of act": with `list_requested` → the overview, with `name` → the card; on its own it selects no form. + `branch`: optional branch name from the -b/--branch option (a long form + and a short alias on a single click option). Run form only: + prepares a fresh branch and a fresh history topic via + `ensure_pipeline_branch` before any docker activity. Silently + ignored in the flat list, overview, and card forms — not an + error. `extra_env`: raw KEY=VALUE strings from the repeatable -e/--env option, forwarded into the container env-file in the run form only. `proxy`: optional HTTP/HTTPS proxy URL from the --proxy option; when @@ -164,8 +192,8 @@ Annotations: | 2.2. `name` absent AND `list_requested` absent → the error 'Missing pipeline name. Use "goga pipeline --list" to list available pipelines, or provide a pipeline name.' to stderr, - exit 1, nothing to stdout, no image refresh and no first-run - build + exit 1, nothing to stdout, no branch procedure, no image + refresh and no first-run build 2.3. `workflow` provided AND `no_workflow` set → mutually-exclusive error (exit 1) 2.4. `workflow` provided → reject a name whose resolved path escapes @@ -174,7 +202,20 @@ Annotations: | resolve into the wider filesystem; then verify /.goga/workflows/.yml exists; a missing file is a clean error (exit 1) - 3. Dispatch by form: + 3. Branch procedure (run form only — `name` given, `list_requested` + False, `branch` given): bring the project onto a fresh branch via + `ensure_pipeline_branch`. When the procedure completed (a + created-and-switched branch or the already-on-branch case), print + 'Pipeline running on branch ' to stdout once, + immediately after the branch procedure and before the step-4 + dispatch; the forms that skip the procedure print no branch line. + Every git action happens on the host before any + docker activity. An input error (an empty slug) or an unresolved + conflict aborts the command with a non-zero exit before any image + refresh, build, or launch. The flat list, overview, and card forms + skip the procedure silently — passing -b there is not an error and + has no effect. + 4. Dispatch by form: - flat list — `run_pipeline_info_container` with name=None, info=False; `update` applies (image refresh before the listing) - overview — `run_pipeline_info_container` with name=None, info=True; @@ -186,14 +227,25 @@ Annotations: | per the flag matrix) - run — resolve the proxy (CLI over config) and the hosts (CLI over config), then `run_pipeline_container` with the full argument set - 4. Propagate the returned exit code via the click context (per the + 5. Propagate the returned exit code via the click context (per the `click` practice) Requirements: - Expose -l/--list and -i/--info as click flags alongside the run options; long and short forms behave identically - - Every step-2 check runs before any docker activity — an argument-form - error never refreshes, builds, or launches an image + - Register -b/--branch with both forms on a single click Option — both + bind the `branch` parameter identically + - When the branch procedure ran, print exactly one stdout line + 'Pipeline running on branch ' before the launch — in the + created-and-switched and the already-on-branch case alike; no branch + line in the flat list, overview, and card forms + - Every step-2 check runs before any git or docker activity — an + argument-form error never creates a branch, refreshes, builds, or + launches an image + - The branch procedure (step 3) runs before any docker activity; a + branch error never launches an image + - The listing and info forms silently ignore -b/--branch — no message, + no side effects - The listing and info forms silently ignore -e/--env, --proxy, -c/--clean, -s/--skip, -p/--parallel, and --add-host — no side effects; --clean deletes nothing @@ -217,6 +269,149 @@ Annotations: | - Do not validate the KEY=VALUE format, the "HOST:IP" format beyond the single-colon split, or the --skip stage names — forwarded as-is - Do not default or validate `parallel` + - Do not validate the branch name at the CLI layer — the branch + procedure and git own that + - Do not switch back to the previous branch after the launch — the run + finishes on the new branch + - Do not pass the branch name into the container — the container sees + the branch through the mounted project + +"normalize_topic_slug(name: str) -> slug: str": + location: branch.py + annotations: | + Normalize a branch name into the history topic slug. + + `name`: branch name as entered by the user + `slug`: history topic slug + + Algorithm: + 1. Lowercase the name + 2. Drop every non-ASCII character (no transliteration) + 3. Replace each remaining character outside [a-z0-9] with a hyphen + 4. Collapse repeat hyphens into one + 5. Trim leading and trailing hyphens + + Requirements: + - The grammar matches the skill-side topic grammar: + "Feature/Foo_Bar" -> "feature-foo-bar"; "release/1.3.0" -> + "release-1-3-0"; "aБb" -> "ab" (non-ASCII dropped before hyphen + replacement); a fully non-ASCII name -> empty slug + - Deterministic — same input always produces the same output + - Pure string transformation — no git, no filesystem, no side effects + + Constraints: + - Do not transliterate Cyrillic or any other script + - Do not return a fallback for an empty result — an empty slug is a + valid output; the caller owns the empty-slug decision + +"resolve_current_branch_name() -> branch: str | None": + location: branch.py + annotations: | + Read the current git branch name exactly as git reports it. + + `branch`: raw current branch name, or None when it cannot be determined + + Apply the `git` practice for the invocation pattern. + + Algorithm: + 1. Ask git for the current branch name + 2. A non-empty answer -> return it stripped, unmodified + 3. Detached HEAD, missing git binary, or a non-repository -> None + + Requirements: + - Read-only — no branch switch, no writes, no caching + - No slugification and no fallback value — both belong to the caller + + Constraints: + - Do not tolerate unexpected OS-level failures silently — the None + result covers only the documented failure modes + +"check_branch_occupancy(branch_name: str, slug: str, history_year: str) -> conflict: str | None": + location: branch.py + annotations: | + Decide whether the entered branch name and the topic slug are free. + + `branch_name`: branch name as entered (checked against git refs) + `slug`: normalized topic slug (checked against the history folder) + `history_year`: current year as YYYY (the caller owns the clock) + `conflict`: human-readable reason of the first occupied oracle, or None + when everything is free + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. A local branch ref for `branch_name` exists -> return the reason + 2. A remote-tracking ref for `branch_name` exists -> return the reason + (local remote-tracking refs only — no network call) + 3. The history topic folder .goga/history// exists + -> return the reason + 4. All three oracles are free -> None + + Requirements: + - The git oracles check the name as entered; the history oracle checks + the slug — the two may deliberately differ + - The first occupied oracle wins; remaining oracles are not probed + - Read-only — no ref or folder is created + + Constraints: + - Do not resolve remote state over the network — remote-tracking refs + only + - Do not create the history folder or any ref here + +"ensure_pipeline_branch(branch_name: str) -> branch: str": + location: branch.py + annotations: | + Bring the project onto a fresh branch with a fresh history topic before + a pipeline run. + + `branch_name`: branch name as entered by the user + `branch`: the final branch name — the entered one or the re-asked one; + the current branch name for the already-on-branch case + + Apply the `click` practice for click.prompt and exit-code propagation. + Apply the `git` practice for every git invocation. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Normalize `branch_name` via `normalize_topic_slug` and read the + current branch via `resolve_current_branch_name` + 2. Empty slug -> input error: + - interactive terminal: print the reason, prompt for a new name via + click.prompt, restart from step 1 with it + - no terminal: print the reason and the hint to pass another name + via -b to stderr, fail with a non-zero exit + 3. The current branch is known and its slug equals the entered slug -> + return the current branch name; no git action, no occupancy check + (a branch does not conflict with itself) + 4. `check_branch_occupancy` with the entered name, the slug, and the + current year returns a reason -> conflict: + - interactive terminal: print the reason, prompt for a new name, + restart from step 1 with it + - no terminal: print the reason and the hint to stderr, fail with a + non-zero exit + 5. Free name -> create the branch named exactly as entered on the host + and switch to it (git owns the name-validity error) + 6. Return the final branch name + + Requirements: + - The branch is created with the name exactly as entered; the history + topic name is the slug — the two may deliberately differ + - The whole procedure runs on the host, before any docker activity + - A re-ask cycle abort (Ctrl-C or closed input) leaves the repository + untouched — no branch is created, no switch happens + - After a successful create-and-switch the caller stays on the new + branch — no switch back + + Constraints: + - Do not validate branch-name characters — git rejects invalid names + itself + - Do not auto-pick suffixed names on a conflict — the user re-asks or + aborts + - Do not touch the runtime-segment branch grammar — the runtime paths + keep their own normalization and are not unified with the slug "run_pipeline_container(name: str, config: ProjectConfig, extra_env: tuple[str, ...], proxy: str | None, hosts: dict[str, str], clean: bool, update: bool, workflow: str | None, no_workflow: bool, skip: tuple[str, ...], parallel: int | None) -> exit_code: int": location: run_pipeline_container.py @@ -697,5 +892,6 @@ Description: | Host-side CLI wrapper cell for the single pipeline command. Every form — the flat list, the overview, the card, and the run — launches the goga Docker container and invokes the in-container pipeline entrypoint inside - it. The runtime boundary to the in-container pipeline is docker — this - cell has no Python Type Imports from it. + it; the run form can first prepare a fresh branch and history topic on + the host. The runtime boundary to the in-container pipeline is docker — + this cell has no Python Type Imports from it. From c4d782a6906d13446422a6b1d8aa214ddfb53b27 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 28 Aug 2026 02:01:45 +0300 Subject: [PATCH 021/229] fix: build script in development workflow --- .goga/workflows/development.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.goga/workflows/development.yml b/.goga/workflows/development.yml index 2f587aeb..4183aade 100644 --- a/.goga/workflows/development.yml +++ b/.goga/workflows/development.yml @@ -38,5 +38,8 @@ extend: after: - commit-changes timeout: "8h" - script: python3 -m goga.build .goga/history/$(date +%Y)/$(git branch --show-current)/plan.md + script: | + branch=$(git branch --show-current) + topic=$(python3 -c "import re,sys; print(re.sub(r'[^a-z0-9]+','-',sys.argv[1].lower()).strip('-'))" "$branch") + python3 -m goga.build ".goga/history/$(date +%Y)/$topic/plan.md" after_script: rm -rf .ralphex From f4efba091b99a37a0dbcd79e23b9a2451fd2307d Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 23:03:27 +0000 Subject: [PATCH 022/229] feat: add branch.py slug, current-branch, and occupancy primitives for pipeline -b --- goga/commands/pipeline/branch.py | 156 +++++++++++++++ tests/commands/pipeline/test_branch.py | 257 +++++++++++++++++++++++++ 2 files changed, 413 insertions(+) create mode 100644 goga/commands/pipeline/branch.py create mode 100644 tests/commands/pipeline/test_branch.py diff --git a/goga/commands/pipeline/branch.py b/goga/commands/pipeline/branch.py new file mode 100644 index 00000000..7e5ef813 --- /dev/null +++ b/goga/commands/pipeline/branch.py @@ -0,0 +1,156 @@ +"""Host-side branch primitives for the ``-b/--branch`` procedure of ``goga pipeline``. + +Three of the four branch routines declared in the cell CODEMANIFEST with +``location: branch.py``: the pure slug transformer, the git current-branch +reader, and the three-oracle occupancy check. Every git invocation follows the +``git`` practice — ``subprocess.run`` with ``check=True``, captured output, and +``GIT_TERMINAL_PROMPT=0`` in the environment. All oracles are read-only; the +single host-side mutation (create-and-switch) belongs to +``ensure_pipeline_branch`` and is not part of this module's read-only surface. +""" + +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import Path + + +def normalize_topic_slug(name: str) -> str: + """Normalize a branch name into the history topic slug. + + Deterministic pure string transformation: lowercase the name, drop every + non-ASCII character (no transliteration), replace each remaining character + outside ``[a-z0-9]`` with a hyphen, collapse repeat hyphens into one, and + trim leading and trailing hyphens. Lowercasing happens BEFORE the ASCII + filter, so a name like ``"aБb"`` yields ``"ab"`` and the Turkish dotted + capital ``"İ"`` lowercases to ``"i"`` plus a combining dot that the filter + drops. + + A fully non-ASCII or all-separator name yields the empty string — a valid + output. No fallback is returned for an empty result; the caller owns the + empty-slug decision. + + Args: + name: Branch name as entered by the user. + + Returns: + The history topic slug (possibly empty). No git, no filesystem, no + side effects. + """ + lowered = name.lower() + ascii_only = "".join(character for character in lowered if character.isascii()) + hyphened = re.sub(r"[^a-z0-9]", "-", ascii_only) + collapsed = re.sub(r"-{2,}", "-", hyphened) + return collapsed.strip("-") + + +def resolve_current_branch_name() -> str | None: + """Read the current git branch name exactly as git reports it. + + Asks git via ``git branch --show-current`` (per the ``git`` practice) and + returns the stripped answer unmodified — no slugification, no fallback + value; both belong to the caller. ``None`` covers only the three documented + failure modes: detached HEAD (an empty git answer), a missing git binary + (``FileNotFoundError``), and a non-repository (a non-zero git exit). + Read-only; the result is not cached — each call asks git anew. + + Returns: + The raw current branch name (stripped, unmodified), or ``None`` when it + cannot be determined. + + Raises: + OSError: unexpected OS-level failures of the git invocation (e.g. a + ``PermissionError``); the ``None`` result covers only the + documented failure modes. + """ + try: + result = subprocess.run( + ["git", "branch", "--show-current"], + check=True, + capture_output=True, + text=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + return None + value = result.stdout.strip() + if value == "": + return None + return value + + +def check_branch_occupancy(branch_name: str, slug: str, history_year: str) -> str | None: + """Decide whether the entered branch name and the topic slug are free. + + Probes three oracles in order and returns the human-readable reason of the + first occupied one; remaining oracles are not probed: + + 1. a local branch ref for ``branch_name`` (exact full-ref verification via + ``git show-ref --verify`` — no glob ambiguity for names containing + ``/``); + 2. a remote-tracking ref for ``branch_name`` (local + ``git for-each-ref refs/remotes`` output only — no network call); + 3. the history topic folder ``.goga/history//`` — only + a DIRECTORY occupies a topic (a stray file named ```` does not). + + The git oracles check the name as entered; the history oracle checks the + slug — the two may deliberately differ (``release/1.3.0`` vs + ``release-1-3-0``). Read-only — no ref or folder is created. Occupancy + answers are not error paths: git infrastructure failures beyond the + occupancy semantics propagate. + + Args: + branch_name: Branch name as entered (checked against git refs). + slug: Normalized topic slug (checked against the history folder). + history_year: Current year as ``YYYY`` (the caller owns the clock). + + Returns: + The human-readable reason of the first occupied oracle, or ``None`` + when everything is free. + + Raises: + subprocess.CalledProcessError: when the remote-tracking-ref listing + itself fails (an infrastructure failure, not an occupancy answer). + OSError: unexpected OS-level failures of the git invocations (e.g. a + missing git binary). + """ + env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"} + + # Oracle 1 — local branch ref. A non-zero exit of the quiet --verify means + # "no such ref": free, not an error. + try: + subprocess.run( + ["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch_name}"], + check=True, + capture_output=True, + text=True, + env=env, + ) + except subprocess.CalledProcessError: + pass + else: + return f"branch '{branch_name}' already exists" + + # Oracle 2 — remote-tracking refs, local refs only (no network). A ref + # refs/remotes// matches when its branch part equals the + # entered name exactly (feat/x must not match feat/xy). + result = subprocess.run( + ["git", "for-each-ref", "--format=%(refname)", "refs/remotes"], + check=True, + capture_output=True, + text=True, + env=env, + ) + for line in result.stdout.splitlines(): + rest = line.removeprefix("refs/remotes/") + _remote, separator, branch = rest.partition("/") + if separator and branch == branch_name: + return f"remote-tracking branch '{branch_name}' already exists" + + # Oracle 3 — history topic folder. Only a directory occupies a topic. + topic_dir = Path.cwd() / ".goga" / "history" / history_year / slug + if topic_dir.is_dir(): + return f"history topic '.goga/history/{history_year}/{slug}' already exists" + return None diff --git a/tests/commands/pipeline/test_branch.py b/tests/commands/pipeline/test_branch.py new file mode 100644 index 00000000..0e51bcde --- /dev/null +++ b/tests/commands/pipeline/test_branch.py @@ -0,0 +1,257 @@ +"""Contract and logic tests for the three branch primitives declared in +``goga/commands/pipeline/CODEMANIFEST`` with ``location: branch.py``: + +- ``normalize_topic_slug(name: str) -> str`` — pure slug transformer +- ``resolve_current_branch_name() -> str | None`` — git reader with the three + documented None modes (detached HEAD, missing git binary, non-repository) +- ``check_branch_occupancy(branch_name, slug, history_year) -> str | None`` — + three-oracle occupancy check (local ref, remote-tracking ref, history topic) + +The fourth declared routine, ``ensure_pipeline_branch``, is covered by its own +task and is intentionally absent here. Git is mocked at the subprocess boundary +per the ``git`` practice — ``mock.patch.object(branch_module.subprocess, "run")`` +— never as a git double. +""" + +from __future__ import annotations + +import subprocess +import typing +from pathlib import Path +from unittest import mock + +import pytest +from goga.commands.pipeline import branch as branch_module + +# --- Git subprocess mocking helpers (the process boundary only) --- + + +class _GitResult: + """Minimal stand-in for a ``subprocess.CompletedProcess``.""" + + def __init__(self, returncode: int = 0, stdout: str = "", stderr: str = "") -> None: + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def _git_run_dispatch( + show_current: object = _GitResult(stdout="main\n"), + show_ref: object = subprocess.CalledProcessError(1, "git"), + for_each_ref: object = _GitResult(stdout=""), +) -> mock.Mock: + """Build a ``subprocess.run`` mock dispatching per git subcommand. + + ``show_current`` / ``show_ref`` / ``for_each_ref`` are either a + ``_GitResult`` (returned) or an exception instance/class (raised). + """ + outcomes = { + ("branch", "--show-current"): show_current, + ("show-ref",): show_ref, + ("for-each-ref",): for_each_ref, + } + + def _run(argv: list[str], **_kwargs: object) -> _GitResult: + for key, outcome in outcomes.items(): + if tuple(argv[1 : 1 + len(key)]) == key or tuple(argv[1:]) == key: + if isinstance(outcome, BaseException) or ( + isinstance(outcome, type) and issubclass(outcome, BaseException) + ): + raise outcome + return outcome + raise AssertionError(f"unexpected git argv in test: {argv!r}") + + return mock.Mock(side_effect=_run) + + +# --- Contract tests --- + + +class TestBranchContract: + def test_three_primitives_exist_and_are_callable(self) -> None: + """The three routines are defined on the branch module and callable.""" + assert callable(branch_module.normalize_topic_slug) + assert callable(branch_module.resolve_current_branch_name) + assert callable(branch_module.check_branch_occupancy) + + def test_normalize_topic_slug_signature(self) -> None: + """``normalize_topic_slug(name: str) -> str``.""" + hints = typing.get_type_hints(branch_module.normalize_topic_slug) + assert hints == {"name": str, "return": str} + + def test_resolve_current_branch_name_signature(self) -> None: + """``resolve_current_branch_name() -> str | None``.""" + hints = typing.get_type_hints(branch_module.resolve_current_branch_name) + assert hints["return"] == str | None + + def test_check_branch_occupancy_signature(self) -> None: + """``check_branch_occupancy(branch_name: str, slug: str, history_year: str) -> str | None``.""" + import inspect + + signature = inspect.signature(branch_module.check_branch_occupancy) + assert list(signature.parameters) == ["branch_name", "slug", "history_year"] + for parameter in signature.parameters.values(): + assert parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + hints = typing.get_type_hints(branch_module.check_branch_occupancy) + assert hints == { + "branch_name": str, + "slug": str, + "history_year": str, + "return": str | None, + } + + def test_resolve_current_branch_name_signature_parameters(self) -> None: + """``resolve_current_branch_name`` takes no parameters.""" + import inspect + + signature = inspect.signature(branch_module.resolve_current_branch_name) + assert list(signature.parameters) == [] + hints = typing.get_type_hints(branch_module.resolve_current_branch_name) + assert hints == {"return": str | None} + + def test_normalize_topic_slug_parameters(self) -> None: + """``normalize_topic_slug`` takes one positional-or-keyword ``name``.""" + import inspect + + signature = inspect.signature(branch_module.normalize_topic_slug) + assert list(signature.parameters) == ["name"] + assert signature.parameters["name"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + + +# --- Logic tests: normalize_topic_slug (pure transformer) --- + + +class TestNormalizeTopicSlug: + @pytest.mark.parametrize( + ("name", "expected"), + [ + ("Feature/Foo_Bar", "feature-foo-bar"), + ("release/1.3.0", "release-1-3-0"), + ("Релиз/Один", ""), + ("aБb", "ab"), + ("-a--b-", "a-b"), + ("My Tool", "my-tool"), + ("feat///x", "feat-x"), + ("UPPER", "upper"), + ("123", "123"), + ], + ) + def test_normalize_topic_slug_parametrized(self, name: str, expected: str) -> None: + """The grammar rows from the contract — deterministic pure transform.""" + assert branch_module.normalize_topic_slug(name) == expected + + def test_normalize_topic_slug_empty_result_is_valid_output(self) -> None: + """A fully non-ASCII name yields "" — no fallback, no raise.""" + assert branch_module.normalize_topic_slug("Релиз/Один") == "" + + +# --- Logic tests: resolve_current_branch_name (git reader) --- + + +class TestResolveCurrentBranchName: + def test_resolve_current_branch_name_returns_stripped_raw_name(self) -> None: + """The raw branch name is returned stripped and unmodified (no slugification).""" + result = _GitResult(returncode=0, stdout=" release/1.3.0\n") + with mock.patch.object(branch_module.subprocess, "run", return_value=result) as run_mock: + branch = branch_module.resolve_current_branch_name() + assert branch == "release/1.3.0" + assert run_mock.call_args.args[0] == ["git", "branch", "--show-current"] + assert run_mock.call_args.kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0" + + def test_resolve_current_branch_name_detached_head_returns_none(self) -> None: + """Detached HEAD — an empty git answer — yields None.""" + result = _GitResult(returncode=0, stdout="") + with mock.patch.object(branch_module.subprocess, "run", return_value=result): + assert branch_module.resolve_current_branch_name() is None + + def test_resolve_current_branch_name_not_a_repository_returns_none(self) -> None: + """A non-repository (non-zero git exit) yields None.""" + error = subprocess.CalledProcessError(128, "git") + with mock.patch.object(branch_module.subprocess, "run", side_effect=error): + assert branch_module.resolve_current_branch_name() is None + + def test_resolve_current_branch_name_missing_git_binary_returns_none(self) -> None: + """A missing git binary yields None.""" + with mock.patch.object(branch_module.subprocess, "run", side_effect=FileNotFoundError("git")): + assert branch_module.resolve_current_branch_name() is None + + def test_resolve_current_branch_name_unexpected_os_error_propagates(self) -> None: + """Unexpected OS-level failures are NOT swallowed — PermissionError propagates.""" + with ( + mock.patch.object(branch_module.subprocess, "run", side_effect=PermissionError("denied")), + pytest.raises(PermissionError), + ): + branch_module.resolve_current_branch_name() + + +# --- Logic tests: check_branch_occupancy (three oracles) --- + + +class TestCheckBranchOccupancy: + def test_check_branch_occupancy_local_ref_reports_reason(self) -> None: + """Oracle 1: an existing local branch ref reports the reason; later oracles not probed.""" + run_mock = _git_run_dispatch( + show_current=_GitResult(stdout="main\n"), + show_ref=_GitResult(returncode=0), + for_each_ref=_GitResult(stdout="refs/remotes/origin/feat/x\n"), + ) + with mock.patch.object(branch_module.subprocess, "run", run_mock): + conflict = branch_module.check_branch_occupancy("feat/x", "feat-x", "2026") + assert conflict == "branch 'feat/x' already exists" + probed = [call.args[0] for call in run_mock.call_args_list] + assert all(argv[1] != "for-each-ref" for argv in probed) + + def test_check_branch_occupancy_remote_tracking_ref_reports_reason(self) -> None: + """Oracle 2: an existing remote-tracking ref reports the reason (exact branch match).""" + run_mock = _git_run_dispatch( + show_current=_GitResult(stdout="main\n"), + show_ref=subprocess.CalledProcessError(1, "git"), + for_each_ref=_GitResult(stdout="refs/remotes/origin/feat/x\nrefs/remotes/origin/main\n"), + ) + with mock.patch.object(branch_module.subprocess, "run", run_mock): + conflict = branch_module.check_branch_occupancy("feat/x", "feat-x", "2026") + assert conflict == "remote-tracking branch 'feat/x' already exists" + + def test_check_branch_occupancy_remote_ref_no_prefix_match( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``feat/x`` must not match the remote branch ``feat/xy`` — exact equality only.""" + monkeypatch.chdir(tmp_path) + run_mock = _git_run_dispatch( + show_current=_GitResult(stdout="main\n"), + show_ref=subprocess.CalledProcessError(1, "git"), + for_each_ref=_GitResult(stdout="refs/remotes/origin/feat/xy\nrefs/remotes/origin/main\n"), + ) + with mock.patch.object(branch_module.subprocess, "run", run_mock): + conflict = branch_module.check_branch_occupancy("feat/x", "feat-x", "2026") + assert conflict is None + + def test_check_branch_occupancy_history_topic_folder_reports_reason( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Oracle 3: an existing history topic DIRECTORY (checked by slug) reports the reason.""" + (tmp_path / ".goga" / "history" / "2026" / "feat-x").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + run_mock = _git_run_dispatch( + show_current=_GitResult(stdout="main\n"), + show_ref=subprocess.CalledProcessError(1, "git"), + for_each_ref=_GitResult(stdout=""), + ) + with mock.patch.object(branch_module.subprocess, "run", run_mock): + conflict = branch_module.check_branch_occupancy("feat/x", "feat-x", "2026") + assert conflict == "history topic '.goga/history/2026/feat-x' already exists" + + def test_check_branch_occupancy_stray_file_is_not_a_topic( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A stray FILE named does not occupy a topic — only a directory does.""" + (tmp_path / ".goga" / "history" / "2026" / "feat-x").parent.mkdir(parents=True) + (tmp_path / ".goga" / "history" / "2026" / "feat-x").write_text("stray") + monkeypatch.chdir(tmp_path) + run_mock = _git_run_dispatch( + show_current=_GitResult(stdout="main\n"), + show_ref=subprocess.CalledProcessError(1, "git"), + for_each_ref=_GitResult(stdout=""), + ) + with mock.patch.object(branch_module.subprocess, "run", run_mock): + assert branch_module.check_branch_occupancy("feat/x", "feat-x", "2026") is None From 76f90461e66cb5bf16951ca3c812e1a12edabc77 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 23:09:40 +0000 Subject: [PATCH 023/229] feat: add ensure_pipeline_branch orchestrator for pipeline -b --- goga/commands/pipeline/branch.py | 116 ++++++++++++-- tests/commands/pipeline/test_branch.py | 201 ++++++++++++++++++++++++- 2 files changed, 300 insertions(+), 17 deletions(-) diff --git a/goga/commands/pipeline/branch.py b/goga/commands/pipeline/branch.py index 7e5ef813..3d69b73d 100644 --- a/goga/commands/pipeline/branch.py +++ b/goga/commands/pipeline/branch.py @@ -1,12 +1,13 @@ -"""Host-side branch primitives for the ``-b/--branch`` procedure of ``goga pipeline``. - -Three of the four branch routines declared in the cell CODEMANIFEST with -``location: branch.py``: the pure slug transformer, the git current-branch -reader, and the three-oracle occupancy check. Every git invocation follows the -``git`` practice — ``subprocess.run`` with ``check=True``, captured output, and -``GIT_TERMINAL_PROMPT=0`` in the environment. All oracles are read-only; the -single host-side mutation (create-and-switch) belongs to -``ensure_pipeline_branch`` and is not part of this module's read-only surface. +"""Host-side branch routines for the ``-b/--branch`` procedure of ``goga pipeline``. + +The four branch routines declared in the cell CODEMANIFEST with ``location: +branch.py``: the pure slug transformer, the git current-branch reader, the +three-oracle occupancy check, and the orchestrator of the whole branch +procedure. Every git invocation follows the ``git`` practice — +``subprocess.run`` with ``check=True``, captured output, and +``GIT_TERMINAL_PROMPT=0`` in the environment. The oracles are read-only; the +single host-side mutation (create-and-switch) is owned by +``ensure_pipeline_branch``. """ from __future__ import annotations @@ -14,8 +15,35 @@ import os import re import subprocess +import sys +from datetime import datetime from pathlib import Path +import click + +_GIT_REQUIRED_MESSAGE = "git is required for -b/--branch: git binary not found" +_REASK_HINT = "Pass another branch name via -b." + + +def _reask_branch_name(reason: str) -> str: + """Handle an unusable branch name: re-ask on a terminal, abort otherwise. + + Args: + reason: Human-readable reason the current name cannot be used. + + Returns: + The re-asked branch name (the caller restarts the procedure with it). + + Raises: + click.ClickException: without a terminal — the reason plus the ``-b`` + hint go to the user as a non-terminal abort. + click.Abort: Ctrl-C or EOF at the prompt. + """ + if not sys.stdin.isatty(): + raise click.ClickException(f"{reason} {_REASK_HINT}") + click.echo(reason, err=True) + return click.prompt("New branch name") + def normalize_topic_slug(name: str) -> str: """Normalize a branch name into the history topic slug. @@ -154,3 +182,73 @@ def check_branch_occupancy(branch_name: str, slug: str, history_year: str) -> st if topic_dir.is_dir(): return f"history topic '.goga/history/{history_year}/{slug}' already exists" return None + + +def ensure_pipeline_branch(branch_name: str) -> str: + """Bring the project onto a fresh branch with a fresh history topic. + + Composes the three primitives above: normalize the entered name into the + topic slug, read the current branch, and check occupancy against the three + oracles. A free name is created and switched to on the host exactly as + entered (``git switch -c`` — the single mutation; git owns name validity). + An unusable name (an empty slug or an occupancy conflict) re-asks on a + terminal — the cycle restarts from the top and validates the NEW name + fully — and aborts cleanly without one. The already-on-branch case (the + current branch's slug equals the entered slug) touches nothing and returns + the CURRENT branch name. + + Args: + branch_name: Branch name as entered by the user via ``-b/--branch``. + + Returns: + The final branch name — the entered one or the re-asked one after a + create-and-switch; the current branch name for the already-on-branch + case. + + Raises: + click.ClickException: an empty topic slug or an unresolved occupancy + conflict without a terminal, a failed create-and-switch (carrying + git's stderr), or a missing git binary. + click.Abort: Ctrl-C or EOF at the re-ask prompt — the repository is + left untouched. + """ + while True: + slug = normalize_topic_slug(branch_name) + current = resolve_current_branch_name() + + if slug == "": + reason = f"branch name '{branch_name}' normalizes to an empty topic slug" + branch_name = _reask_branch_name(reason) + continue + + if current is not None and normalize_topic_slug(current) == slug: + return current + + try: + # Local-timezone year — the history tree is organized by the host's + # calendar year; the bare now() shape is the mandated test mock target. + conflict = check_branch_occupancy( + branch_name, + slug, + f"{datetime.now().year:04d}", # noqa: DTZ005 + ) + except FileNotFoundError as exc: + raise click.ClickException(_GIT_REQUIRED_MESSAGE) from exc + if conflict is not None: + branch_name = _reask_branch_name(conflict) + continue + + try: + subprocess.run( + ["git", "switch", "-c", branch_name], + check=True, + capture_output=True, + text=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) + except FileNotFoundError as exc: + raise click.ClickException(_GIT_REQUIRED_MESSAGE) from exc + except subprocess.CalledProcessError as exc: + stderr = exc.stderr if isinstance(exc.stderr, str) else "" + raise click.ClickException(f"git failed to create branch {branch_name!r}: {stderr.strip()}") from exc + return branch_name diff --git a/tests/commands/pipeline/test_branch.py b/tests/commands/pipeline/test_branch.py index 0e51bcde..3b70180d 100644 --- a/tests/commands/pipeline/test_branch.py +++ b/tests/commands/pipeline/test_branch.py @@ -1,4 +1,4 @@ -"""Contract and logic tests for the three branch primitives declared in +"""Contract and logic tests for the branch routines declared in ``goga/commands/pipeline/CODEMANIFEST`` with ``location: branch.py``: - ``normalize_topic_slug(name: str) -> str`` — pure slug transformer @@ -6,11 +6,12 @@ documented None modes (detached HEAD, missing git binary, non-repository) - ``check_branch_occupancy(branch_name, slug, history_year) -> str | None`` — three-oracle occupancy check (local ref, remote-tracking ref, history topic) +- ``ensure_pipeline_branch(branch_name: str) -> str`` — the branch-procedure + orchestrator (re-ask cycle, non-terminal abort, no-git-host conversion, the + single create-and-switch mutation) -The fourth declared routine, ``ensure_pipeline_branch``, is covered by its own -task and is intentionally absent here. Git is mocked at the subprocess boundary -per the ``git`` practice — ``mock.patch.object(branch_module.subprocess, "run")`` -— never as a git double. +Git is mocked at the subprocess boundary per the ``git`` practice — +``mock.patch.object(branch_module.subprocess, "run")`` — never as a git double. """ from __future__ import annotations @@ -20,7 +21,9 @@ from pathlib import Path from unittest import mock +import click import pytest +from click.testing import CliRunner from goga.commands.pipeline import branch as branch_module # --- Git subprocess mocking helpers (the process boundary only) --- @@ -39,21 +42,32 @@ def _git_run_dispatch( show_current: object = _GitResult(stdout="main\n"), show_ref: object = subprocess.CalledProcessError(1, "git"), for_each_ref: object = _GitResult(stdout=""), + switch: object = _GitResult(returncode=0), ) -> mock.Mock: """Build a ``subprocess.run`` mock dispatching per git subcommand. - ``show_current`` / ``show_ref`` / ``for_each_ref`` are either a - ``_GitResult`` (returned) or an exception instance/class (raised). + ``show_current`` / ``show_ref`` / ``for_each_ref`` / ``switch`` are either a + ``_GitResult`` (returned) or an exception instance/class (raised). Any of + them may instead be a LIST of such outcomes consumed in call order + (exhausted → AssertionError) — for re-ask sequences where the same command + must answer differently per iteration. """ outcomes = { ("branch", "--show-current"): show_current, ("show-ref",): show_ref, ("for-each-ref",): for_each_ref, + ("switch",): switch, } + queues = {key: (list(value) if isinstance(value, list) else None) for key, value in outcomes.items()} def _run(argv: list[str], **_kwargs: object) -> _GitResult: - for key, outcome in outcomes.items(): + for key, default_outcome in outcomes.items(): if tuple(argv[1 : 1 + len(key)]) == key or tuple(argv[1:]) == key: + outcome = default_outcome + if queues[key] is not None: + if not queues[key]: + raise AssertionError(f"unexpected repeat of git argv in test: {argv!r}") + outcome = queues[key].pop(0) if isinstance(outcome, BaseException) or ( isinstance(outcome, type) and issubclass(outcome, BaseException) ): @@ -117,6 +131,31 @@ def test_normalize_topic_slug_parameters(self) -> None: assert list(signature.parameters) == ["name"] assert signature.parameters["name"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + def test_ensure_pipeline_branch_exists_and_is_callable(self) -> None: + """The orchestrator is defined on the branch module and callable.""" + assert callable(branch_module.ensure_pipeline_branch) + + def test_ensure_pipeline_branch_signature(self) -> None: + """``ensure_pipeline_branch(branch_name: str) -> str``.""" + import inspect + + signature = inspect.signature(branch_module.ensure_pipeline_branch) + assert list(signature.parameters) == ["branch_name"] + assert signature.parameters["branch_name"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + hints = typing.get_type_hints(branch_module.ensure_pipeline_branch) + assert hints == {"branch_name": str, "return": str} + + def test_ensure_pipeline_branch_free_name_returns_entered_name(self) -> None: + """A free name creates-and-switches and returns the entered name (str → str).""" + run_mock = _git_run_dispatch( + show_current=_GitResult(stdout="main\n"), + show_ref=subprocess.CalledProcessError(1, "git"), + for_each_ref=_GitResult(stdout=""), + switch=_GitResult(returncode=0), + ) + with mock.patch.object(branch_module.subprocess, "run", run_mock): + assert branch_module.ensure_pipeline_branch("feat/x") == "feat/x" + # --- Logic tests: normalize_topic_slug (pure transformer) --- @@ -255,3 +294,149 @@ def test_check_branch_occupancy_stray_file_is_not_a_topic( ) with mock.patch.object(branch_module.subprocess, "run", run_mock): assert branch_module.check_branch_occupancy("feat/x", "feat-x", "2026") is None + + +# --- Logic tests: ensure_pipeline_branch (the branch-procedure orchestrator) --- + + +def _switch_argv_calls(run_mock: mock.Mock) -> list[list[str]]: + """The recorded ``git switch`` argvs (usually asserted to be empty).""" + return [call.args[0] for call in run_mock.call_args_list if call.args[0][1] == "switch"] + + +class TestEnsurePipelineBranch: + def test_ensure_pipeline_branch_creates_and_switches_as_entered( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A free name is created with the ENTERED name (topic is the slug — duality).""" + monkeypatch.chdir(tmp_path) + run_mock = _git_run_dispatch( + show_current=_GitResult(stdout="main\n"), + show_ref=subprocess.CalledProcessError(1, "git"), + for_each_ref=_GitResult(stdout=""), + switch=_GitResult(returncode=0), + ) + with mock.patch.object(branch_module.subprocess, "run", run_mock): + assert branch_module.ensure_pipeline_branch("Feature/X") == "Feature/X" + assert run_mock.call_args.args[0] == ["git", "switch", "-c", "Feature/X"] + + def test_ensure_pipeline_branch_already_on_branch_returns_current_name(self) -> None: + """Slug equality with the current branch → the CURRENT name, one probe, no mutation.""" + run_mock = _git_run_dispatch(show_current=_GitResult(stdout="release/1.3.0\n")) + with mock.patch.object(branch_module.subprocess, "run", run_mock): + assert branch_module.ensure_pipeline_branch("release-1.3.0") == "release/1.3.0" + assert run_mock.call_count == 1 + assert run_mock.call_args.args[0] == ["git", "branch", "--show-current"] + + def test_ensure_pipeline_branch_empty_slug_no_tty_fails_with_hint(self) -> None: + """Empty slug without a terminal → ClickException with the reason and the -b hint.""" + run_mock = _git_run_dispatch() + with ( + mock.patch.object(branch_module.subprocess, "run", run_mock), + mock.patch.object(branch_module.sys.stdin, "isatty", return_value=False), + pytest.raises(click.ClickException) as excinfo, + ): + branch_module.ensure_pipeline_branch("Релиз") + message = str(excinfo.value) + assert "normalizes to an empty topic slug" in message + assert "Pass another branch name via -b." in message + assert _switch_argv_calls(run_mock) == [] + + def test_ensure_pipeline_branch_empty_slug_cli_semantics_stderr_exit_1(self) -> None: + """The ClickException surfaces as stderr + exit 1 through a click command.""" + + @click.command() + def _probe() -> None: + branch_module.ensure_pipeline_branch("Релиз") + + with mock.patch.object(branch_module.subprocess, "run", _git_run_dispatch()): + result = CliRunner().invoke(_probe, []) + assert result.exit_code == 1 + assert "normalizes to an empty topic slug" in result.stderr + assert "Pass another branch name via -b." in result.stderr + + def test_ensure_pipeline_branch_conflict_no_tty_fails_with_reason(self) -> None: + """A conflict without a terminal → ClickException with the oracle reason and hint.""" + run_mock = _git_run_dispatch(show_ref=_GitResult(returncode=0)) + with ( + mock.patch.object(branch_module.subprocess, "run", run_mock), + mock.patch.object(branch_module.sys.stdin, "isatty", return_value=False), + pytest.raises(click.ClickException) as excinfo, + ): + branch_module.ensure_pipeline_branch("feat/x") + message = str(excinfo.value) + assert "branch 'feat/x' already exists" in message + assert "Pass another branch name via -b." in message + assert _switch_argv_calls(run_mock) == [] + + def test_ensure_pipeline_branch_tty_reask_until_free(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """On a terminal an occupied name re-asks; the NEW name runs the FULL procedure.""" + monkeypatch.chdir(tmp_path) + run_mock = _git_run_dispatch( + show_current=_GitResult(stdout="main\n"), + show_ref=[_GitResult(returncode=0), subprocess.CalledProcessError(1, "git")], + for_each_ref=_GitResult(stdout=""), + switch=_GitResult(returncode=0), + ) + with ( + mock.patch.object(branch_module.subprocess, "run", run_mock), + mock.patch.object(branch_module.sys.stdin, "isatty", return_value=True), + mock.patch.object(branch_module.click, "prompt", return_value="feat/two") as prompt_mock, + ): + assert branch_module.ensure_pipeline_branch("feat/one") == "feat/two" + assert prompt_mock.call_count == 1 + assert run_mock.call_args.args[0] == ["git", "switch", "-c", "feat/two"] + + def test_ensure_pipeline_branch_abort_leaves_repository_untouched(self) -> None: + """Ctrl-C at the re-ask prompt propagates as click.Abort — no switch ever ran.""" + run_mock = _git_run_dispatch(show_ref=_GitResult(returncode=0)) + with ( + mock.patch.object(branch_module.subprocess, "run", run_mock), + mock.patch.object(branch_module.sys.stdin, "isatty", return_value=True), + mock.patch.object(branch_module.click, "prompt", side_effect=click.Abort()), + pytest.raises(click.Abort), + ): + branch_module.ensure_pipeline_branch("feat/x") + assert _switch_argv_calls(run_mock) == [] + + def test_ensure_pipeline_branch_git_rejects_invalid_name_surfaces_stderr(self) -> None: + """git owns name validity — its stderr is surfaced in the ClickException.""" + run_mock = _git_run_dispatch( + show_current=_GitResult(stdout="main\n"), + show_ref=subprocess.CalledProcessError(1, "git"), + for_each_ref=_GitResult(stdout=""), + switch=subprocess.CalledProcessError(128, "git", stderr="fatal: 'a b' is not a valid branch name"), + ) + with ( + mock.patch.object(branch_module.subprocess, "run", run_mock), + pytest.raises(click.ClickException) as excinfo, + ): + branch_module.ensure_pipeline_branch("a b") + message = str(excinfo.value) + assert "git failed to create branch" in message + assert "fatal: 'a b' is not a valid branch name" in message + + def test_ensure_pipeline_branch_missing_git_binary_fails_cleanly(self) -> None: + """A no-git host is a clean ClickException — never a traceback.""" + run_mock = mock.Mock(side_effect=FileNotFoundError("git")) + with ( + mock.patch.object(branch_module.subprocess, "run", run_mock), + pytest.raises(click.ClickException) as excinfo, + ): + branch_module.ensure_pipeline_branch("feat/x") + assert str(excinfo.value) == "git is required for -b/--branch: git binary not found" + assert _switch_argv_calls(run_mock) == [] + + def test_ensure_pipeline_branch_reask_validates_new_name_fully(self) -> None: + """The re-asked name re-runs slug + already-on-branch + occupancy — fully.""" + run_mock = _git_run_dispatch( + show_current=_GitResult(stdout="main\n"), + show_ref=[_GitResult(returncode=0)], + ) + with ( + mock.patch.object(branch_module.subprocess, "run", run_mock), + mock.patch.object(branch_module.sys.stdin, "isatty", return_value=True), + mock.patch.object(branch_module.click, "prompt", return_value="main"), + ): + assert branch_module.ensure_pipeline_branch("feat/one") == "main" + assert _switch_argv_calls(run_mock) == [] From aac5c4be34bb35e31a4703ca8c8cb0dd252feda9 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 23:14:13 +0000 Subject: [PATCH 024/229] feat: wire -b/--branch option and branch procedure into pipeline run form --- goga/commands/pipeline/pipeline.py | 30 ++++- .../pipeline/test_pipeline_dispatch.py | 105 ++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/goga/commands/pipeline/pipeline.py b/goga/commands/pipeline/pipeline.py index 7c1bb591..ffb7eea5 100644 --- a/goga/commands/pipeline/pipeline.py +++ b/goga/commands/pipeline/pipeline.py @@ -6,6 +6,7 @@ import yaml from ...config import load_project_config +from .branch import ensure_pipeline_branch from .run_pipeline_container import run_pipeline_container from .run_pipeline_info_container import run_pipeline_info_container @@ -28,6 +29,14 @@ default=False, help="Show pipeline descriptions (--list) or a pipeline card (NAME) instead of running", ) +@click.option( + "-b", + "--branch", + "branch", + type=str, + default=None, + help="Create and switch to a fresh branch before the run (run form only)", +) @click.option( "-e", "--env", @@ -93,11 +102,12 @@ help="cap concurrently executing stages (run mode only; threads to afm --max-parallel)", ) @click.pass_context -def pipeline( # noqa: C901, PLR0913, PLR0917 +def pipeline( # noqa: C901, PLR0912, PLR0913, PLR0917 ctx: click.Context, name: str | None, list_requested: bool, info: bool, + branch: str | None, extra_env: tuple[str, ...], proxy: str | None, add_host: tuple[str, ...], @@ -119,6 +129,8 @@ def pipeline( # noqa: C901, PLR0913, PLR0917 With NAME and -i/--info: prints the pipeline card (name, description, stages in execution order) without running anything. + With -b/--branch: prepare a fresh git branch and history topic before the run. + All forms launch the goga Docker container and delegate there — the host never reads pipeline files directly. """ @@ -188,7 +200,21 @@ def pipeline( # noqa: C901, PLR0913, PLR0917 if not workflow_path.exists(): raise click.ClickException(f"workflow '{workflow}' not found at {workflow_path}") - # Step 3 — dispatch. The info forms receive hosts from the config ONLY: + # Step 3 — branch procedure (run form only: `name` given, no --list, no + # --info, and -b/--branch given). Every git action happens here on the + # host, AFTER every step-2 form check and BEFORE any docker activity — a + # form error or a branch error never refreshes, builds, or launches an + # image. The flat list, overview, and card forms skip the procedure + # silently: passing -b there is not an error and has no effect. The final + # branch name (the created-and-switched one, or the current one in the + # already-on-branch case) is echoed to stdout exactly once, immediately + # after the procedure and before the dispatch — and never forwarded into + # a launcher: the container sees the branch through the mounted project. + if branch is not None and name is not None and not list_requested and not info: + final_branch = ensure_pipeline_branch(branch) + click.echo(f"Pipeline running on branch {final_branch}") + + # Step 4 — dispatch. The info forms receive hosts from the config ONLY: # --add-host is a run-form surface (an info container is read-only, so # extra host aliases there would be dead weight) and is a deliberate no-op. info_hosts: dict[str, str] = {**config.pipeline.hosts} diff --git a/tests/commands/pipeline/test_pipeline_dispatch.py b/tests/commands/pipeline/test_pipeline_dispatch.py index 673b6f9c..84edf4a7 100644 --- a/tests/commands/pipeline/test_pipeline_dispatch.py +++ b/tests/commands/pipeline/test_pipeline_dispatch.py @@ -5,6 +5,10 @@ ``pipeline(ctx, name, extra_env, proxy, add_host, clean, update)``: - new ``--proxy``, ``--add-host`` (multiple), ``--clean``, ``--update/-u`` options +- new ``-b/--branch`` option (run form only): one Option with both forms + binding the ``branch`` parameter; the guarded branch procedure runs after + the step-2 validation and before any docker activity, prints exactly one + stdout line, and never forwards the branch name into a launcher - proxy resolution: ``--proxy`` wins over ``config.pipeline.proxy`` - hosts resolution: ``--add-host`` entries merge on top of ``config.pipeline.hosts`` (CLI overrides config on key conflict) @@ -19,9 +23,12 @@ from __future__ import annotations +import inspect import sys +import typing from unittest import mock +import click import pytest from click.testing import CliRunner from goga.commands.pipeline import pipeline @@ -103,6 +110,41 @@ def test_help_lists_new_options(self) -> None: assert "-u" in output +class TestPipelineBranchOptionContract: + def test_pipeline_branch_option_contract_both_forms_one_option(self) -> None: + """-b/--branch is a single Option binding ``branch``; both forms reach the procedure. + + The Option carries both forms (``set(param.opts)`` is exactly the pair), + defaults to None, and is a plain string option (click renders the + declared ``type=str`` as its canonical STRING param type). The callback + declares ``branch: str | None`` directly after ``info`` (contract + order), and ``--branch x NAME`` / ``-b x NAME`` reach + ``ensure_pipeline_branch`` with the same value. + """ + branch_param = next(p for p in pipeline.params if p.name == "branch") + assert set(branch_param.opts) == {"-b", "--branch"} + assert branch_param.default is None + assert branch_param.type is click.STRING + + parameters = list(inspect.signature(pipeline_cmd.callback).parameters) + assert parameters.index("branch") == parameters.index("info") + 1 + hints = typing.get_type_hints(pipeline_cmd.callback) + assert hints["branch"] == str | None + + config = _make_config() + runner = CliRunner() + for argv in (["--branch", "x", "my-pipeline"], ["-b", "x", "my-pipeline"]): + with ( + mock.patch.object(_pipeline_module, "load_project_config", return_value=config), + mock.patch.object(_pipeline_module, "ensure_pipeline_branch", return_value="x") as mock_ensure, + mock.patch.object(_pipeline_module, "run_pipeline_container", return_value=0), + ): + result = runner.invoke(pipeline, argv) + + assert result.exit_code == 0 + mock_ensure.assert_called_once_with("x") + + # --- Logic tests (positive) --- @@ -252,6 +294,69 @@ def test_pipeline_propagates_exit_code(self, exit_code: int) -> None: assert result.exit_code == exit_code +class TestPipelineBranchRunForm: + def test_pipeline_run_form_with_branch_prints_line_and_launches(self) -> None: + """The run form runs the procedure, prints the branch line, launches without the name.""" + config = _make_config() + runner = CliRunner() + with ( + mock.patch.object(_pipeline_module, "load_project_config", return_value=config), + mock.patch.object(_pipeline_module, "ensure_pipeline_branch", return_value="feat/x") as mock_ensure, + mock.patch.object(_pipeline_module, "run_pipeline_container", return_value=0) as mock_run, + ): + result = runner.invoke(pipeline, ["-b", "feat/x", "my-pipeline"]) + + assert result.exit_code == 0 + assert "Pipeline running on branch feat/x" in result.stdout + mock_ensure.assert_called_once_with("feat/x") + assert mock_run.call_count == 1 + assert mock_run.call_args.kwargs["name"] == "my-pipeline" + # The branch name never crosses the docker boundary — no branch kwarg, + # no value equal to it anywhere in the launcher call. + assert "branch" not in mock_run.call_args.kwargs + assert "feat/x" not in mock_run.call_args.kwargs.values() + + @pytest.mark.parametrize( + "argv", + [ + ["-b", "x", "--list"], + ["-b", "x", "--list", "--info"], + ["-b", "x", "my-pipeline", "--info"], + ], + ids=["flat-list", "overview", "card"], + ) + def test_pipeline_list_and_info_forms_silently_skip_branch(self, argv: list[str]) -> None: + """The flat list, overview, and card forms ignore -b — no procedure, no line.""" + config = _make_config() + runner = CliRunner() + with ( + mock.patch.object(_pipeline_module, "load_project_config", return_value=config), + mock.patch.object(_pipeline_module, "ensure_pipeline_branch") as mock_ensure, + mock.patch.object(_pipeline_module, "run_pipeline_info_container", return_value=0), + ): + result = runner.invoke(pipeline, argv) + + assert result.exit_code == 0 + mock_ensure.assert_not_called() + assert "Pipeline running on branch" not in result.stdout + + def test_pipeline_missing_name_error_precedes_branch_procedure(self) -> None: + """A step-2 form error exits 1 before any git action of the branch procedure.""" + config = _make_config() + runner = CliRunner() + with ( + mock.patch.object(_pipeline_module, "load_project_config", return_value=config), + mock.patch.object(_pipeline_module, "ensure_pipeline_branch") as mock_ensure, + mock.patch.object(_pipeline_module, "run_pipeline_container") as mock_run, + ): + result = runner.invoke(pipeline, ["-b", "feat/x"]) + + assert result.exit_code == 1 + assert "Missing pipeline name" in result.output + mock_ensure.assert_not_called() + mock_run.assert_not_called() + + class TestPipelineCallbackSignature: def test_pipeline_callback_has_new_parameters(self) -> None: """The decorated callback exposes proxy/add_host/clean/update parameters.""" From 069d9d4689eb030f7d5c84e16946033844953137 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 23:17:14 +0000 Subject: [PATCH 025/229] feat: export full 9-name pipeline contract API from cell facade --- goga/commands/pipeline/__init__.py | 24 +++++++- .../pipeline/test_pipeline_command.py | 57 +++++++++++++++++-- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/goga/commands/pipeline/__init__.py b/goga/commands/pipeline/__init__.py index 790c197a..03575008 100644 --- a/goga/commands/pipeline/__init__.py +++ b/goga/commands/pipeline/__init__.py @@ -1,7 +1,27 @@ """Pipeline command cell — host-side launcher for the single goga pipeline command.""" +from .branch import ( + check_branch_occupancy, + ensure_pipeline_branch, + normalize_topic_slug, + resolve_current_branch_name, +) from .pipeline import pipeline -from .run_pipeline_container import run_pipeline_container +from .run_pipeline_container import ( + clean_pipeline_runtime_dir, + resolve_pipeline_runtime_dir, + run_pipeline_container, +) from .run_pipeline_info_container import run_pipeline_info_container -__all__: list[str] = ["pipeline", "run_pipeline_container", "run_pipeline_info_container"] +__all__: list[str] = [ + "check_branch_occupancy", + "clean_pipeline_runtime_dir", + "ensure_pipeline_branch", + "normalize_topic_slug", + "pipeline", + "resolve_current_branch_name", + "resolve_pipeline_runtime_dir", + "run_pipeline_container", + "run_pipeline_info_container", +] diff --git a/tests/commands/pipeline/test_pipeline_command.py b/tests/commands/pipeline/test_pipeline_command.py index 0da39b80..d971e6c6 100644 --- a/tests/commands/pipeline/test_pipeline_command.py +++ b/tests/commands/pipeline/test_pipeline_command.py @@ -591,12 +591,27 @@ def test_pipeline_info_forms_ignore_run_flags(self, tmp_path: Path, monkeypatch: assert mock_info.call_args.kwargs["hosts"] == {} -# --- Facade contract: goga/commands/pipeline exports the info launcher --- +# --- Facade contract: goga/commands/pipeline exports the full contract API --- + +# The nine names declared in the cell CODEMANIFEST — the pipeline command, the +# two container launchers, the four branch routines, and the two runtime-dir +# helpers (declared since the cell existed, exported since release 1.3.0). +_PIPELINE_FACADE_ALL = [ + "check_branch_occupancy", + "clean_pipeline_runtime_dir", + "ensure_pipeline_branch", + "normalize_topic_slug", + "pipeline", + "resolve_current_branch_name", + "resolve_pipeline_runtime_dir", + "run_pipeline_container", + "run_pipeline_info_container", +] class TestCommandsFacadeExportsInfoLauncher: def test_commands_facade_exports_info_launcher(self) -> None: - """The package facade defines all three public names and lists them in ``__all__``. + """The package facade defines all nine public names and lists them in ``__all__``. ``goga.commands.pipeline`` is shadowed on the ``goga.commands`` package by the pipeline Click command (see the module-level note above), so the @@ -605,14 +620,46 @@ def test_commands_facade_exports_info_launcher(self) -> None: """ commands_facade = sys.modules["goga.commands.pipeline"] - for name in ("pipeline", "run_pipeline_container", "run_pipeline_info_container"): + for name in _PIPELINE_FACADE_ALL: assert hasattr(commands_facade, name), f"{name} is not defined on goga.commands.pipeline" assert name in commands_facade.__all__, f"{name} is missing from goga.commands.pipeline.__all__" def test_commands_facade_all_is_alphabetical_and_complete(self) -> None: - """``__all__`` holds exactly the three names in alphabetical order.""" + """``__all__`` holds exactly the nine names in alphabetical order.""" commands_facade = sys.modules["goga.commands.pipeline"] - assert commands_facade.__all__ == ["pipeline", "run_pipeline_container", "run_pipeline_info_container"] + assert commands_facade.__all__ == _PIPELINE_FACADE_ALL + + def test_cell_facades_export_full_contract_api(self) -> None: + """Every declared contract name is importable from the cell facade root. + + The Python facade rule obliges ``goga.commands.pipeline`` to expose the + full contract API: the command, both launchers, the four ``branch.py`` + routines, and the two runtime-dir helpers. + """ + from goga.commands.pipeline import ( + check_branch_occupancy, + clean_pipeline_runtime_dir, + ensure_pipeline_branch, + normalize_topic_slug, + resolve_current_branch_name, + resolve_pipeline_runtime_dir, + run_pipeline_container, + run_pipeline_info_container, + ) + from goga.commands.pipeline import ( + pipeline as pipeline_from_facade, + ) + + assert pipeline_from_facade is pipeline + assert run_pipeline_container is not None + assert run_pipeline_info_container is not None + assert resolve_pipeline_runtime_dir is not None + assert clean_pipeline_runtime_dir is not None + assert normalize_topic_slug is not None + assert resolve_current_branch_name is not None + assert check_branch_occupancy is not None + assert ensure_pipeline_branch is not None + assert sys.modules["goga.commands.pipeline"].__all__ == _PIPELINE_FACADE_ALL def test_commands_facade_info_launcher_is_importable_by_name(self) -> None: """The consumer form ``from goga.commands.pipeline import run_pipeline_info_container`` works.""" From 7452c1e59df7e97e54fb7fa88d7228e10ca90ce4 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 23:19:38 +0000 Subject: [PATCH 026/229] feat: pipeline -b branch-flow integration tests through the real command --- .../pipeline/test_pipeline_dispatch.py | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/tests/commands/pipeline/test_pipeline_dispatch.py b/tests/commands/pipeline/test_pipeline_dispatch.py index 84edf4a7..2ff0486d 100644 --- a/tests/commands/pipeline/test_pipeline_dispatch.py +++ b/tests/commands/pipeline/test_pipeline_dispatch.py @@ -19,18 +19,25 @@ The dispatch target ``run_pipeline_container`` is mocked so these tests stay focused on the click surface and the host-side resolution logic, with no docker dependency. + +The integration block at the bottom drives the REAL ``ensure_pipeline_branch`` +through the real command surface, mocking only the process boundary (git +subprocess and docker launcher) to verify the wiring the unit tests mock away. """ from __future__ import annotations import inspect +import subprocess import sys import typing +from pathlib import Path from unittest import mock import click import pytest from click.testing import CliRunner +from goga.commands.pipeline import branch as branch_module from goga.commands.pipeline import pipeline from goga.commands.pipeline.pipeline import pipeline as pipeline_cmd from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig @@ -357,6 +364,141 @@ def test_pipeline_missing_name_error_precedes_branch_procedure(self) -> None: mock_run.assert_not_called() +# --- Integration tests (the real branch procedure through the real command) --- + + +class _GitResult: + """Minimal stand-in for a ``subprocess.CompletedProcess``.""" + + def __init__(self, returncode: int = 0, stdout: str = "", stderr: str = "") -> None: + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def _git_answers( + show_current: object = _GitResult(stdout="main\n"), + show_ref: object = subprocess.CalledProcessError(1, "git"), + for_each_ref: object = _GitResult(stdout=""), + switch: object = _GitResult(), +) -> mock.Mock: + """Build a ``subprocess.run`` mock answering per git subcommand. + + Each answer is either a result object (returned) or an exception instance + (raised); an unexpected argv fails the test loudly. Same process-boundary + doubling as ``test_branch.py`` — git itself is never mocked. + """ + answers = { + ("branch", "--show-current"): show_current, + ("show-ref",): show_ref, + ("for-each-ref",): for_each_ref, + ("switch",): switch, + } + + def _run(argv: list[str], **_kwargs: object) -> _GitResult: + for key, answer in answers.items(): + if tuple(argv[1 : 1 + len(key)]) == key: + if isinstance(answer, BaseException): + raise answer + return answer + raise AssertionError(f"unexpected git argv in test: {argv!r}") + + return mock.Mock(side_effect=_run) + + +def _switch_calls(run_mock: mock.Mock) -> list[list[str]]: + """The recorded ``git switch`` argvs (usually asserted to be empty).""" + return [call.args[0] for call in run_mock.call_args_list if call.args[0][1] == "switch"] + + +class TestPipelineBranchIntegration: + """Cross-entity: the real ``ensure_pipeline_branch`` through the real command. + + Only the process boundary is mocked (git subprocess calls, docker + launcher), so these tests verify the wiring the unit tests mock away: the + ``from .branch import`` path, the run-form guard, the argument handed to + the procedure, and the ordering guarantee — step-2 validation, branch + procedure, branch line, docker activity. + """ + + def test_pipeline_branch_flow_a_creates_and_launches(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Flow A happy path: created as entered, line printed, launcher unbranch'd.""" + monkeypatch.chdir(tmp_path) + config = _make_config() + run_mock = _git_answers() + runner = CliRunner() + with ( + mock.patch.object(_pipeline_module, "load_project_config", return_value=config), + mock.patch.object(branch_module.subprocess, "run", run_mock), + mock.patch.object(_pipeline_module, "run_pipeline_container", return_value=0) as mock_run, + ): + result = runner.invoke(pipeline, ["-b", "Feature/X", "my-pipeline"]) + + assert result.exit_code == 0 + assert "Pipeline running on branch Feature/X" in result.stdout + assert run_mock.call_args_list[-1].args[0] == ["git", "switch", "-c", "Feature/X"] + assert mock_run.call_count == 1 + assert mock_run.call_args.kwargs["name"] == "my-pipeline" + # The branch name never crosses the docker boundary. + assert "branch" not in mock_run.call_args.kwargs + assert "Feature/X" not in mock_run.call_args.kwargs.values() + + def test_pipeline_branch_line_uses_final_branch_name(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Already on the branch: the line carries the CURRENT name and git is untouched.""" + monkeypatch.chdir(tmp_path) + config = _make_config() + run_mock = _git_answers(show_current=_GitResult(stdout="release/1.3.0\n")) + runner = CliRunner() + with ( + mock.patch.object(_pipeline_module, "load_project_config", return_value=config), + mock.patch.object(branch_module.subprocess, "run", run_mock), + mock.patch.object(_pipeline_module, "run_pipeline_container", return_value=0) as mock_run, + ): + result = runner.invoke(pipeline, ["-b", "release-1.3.0", "my-pipeline"]) + + assert result.exit_code == 0 + assert "Pipeline running on branch release/1.3.0" in result.stdout + assert _switch_calls(run_mock) == [] + assert mock_run.call_count == 1 + + def test_pipeline_branch_no_git_host_fails_cleanly_through_cli(self) -> None: + """A host without git: the clean failure on stderr, exit 1, nothing launches.""" + config = _make_config() + runner = CliRunner() + with ( + mock.patch.object(_pipeline_module, "load_project_config", return_value=config), + mock.patch.object(branch_module.subprocess, "run", side_effect=FileNotFoundError("git")), + mock.patch.object(_pipeline_module, "run_pipeline_container") as mock_run, + ): + result = runner.invoke(pipeline, ["-b", "feat/x", "my-pipeline"]) + + assert result.exit_code == 1 + assert "git is required for -b/--branch: git binary not found" in result.stderr + mock_run.assert_not_called() + + def test_pipeline_branch_conflict_without_tty_fails_through_cli( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A conflict without a terminal: reason and -b hint on stderr, no launch.""" + monkeypatch.chdir(tmp_path) + config = _make_config() + run_mock = _git_answers(show_ref=_GitResult(returncode=0)) + runner = CliRunner() + with ( + mock.patch.object(_pipeline_module, "load_project_config", return_value=config), + mock.patch.object(branch_module.subprocess, "run", run_mock), + mock.patch.object(branch_module.sys.stdin, "isatty", return_value=False), + mock.patch.object(_pipeline_module, "run_pipeline_container") as mock_run, + ): + result = runner.invoke(pipeline, ["-b", "feat/x", "my-pipeline"]) + + assert result.exit_code == 1 + assert "branch 'feat/x' already exists" in result.stderr + assert "Pass another branch name via -b." in result.stderr + assert _switch_calls(run_mock) == [] + mock_run.assert_not_called() + + class TestPipelineCallbackSignature: def test_pipeline_callback_has_new_parameters(self) -> None: """The decorated callback exposes proxy/add_host/clean/update parameters.""" From a9ee4119455a21875e64bfa11610f733f6dd0fd9 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 23:24:11 +0000 Subject: [PATCH 027/229] feat: install cell hook.py with user resolution, hook invocation, and hook runner --- goga/commands/install/hook.py | 129 ++++++++++++ tests/commands/install/test_hook.py | 298 ++++++++++++++++++++++++++++ 2 files changed, 427 insertions(+) create mode 100644 goga/commands/install/hook.py create mode 100644 tests/commands/install/test_hook.py diff --git a/goga/commands/install/hook.py b/goga/commands/install/hook.py new file mode 100644 index 00000000..8279871a --- /dev/null +++ b/goga/commands/install/hook.py @@ -0,0 +1,129 @@ +"""Post-install hook routines for ``goga install``. + +The three hook routines declared in the cell CODEMANIFEST with ``location: +hook.py``: the initiating-user resolver (the actual person behind a possibly +sudo-ed install), the single-tool hook invocation (a dynamic +``goga_tool_`` facade import with signature-projected ``user`` +injection), and the sequential runner (one initiating user per run, stop at +the first failure). The hook is optional per tool: a missing facade module or +a missing ``install`` callable is a quiet skip — silent for the user and +visible in the debug log only. +""" + +from __future__ import annotations + +import getpass +import importlib +import inspect +import logging +import os + +logger = logging.getLogger(__name__) + +# The signature projection's opt-in set: only a ``user`` parameter declared +# POSITIONAL_OR_KEYWORD or KEYWORD_ONLY receives the value — ``**kwargs`` +# alone is NOT an opt-in (the offered-name set is the single source). +_KEYWORD_CAPABLE_KINDS = frozenset({inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY}) + + +def resolve_initiating_user() -> str: + """Resolve the user who initiated the installation. + + The actual person, not the root account the installer may run under: a + set and non-empty ``SUDO_USER`` environment variable wins (the + installation runs under sudo and sudo recorded the caller); otherwise + the operating-system user name of the current process is the canonical + answer. + + Returns: + The initiating user name. + + Raises: + OSError: when the operating-system identity cannot be resolved — the + failure propagates, no fallback name is invented. + KeyError: when the user database has no entry for the current + process — propagates for the same reason. + """ + sudo_user = os.environ.get("SUDO_USER") + if sudo_user: + return sudo_user + return getpass.getuser() + + +def call_install_hook(tool: str, user: str) -> bool: + """Run the post-install hook of one installed tool when it provides one. + + Imports the tool facade ``goga_tool_`` and calls its optional + ``install`` callable. The injection follows the signature projection: + only a declared keyword-capable ``user`` parameter receives the value — a + positional-only ``user`` or a bare ``**kwargs`` is not an opt-in and the + hook is called without arguments. No argument other than ``user`` is + ever passed, and the hook runs unsandboxed, with the trust level of the + installed package. + + Args: + tool: Tool name without the ``goga_tool_`` prefix. + user: Initiating user to inject when the hook asks for it. + + Returns: + True when the hook ran; False when it was skipped (a missing facade + module or a missing/non-callable ``install`` — a quiet skip). + + Raises: + ModuleNotFoundError: when the facade exists but its own imports are + broken — the missing module is a different one (``exc.name`` + differs), a failure rather than a skip. + Exception: whatever the hook itself raises, propagated unchanged. + """ + module_name = f"goga_tool_{tool}" + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError as exc: + if exc.name == module_name: + return False + raise + + install = getattr(module, "install", None) + if install is None or not callable(install): + return False + + user_parameter = inspect.signature(install).parameters.get("user") + if user_parameter is not None and user_parameter.kind in _KEYWORD_CAPABLE_KINDS: + install(user=user) + else: + install() + return True + + +def run_install_hooks(tools: list[str]) -> None: + """Run the post-install hook for every freshly installed tool. + + Resolves the initiating user exactly once — every hook of the run sees + the same value — then invokes each tool's hook in installation order. + A hook failure stops the loop: the exception is wrapped with the tool's + name as context and the hooks of the remaining tools are not run. An + empty list is a quiet no-op: no user resolution, no log lines. The agent + re-sync never runs here; activation stays the command's own step. + + Args: + tools: Names of the installed tools in installation order. + + Raises: + RuntimeError: a hook raised — ``install hook for tool '' + failed: ``, with the original exception as its cause. + Exception: a failure of the user-resolution step propagates as-is — + it is not a hook failure and carries no tool context. + """ + if not tools: + return + + user = resolve_initiating_user() + for tool in tools: + try: + invoked = call_install_hook(tool, user) + except Exception as exc: + raise RuntimeError(f"install hook for tool {tool!r} failed: {exc}") from exc + if invoked: + logger.info("install hook invoked", extra={"tool": tool}) + else: + logger.debug("install hook skipped", extra={"tool": tool}) diff --git a/tests/commands/install/test_hook.py b/tests/commands/install/test_hook.py new file mode 100644 index 00000000..a194ad61 --- /dev/null +++ b/tests/commands/install/test_hook.py @@ -0,0 +1,298 @@ +"""Contract and logic tests for the hook routines declared in +``goga/commands/install/CODEMANIFEST`` with ``location: hook.py``: + +- ``resolve_initiating_user() -> str`` — the actual person behind a possibly + sudo-ed install (``SUDO_USER`` when set and non-empty, else the OS user) +- ``call_install_hook(tool: str, user: str) -> bool`` — dynamic + ``goga_tool_`` facade import with signature-projected ``user`` + injection +- ``run_install_hooks(tools: list[str]) -> None`` — sequential runner with + one initiating user per run and tool-context failure wrapping + +The facade boundary is mocked at the import boundary per the +hook-fake construction rule: every fake ``install`` is a REAL function +declaring its parameters (a recorder appending to a list), never a bare +MagicMock — ``inspect.signature(MagicMock())`` is ``(*args, **kwargs)`` and +the signature projection would bare-call it. +""" + +from __future__ import annotations + +import inspect +import logging +import sys +import types +import typing +from unittest import mock + +import pytest +from goga.commands.install import hook as hook_module + +# --- Contract tests --- + + +class TestHookContract: + def test_three_routines_exist_and_are_callable(self) -> None: + """The three routines are defined on the hook module and callable.""" + assert callable(hook_module.resolve_initiating_user) + assert callable(hook_module.run_install_hooks) + assert callable(hook_module.call_install_hook) + + def test_module_carries_the_convention_logger(self) -> None: + """``logger = logging.getLogger(__name__)`` — the module's own logger.""" + assert hook_module.logger.name == "goga.commands.install.hook" + + def test_resolve_initiating_user_signature(self) -> None: + """``resolve_initiating_user() -> str``.""" + signature = inspect.signature(hook_module.resolve_initiating_user) + assert list(signature.parameters) == [] + hints = typing.get_type_hints(hook_module.resolve_initiating_user) + assert hints == {"return": str} + + def test_run_install_hooks_signature(self) -> None: + """``run_install_hooks(tools: list[str]) -> None``.""" + signature = inspect.signature(hook_module.run_install_hooks) + assert list(signature.parameters) == ["tools"] + assert signature.parameters["tools"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + hints = typing.get_type_hints(hook_module.run_install_hooks) + assert hints == {"tools": list[str], "return": type(None)} + + def test_call_install_hook_signature(self) -> None: + """``call_install_hook(tool: str, user: str) -> bool``.""" + signature = inspect.signature(hook_module.call_install_hook) + assert list(signature.parameters) == ["tool", "user"] + for parameter in signature.parameters.values(): + assert parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + hints = typing.get_type_hints(hook_module.call_install_hook) + assert hints == {"tool": str, "user": str, "return": bool} + + +# --- Logic tests — resolve_initiating_user --- + + +class TestResolveInitiatingUser: + def test_resolve_initiating_user_prefers_sudo_user(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A set, non-empty ``SUDO_USER`` short-circuits before ``getpass``.""" + monkeypatch.setenv("SUDO_USER", "alice") + + def _raise_unresolvable() -> str: + raise KeyError("no resolvable identity") + + with mock.patch.object(hook_module.getpass, "getuser", side_effect=_raise_unresolvable): + assert hook_module.resolve_initiating_user() == "alice" + + def test_resolve_initiating_user_falls_back_to_os_user(self, monkeypatch: pytest.MonkeyPatch) -> None: + """No (or set-but-empty) ``SUDO_USER`` → the OS user name.""" + monkeypatch.delenv("SUDO_USER", raising=False) + with mock.patch.object(hook_module.getpass, "getuser", return_value="bob"): + assert hook_module.resolve_initiating_user() == "bob" + # Set but EMPTY is treated as unset — the OS user is the answer too. + monkeypatch.setenv("SUDO_USER", "") + with mock.patch.object(hook_module.getpass, "getuser", return_value="bob"): + assert hook_module.resolve_initiating_user() == "bob" + + def test_resolve_initiating_user_identity_failure_propagates(self, monkeypatch: pytest.MonkeyPatch) -> None: + """No fallback name is invented when identity resolution fails.""" + monkeypatch.delenv("SUDO_USER", raising=False) + with ( + mock.patch.object(hook_module.getpass, "getuser", side_effect=KeyError("uid not found")), + pytest.raises(KeyError), + ): + hook_module.resolve_initiating_user() + + +# --- Logic tests — call_install_hook --- + + +class TestCallInstallHook: + def test_call_install_hook_injects_declared_user_keyword(self) -> None: + """A declared keyword-capable ``user`` parameter receives the value.""" + calls: list[dict[str, str | None]] = [] + + def _fake_install(user: str | None = None) -> None: + calls.append({"user": user}) + + fake_module = types.SimpleNamespace(install=_fake_install) + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module) as mock_import: + invoked = hook_module.call_install_hook("fake", "alice") + assert invoked is True + assert calls == [{"user": "alice"}] + mock_import.assert_called_once_with("goga_tool_fake") + + def test_call_install_hook_bare_call_when_no_user_parameter(self) -> None: + """A hook without a ``user`` parameter is called with NO arguments.""" + calls: list[tuple[()]] = [] + + def _fake_install() -> None: + calls.append(()) + + fake_module = types.SimpleNamespace(install=_fake_install) + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module): + invoked = hook_module.call_install_hook("fake", "alice") + assert invoked is True + # The zero-parameter recorder proves the call carried no arguments — + # any argument would have raised TypeError and propagated. + assert calls == [()] + + def test_call_install_hook_positional_only_user_not_injected(self) -> None: + """``def install(user, /)`` — positional-only is NOT keyword-capable.""" + calls: list[dict[str, str | None]] = [] + + def _fake_install(user: str | None = None, /) -> None: + calls.append({"user": user}) + + fake_module = types.SimpleNamespace(install=_fake_install) + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module): + invoked = hook_module.call_install_hook("fake", "alice") + assert invoked is True + # The bare call leaves the declared default — "alice" never arrives. + assert calls == [{"user": None}] + + def test_call_install_hook_var_keyword_only_not_injected(self) -> None: + """``def install(**kwargs)`` — ``**kwargs`` is NOT a declared opt-in.""" + calls: list[dict[str, object]] = [] + + def _fake_install(**kwargs: object) -> None: + calls.append(dict(kwargs)) + + fake_module = types.SimpleNamespace(install=_fake_install) + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module): + invoked = hook_module.call_install_hook("fake", "alice") + assert invoked is True + assert calls == [{}] + + def test_call_install_hook_missing_facade_module_skips_quietly(self) -> None: + """A truly missing ``goga_tool_`` facade is a skip, not a failure.""" + error = ModuleNotFoundError("No module named 'goga_tool_ghost'", name="goga_tool_ghost") + with mock.patch.object(hook_module.importlib, "import_module", side_effect=error): + assert hook_module.call_install_hook("ghost", "alice") is False + + def test_call_install_hook_broken_facade_import_is_failure_not_skip(self) -> None: + """A DIFFERENT missing module (the facade's own import broke) propagates.""" + error = ModuleNotFoundError("No module named 'dep'", name="dep") + with ( + mock.patch.object(hook_module.importlib, "import_module", side_effect=error), + pytest.raises(ModuleNotFoundError), + ): + hook_module.call_install_hook("fake", "alice") + + def test_call_install_hook_absent_or_non_callable_install_skips(self) -> None: + """A facade without ``install``, or with a non-callable one, skips.""" + with mock.patch.object(hook_module.importlib, "import_module", return_value=types.SimpleNamespace()): + assert hook_module.call_install_hook("fake", "alice") is False + with mock.patch.object( + hook_module.importlib, + "import_module", + return_value=types.SimpleNamespace(install="not-callable"), + ): + assert hook_module.call_install_hook("fake", "alice") is False + + def test_call_install_hook_hook_exception_propagates_unchanged(self) -> None: + """Whatever the hook itself raises escapes untouched (no wrap here).""" + + def _fake_install(user: str | None = None) -> None: + raise ValueError("boom") + + fake_module = types.SimpleNamespace(install=_fake_install) + with ( + mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module), + pytest.raises(ValueError, match=r"^boom$"), + ): + hook_module.call_install_hook("fake", "alice") + + +# --- Logic tests — run_install_hooks --- + + +class TestRunInstallHooks: + def test_run_install_hooks_order_and_shared_user(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Hooks run in order, every one seeing the SAME initiating user.""" + log: list[tuple[str, str | None]] = [] + + def _install_a(user: str | None = None) -> None: + log.append(("a", user)) + + def _install_b(user: str | None = None) -> None: + log.append(("b", user)) + + # Real throwaway facades in sys.modules — the real importlib path + # resolves them without touching the filesystem. + monkeypatch.setitem(sys.modules, "goga_tool_a", types.SimpleNamespace(install=_install_a)) + monkeypatch.setitem(sys.modules, "goga_tool_b", types.SimpleNamespace(install=_install_b)) + monkeypatch.delenv("SUDO_USER", raising=False) + with mock.patch.object(hook_module.getpass, "getuser", return_value="alice"): + hook_module.run_install_hooks(["a", "b"]) + assert log == [("a", "alice"), ("b", "alice")] + + def test_run_install_hooks_stops_at_first_failure_with_tool_context(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The failing hook's exception is wrapped with its tool name; the rest never run.""" + log: list[str] = [] + + def _boom(user: str | None = None) -> None: + raise ValueError("boom") + + def _install_b(user: str | None = None) -> None: + log.append("b") + + monkeypatch.setitem(sys.modules, "goga_tool_a", types.SimpleNamespace(install=_boom)) + monkeypatch.setitem(sys.modules, "goga_tool_b", types.SimpleNamespace(install=_install_b)) + monkeypatch.delenv("SUDO_USER", raising=False) + with ( + mock.patch.object(hook_module.getpass, "getuser", return_value="alice"), + pytest.raises(RuntimeError) as excinfo, + ): + hook_module.run_install_hooks(["a", "b"]) + assert str(excinfo.value) == "install hook for tool 'a' failed: boom" + assert isinstance(excinfo.value.__cause__, ValueError) + assert log == [] + + def test_run_install_hooks_user_resolution_failure_not_wrapped(self, monkeypatch: pytest.MonkeyPatch) -> None: + """An identity-resolution failure is not a hook failure — no tool context.""" + monkeypatch.delenv("SUDO_USER", raising=False) + with ( + mock.patch.object(hook_module.getpass, "getuser", side_effect=KeyError("uid not found")), + pytest.raises(KeyError), + ): + hook_module.run_install_hooks(["a"]) + + def test_run_install_hooks_empty_list_is_quiet_noop(self, caplog: pytest.LogCaptureFixture) -> None: + """``[]`` — no user resolution, no import calls, no log lines.""" + with ( + mock.patch.object(hook_module, "resolve_initiating_user") as mock_resolve, + mock.patch.object(hook_module.importlib, "import_module") as mock_import, + ): + hook_module.run_install_hooks([]) + mock_resolve.assert_not_called() + mock_import.assert_not_called() + assert caplog.records == [] + + def test_run_install_hooks_log_levels_invoked_and_skipped( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + """An invoked hook logs INFO; a quiet skip logs DEBUG only.""" + calls: list[str | None] = [] + + def _fake_install(user: str | None = None) -> None: + calls.append(user) + + def _fake_import(name: str) -> types.SimpleNamespace: + if name == "goga_tool_present": + return types.SimpleNamespace(install=_fake_install) + raise ModuleNotFoundError(f"No module named {name!r}", name=name) + + monkeypatch.delenv("SUDO_USER", raising=False) + with ( + mock.patch.object(hook_module.importlib, "import_module", side_effect=_fake_import), + mock.patch.object(hook_module.getpass, "getuser", return_value="alice"), + caplog.at_level(logging.DEBUG, logger="goga.commands.install.hook"), + ): + hook_module.run_install_hooks(["present", "ghost"]) + assert calls == ["alice"] + invoked = [r for r in caplog.records if r.message == "install hook invoked"] + skipped = [r for r in caplog.records if r.message == "install hook skipped"] + assert len(invoked) == 1 + assert invoked[0].levelno == logging.INFO + assert invoked[0].tool == "present" + assert len(skipped) == 1 + assert skipped[0].levelno == logging.DEBUG + assert skipped[0].tool == "ghost" From c0661fe7b16b09dbc9e7ce700499787f4d80d182 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 23:31:20 +0000 Subject: [PATCH 028/229] feat: wire install hooks and --local tool-name grammar into install command --- goga/commands/install/install.py | 166 +++++++++++++++++--- tests/commands/install/test_install.py | 208 +++++++++++++++++++++++++ 2 files changed, 350 insertions(+), 24 deletions(-) diff --git a/goga/commands/install/install.py b/goga/commands/install/install.py index e1b7fd16..b4560ee3 100644 --- a/goga/commands/install/install.py +++ b/goga/commands/install/install.py @@ -11,6 +11,7 @@ from ...config import load_project_config from ...connect import resync_registered_agents from ...version import resolve_version +from .hook import run_install_hooks logger = logging.getLogger(__name__) @@ -76,26 +77,135 @@ def _resolve_pkg(name: str, form: str | None) -> str: return f"goga-tool-{name}" + (spec or "") -def _after_pip(pip_rc: int, no_connect: bool) -> int: - """Decide the final exit code after pip (Algorithm step 4 — ACTIVATION). +def _parse_local(value: str) -> tuple[str, str | None]: + """Split a ``--local`` value into its path and optional tool-name suffix. - Activation (the post-install agent re-sync) runs only when pip succeeded - (``pip_rc == 0``) and the caller did not opt out with ``no_connect``. When - skipped — because pip failed or ``no_connect`` is set — pip's own outcome is - the final exit code. The re-sync targets the current user's ``~/.goga`` and - is the single path through which activation runs; this routine never writes - ``connect.yml`` and never runs under sudo. + The grammar is ```` or ``:`` — the FIRST colon is + the separator. The suffix names the tool whose post-install hook runs for + the local install; without it no hook runs. A malformed suffix (an empty + name, a path separator, or another colon — e.g. a Windows drive path + misread as a suffix) is rejected here, before any pip. + + Args: + value: The raw ``--local`` option value. + + Returns: + The local directory path and the tool name from the suffix, or None + when the value carries no ``:`` suffix. + + Raises: + click.ClickException: when the suffix is malformed — a user-facing + error (exit 1) raised before any pip invocation. + """ + path, sep, tool = value.partition(":") + if sep == "": + return value, None + if tool == "": + raise click.ClickException(f"malformed --local value {value!r}: empty tool name after ':'") + if "/" in tool or "\\" in tool: + raise click.ClickException(f"malformed --local value {value!r}: tool name must not contain a path separator") + if ":" in tool: + raise click.ClickException(f"malformed --local value {value!r}: tool name must not contain ':'") + return path, tool + + +def _local_hook_targets(local_path: str, local_tool: str | None) -> list[str]: + """Compose the LOCAL path's hook-target list (Algorithm step 2.2). + + The ``:`` suffix names the tool whose post-install hook runs for + the local install. A value without the suffix gets an empty list — no tool + name is guessed from the path — and a warning naming the suffix as the way + to enable the hook. + + Args: + local_path: The local directory path from the ``--local`` value. + local_tool: The tool name from the ``:`` suffix, or None + when the value carries no suffix. + + Returns: + The hook-target list handed to the hooks step. + """ + if local_tool is None: + logger.warning( + "install hook skipped for local source", + extra={"path": local_path, "hint": "pass : to enable the post-install hook"}, + ) + return [] + return [local_tool] + + +def _resolve_bulk_pkgs(tools: dict[str, str]) -> list[str]: + """Resolve every declared tool's identifier, preserving insertion order. + + Each ``(tool_name, form)`` pair from the config's ``tools`` mapping is + resolved through ``resolve_version``; a rejected form is a user-facing + ``click.ClickException`` naming the offending tool, raised before any pip + invocation. + + Args: + tools: The config tools mapping, in YAML insertion order. + + Returns: + The composed ``goga-tool-`` identifiers. + + Raises: + click.ClickException: when a tool's version form is rejected. + """ + pkgs: list[str] = [] + for tool_name, form in tools.items(): + try: + pkgs.append(_resolve_pkg(tool_name, form)) + except ValueError as exc: + raise click.ClickException(f"invalid version for tool {tool_name!r}: {exc}") from exc + return pkgs + + +def _after_pip(pip_rc: int, hook_targets: list[str], no_connect: bool) -> int: + """Run the post-install hooks and activation after pip (Algorithm steps 4 and 5). + + Hooks run only when pip succeeded (``pip_rc == 0``): every freshly + installed tool's optional ``install`` hook is invoked with one initiating + user, in installation order, stopping at the first failure — a hook failure + is a user-facing ``click.ClickException`` (exit 1) that leaves the pip + package in place and never reaches activation. Activation (the post-install + agent re-sync) follows unless the caller opted out with ``no_connect``; + the flag suppresses only the re-sync — the hooks already ran. When a step + is skipped, pip's own outcome is the final exit code. The re-sync targets + the current user's ``~/.goga`` and is the single path through which + activation runs; this routine never writes ``connect.yml`` and never runs + under sudo. Args: pip_rc: The exit code returned by the pip invocation. + hook_targets: Names of the freshly installed tools in installation + order — the per-path hook target list (single ``[name]``, local + ``[]`` or empty, bulk config keys). no_connect: When True, skip activation and keep ``pip_rc`` verbatim. Returns: - ``pip_rc`` when activation is skipped, otherwise the re-sync outcome - (0 on full success or a missing/empty registry, else the first + ``pip_rc`` when hooks or activation are skipped, otherwise the re-sync + outcome (0 on full success or a missing/empty registry, else the first non-zero per-agent failure). + + Raises: + click.ClickException: when the hooks step fails — a wrapped hook + failure carries the tool name and hook message; an unwrapped + hook-step failure (e.g. identity resolution) surfaces its own + message. ``BaseException`` (Ctrl-C, SystemExit) is never caught. """ - if no_connect or pip_rc != 0: + if pip_rc != 0: + # Nothing runs after a failed pip — no hooks, no re-sync. + return pip_rc + + try: + run_install_hooks(hook_targets) + except Exception as exc: + # The wrapped RuntimeError carries the tool name; an unwrapped + # hook-step failure (identity resolution) surfaces here equally clean. + raise click.ClickException(str(exc)) from exc + + if no_connect: + # The flag suppresses only the re-sync; the hooks already ran. return pip_rc return resync_registered_agents(Path.home() / ".goga") @@ -115,7 +225,11 @@ def _after_pip(pip_rc: int, no_connect: bool) -> int: "-l", "local", default=None, - help="Path to a pip-installable local directory (local mode); mutually exclusive with name; --version is rejected", + help=( + "Path to a pip-installable local directory, optionally : " + "to name the tool whose install hook runs; mutually exclusive with " + "name; --version is rejected" + ), ) @click.option( "--no-connect", @@ -141,7 +255,9 @@ def install( # noqa: PLR0913, PLR0917 — Click callback arity is contract-mand * SINGLE (``name`` set): install ``goga-tool-`` resolved from ``--version`` in a single pip call. * LOCAL (``--local `` set): pip-install a local directory; mutually - exclusive with ``name``, and ``--version`` is rejected. + exclusive with ``name``, and ``--version`` is rejected. The value may + carry a ``:`` suffix naming the tool whose post-install + hook runs; without it no hook runs for the local install. * BULK (``name`` omitted, ``.goga/config.yml`` lists ``tools:``): install every ``goga-tool-`` declared in the config in a single pip call. @@ -157,6 +273,13 @@ def install( # noqa: PLR0913, PLR0917 — Click callback arity is contract-mand if local is not None and version is not None: raise click.ClickException("--version is not supported with --local") + local_path: str | None = None + local_tool: str | None = None + if local is not None: + # 0.3. VALIDATION — the : suffix grammar; a malformed suffix + # aborts before any pip, a well-formed one names the hook target. + local_path, local_tool = _parse_local(local) + if name is not None: # SINGLE PATH — install one tool, grammar-resolving --version. try: @@ -165,13 +288,14 @@ def install( # noqa: PLR0913, PLR0917 — Click callback arity is contract-mand raise click.ClickException(f"invalid --version value {version!r}: {exc}") from exc pip_rc = _run_pip(_pip_argv([pkg], sudo), sudo) - ctx.exit(_after_pip(pip_rc, no_connect)) + ctx.exit(_after_pip(pip_rc, [name], no_connect)) if local is not None: # LOCAL PATH — pip-install a local directory. pip owns the missing-path # error (no CLI-level existence check), and -U is always requested (never -e). - pip_rc = _run_pip(_pip_argv([local], sudo), sudo) - ctx.exit(_after_pip(pip_rc, no_connect)) + hook_targets = _local_hook_targets(local_path, local_tool) + pip_rc = _run_pip(_pip_argv([local_path], sudo), sudo) + ctx.exit(_after_pip(pip_rc, hook_targets, no_connect)) # BULK / EMPTY PATH — driven by .goga/config.yml. try: @@ -187,12 +311,6 @@ def install( # noqa: PLR0913, PLR0917 — Click callback arity is contract-mand click.echo("Nothing to install") ctx.exit(0) - pkgs: list[str] = [] - for tool_name, form in tools.items(): - try: - pkgs.append(_resolve_pkg(tool_name, form)) - except ValueError as exc: - raise click.ClickException(f"invalid version for tool {tool_name!r}: {exc}") from exc - + pkgs = _resolve_bulk_pkgs(tools) pip_rc = _run_pip(_pip_argv(pkgs, sudo), sudo) - ctx.exit(_after_pip(pip_rc, no_connect)) + ctx.exit(_after_pip(pip_rc, list(tools.keys()), no_connect)) diff --git a/tests/commands/install/test_install.py b/tests/commands/install/test_install.py index 1c16f3eb..c5dfac5e 100644 --- a/tests/commands/install/test_install.py +++ b/tests/commands/install/test_install.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib +import logging import sys from pathlib import Path from unittest import mock @@ -485,6 +486,213 @@ def test_install_local_sudo_prepends_sudo_preserve_home(self) -> None: mock_resync.assert_called_once() +class TestInstallHookWiringContract: + """Contract tests — the release adds NO new CLI option, and every pip path + hands ``_after_pip`` a hook-targets list (Algorithm step 4 — HOOKS). + + The hook wiring lives inside the existing surface: single targets + ``[name]``, local the ``:`` suffix value (or an empty list), + bulk the config keys in YAML order. The empty path never reaches hooks. + """ + + def test_install_facade_still_binds_only_the_four_cli_options(self) -> None: + names = {p.name for p in install.params if isinstance(p, click.Option)} + assert names == {"sudo", "version", "local", "no_connect"} + + def test_install_single_path_passes_name_as_hook_targets(self) -> None: + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()), + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents", return_value=0), + ): + result = CliRunner().invoke(app, ["install", "viewer"]) + assert result.exit_code == 0 + mock_hooks.assert_called_once_with(["viewer"]) + + def test_install_local_path_passes_suffix_tool_as_hook_targets(self) -> None: + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()), + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents", return_value=0), + ): + result = CliRunner().invoke(app, ["install", "--local", "./my-tool:mytool"]) + assert result.exit_code == 0 + mock_hooks.assert_called_once_with(["mytool"]) + + def test_install_bulk_path_passes_config_keys_as_hook_targets( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + _write_config(tmp_path, "language: python\ntools:\n viewer: latest\n afm: 1.0.x\n") + monkeypatch.chdir(tmp_path) + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()), + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents", return_value=0), + ): + result = CliRunner().invoke(app, ["install"]) + assert result.exit_code == 0 + mock_hooks.assert_called_once_with(["viewer", "afm"]) + + +class TestInstallHookFlow: + """HOOKS step — pip → hooks → activation, per-path targets, containment. + + After a successful pip in single, local, and bulk mode ``run_install_hooks`` + receives the path's hook-target list. ``--no-connect`` suppresses only the + re-sync; nothing runs after a failed pip; a hook failure is a user-facing + non-zero exit with no rollback and no re-sync; a malformed ``--local`` + suffix and the empty path never reach hooks at all. + """ + + def test_install_single_mode_runs_hook_after_pip(self) -> None: + order: list[str] = [] + + def _pip(_argv: list[str], **_kwargs: object) -> mock.MagicMock: + order.append("pip") + return _pip_result() + + def _hooks(tools: list[str]) -> None: + order.append(f"hooks:{','.join(tools)}") + + def _resync(_home: Path) -> int: + order.append("resync") + return 0 + + with ( + mock.patch.object(_install_module.subprocess, "run", side_effect=_pip) as mock_run, + mock.patch.object(_install_module, "run_install_hooks", side_effect=_hooks) as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents", side_effect=_resync), + ): + result = CliRunner().invoke(app, ["install", "viewer"]) + assert result.exit_code == 0 + mock_run.assert_called_once() + mock_hooks.assert_called_once_with(["viewer"]) + # The hook step sits between pip and the agent re-sync. + assert order == ["pip", "hooks:viewer", "resync"] + + def test_install_local_with_suffix_targets_tool_hook(self) -> None: + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents", return_value=0), + ): + result = CliRunner().invoke(app, ["install", "--local", "./my-tool:mytool"]) + assert result.exit_code == 0 + # The suffix is stripped from the pip target and names the hook tool. + assert mock_run.call_count == 1 + assert _pkgs_from_argv(mock_run.call_args[0][0]) == ["./my-tool"] + mock_hooks.assert_called_once_with(["mytool"]) + + def test_install_bulk_hooks_follow_yaml_order(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _write_config(tmp_path, "language: python\ntools:\n viewer: latest\n afm: 1.0.x\n") + monkeypatch.chdir(tmp_path) + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents", return_value=0), + ): + result = CliRunner().invoke(app, ["install"]) + assert result.exit_code == 0 + assert mock_run.call_count == 1 + assert _pkgs_from_argv(mock_run.call_args[0][0]) == ["goga-tool-viewer", "goga-tool-afm~=1.0.0"] + mock_hooks.assert_called_once_with(["viewer", "afm"]) + + def test_install_hook_failure_fails_command_without_rollback_or_resync(self) -> None: + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, + mock.patch.object( + _install_module, + "run_install_hooks", + side_effect=RuntimeError("install hook for tool 'viewer' failed: boom"), + ), + mock.patch.object(_install_module, "resync_registered_agents") as mock_resync, + ): + result = CliRunner().invoke(app, ["install", "viewer"]) + assert result.exit_code == 1 + assert "install hook for tool 'viewer' failed: boom" in result.output + # No rollback — exactly one pip install, no uninstall anywhere. + assert mock_run.call_count == 1 + mock_resync.assert_not_called() + + def test_install_no_connect_still_runs_hooks(self) -> None: + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()), + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents") as mock_resync, + ): + result = CliRunner().invoke(app, ["install", "viewer", "--no-connect"]) + assert result.exit_code == 0 + mock_hooks.assert_called_once_with(["viewer"]) + # The flag suppresses ONLY the re-sync — the hooks still ran. + mock_resync.assert_not_called() + + def test_install_pip_failure_skips_hooks_and_resync(self) -> None: + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result(2)), + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents") as mock_resync, + ): + result = CliRunner().invoke(app, ["install", "viewer"]) + assert result.exit_code == 2 + mock_hooks.assert_not_called() + mock_resync.assert_not_called() + + @pytest.mark.parametrize( + "argv", + [ + ["--local", "./x:"], + ["--local", "./x:a/b"], + ["--local", "./x:a\\b"], + ["--local", "./x:a:b"], + ], + ) + def test_install_malformed_local_suffix_rejected_before_pip(self, argv: list[str]) -> None: + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + ): + result = CliRunner().invoke(app, ["install", *argv]) + assert result.exit_code == 1 + assert "Error: malformed --local value" in result.output + assert "Traceback" not in result.output + mock_run.assert_not_called() + mock_hooks.assert_not_called() + + def test_install_local_without_suffix_warns_and_runs_no_hook(self, caplog: pytest.LogCaptureFixture) -> None: + with ( + caplog.at_level(logging.WARNING, logger=_install_module.logger.name), + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()), + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents", return_value=0) as mock_resync, + ): + result = CliRunner().invoke(app, ["install", "--local", "./my-tool"]) + assert result.exit_code == 0 + mock_hooks.assert_called_once_with([]) + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert warnings[0].message == "install hook skipped for local source" + assert warnings[0].path == "./my-tool" + assert warnings[0].hint == "pass : to enable the post-install hook" + # A missing suffix suppresses the hook, NOT the activation. + mock_resync.assert_called_once() + + def test_install_empty_mode_touches_nothing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _write_config(tmp_path, "language: python\ntools: {}\n") + monkeypatch.chdir(tmp_path) + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents") as mock_resync, + ): + result = CliRunner().invoke(app, ["install"]) + assert result.exit_code == 0 + assert "Nothing to install" in result.output + # The empty path's never-clauses: no pip, no hooks, no activation. + mock_run.assert_not_called() + mock_hooks.assert_not_called() + mock_resync.assert_not_called() + + class TestInstallActivation: """Post-install activation wiring — ``--no-connect`` flag + ACTIVATION step. From 30b2864cf25dbfc41bd85a2a3b718f230518c68f Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 23:34:55 +0000 Subject: [PATCH 029/229] feat: export full 5-name install contract API from cell facade --- goga/commands/install/__init__.py | 3 +- tests/commands/install/test_install.py | 45 ++++++++++++++++--- tests/commands/install/test_uninstall.py | 14 ++++-- .../test_connect_tool_after_install.py | 11 ++++- 4 files changed, 62 insertions(+), 11 deletions(-) diff --git a/goga/commands/install/__init__.py b/goga/commands/install/__init__.py index fad72118..fa1ca7d5 100644 --- a/goga/commands/install/__init__.py +++ b/goga/commands/install/__init__.py @@ -1,6 +1,7 @@ """Install command cell — CLI wrappers for the goga tool package lifecycle.""" +from .hook import call_install_hook, resolve_initiating_user, run_install_hooks from .install import install from .uninstall import uninstall -__all__: list[str] = ["install", "uninstall"] +__all__: list[str] = ["call_install_hook", "install", "resolve_initiating_user", "run_install_hooks", "uninstall"] diff --git a/tests/commands/install/test_install.py b/tests/commands/install/test_install.py index c5dfac5e..1ef966b8 100644 --- a/tests/commands/install/test_install.py +++ b/tests/commands/install/test_install.py @@ -36,6 +36,17 @@ def _pkgs_from_argv(argv: list[str]) -> list[str]: return argv[argv.index("install") + 1 : -1] +# The five names declared in the cell CODEMANIFEST — the two lifecycle commands +# and the three ``hook.py`` routines (exported since release 1.3.0). +_INSTALL_FACADE_ALL = [ + "call_install_hook", + "install", + "resolve_initiating_user", + "run_install_hooks", + "uninstall", +] + + class TestInstallFacade: """Contract tests — verify the install facade and Click command shape.""" @@ -45,12 +56,36 @@ def test_install_importable_from_facade(self) -> None: def test_install_facade_all(self) -> None: # Access the package module directly to assert its own ``__all__`` # (``import ... as`` would resolve to the Click command re-exported into - # ``goga.commands``, shadowing the submodule). The facade now carries - # both lifecycle commands of this cell — install and uninstall — pinned - # as the exact surface. ``resolve_version`` belongs to the ``goga/version`` - # domain cell. + # ``goga.commands``, shadowing the submodule). The facade carries the + # five declared names — both lifecycle commands of this cell plus the + # three ``hook.py`` routines — pinned as the exact surface. + # ``resolve_version`` belongs to the ``goga/version`` domain cell. facade = importlib.import_module("goga.commands.install") - assert facade.__all__ == ["install", "uninstall"] + assert facade.__all__ == _INSTALL_FACADE_ALL + + def test_cell_facades_export_full_contract_api(self) -> None: + """Every declared contract name is importable from the cell facade root. + + The Python facade rule obliges ``goga.commands.install`` to expose the + full contract API: both lifecycle commands and the three ``hook.py`` + routines. + """ + from goga.commands.install import ( + call_install_hook, + resolve_initiating_user, + run_install_hooks, + uninstall, + ) + from goga.commands.install import ( + install as install_from_facade, + ) + + assert install_from_facade is install + assert isinstance(uninstall, click.Command) + assert resolve_initiating_user is not None + assert run_install_hooks is not None + assert call_install_hook is not None + assert sys.modules["goga.commands.install"].__all__ == _INSTALL_FACADE_ALL def test_install_is_click_command(self) -> None: assert isinstance(install, click.Command) diff --git a/tests/commands/install/test_uninstall.py b/tests/commands/install/test_uninstall.py index 85cd3072..e5da27b8 100644 --- a/tests/commands/install/test_uninstall.py +++ b/tests/commands/install/test_uninstall.py @@ -27,10 +27,18 @@ def test_uninstall_importable_from_facade(self) -> None: assert uninstall is not None def test_uninstall_facade_all(self) -> None: - # The install cell facade carries both lifecycle commands — install - # and uninstall — pinned as the exact declared surface. + # The install cell facade carries the five declared names — both + # lifecycle commands plus the three ``hook.py`` routines — pinned as + # the exact surface. Uninstall runs no hooks; only the ``__all__`` + # surface it sits on grew in release 1.3.0. facade = importlib.import_module("goga.commands.install") - assert facade.__all__ == ["install", "uninstall"] + assert facade.__all__ == [ + "call_install_hook", + "install", + "resolve_initiating_user", + "run_install_hooks", + "uninstall", + ] def test_uninstall_is_click_command(self) -> None: assert isinstance(uninstall, click.Command) diff --git a/tests/integration/test_connect_tool_after_install.py b/tests/integration/test_connect_tool_after_install.py index 1495e4d7..c757a1fa 100644 --- a/tests/integration/test_connect_tool_after_install.py +++ b/tests/integration/test_connect_tool_after_install.py @@ -49,9 +49,16 @@ def test_resync_registered_agents_in_connect_all(self) -> None: def test_install_facade_surface_unchanged(self) -> None: # The install cell facade carries both lifecycle commands — install and - # uninstall — since the uninstall contract was materialized. + # uninstall — since the uninstall contract was materialized, plus the + # three ``hook.py`` routines since release 1.3.0. facade = importlib.import_module("goga.commands.install") - assert facade.__all__ == ["install", "uninstall"] + assert facade.__all__ == [ + "call_install_hook", + "install", + "resolve_initiating_user", + "run_install_hooks", + "uninstall", + ] def test_upgrade_facade_surface_unchanged(self) -> None: facade = importlib.import_module("goga.commands.upgrade") From dd3eb8cc1ac14c5680ab0978df9d24eac352e511 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Thu, 27 Aug 2026 23:42:16 +0000 Subject: [PATCH 030/229] feat: add install hook flow end-to-end integration tests --- tests/commands/install/test_integration.py | 140 +++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/tests/commands/install/test_integration.py b/tests/commands/install/test_integration.py index bc14e53e..316d2ecb 100644 --- a/tests/commands/install/test_integration.py +++ b/tests/commands/install/test_integration.py @@ -2,12 +2,14 @@ import importlib import sys +import types from pathlib import Path from unittest import mock import pytest from click.testing import CliRunner from goga.cli import app +from goga.commands.install import hook as hook_module _install_module = importlib.import_module("goga.commands.install.install") _uninstall_module = importlib.import_module("goga.commands.install.uninstall") @@ -226,3 +228,141 @@ def test_install_empty_path_with_sudo_still_empty(self, tmp_path: Path, monkeypa assert result.exit_code == 0 assert result.output.strip() == "Nothing to install" mock_run.assert_not_called() + + +class TestInstallHookFlowIntegration: + """Cross-entity: the real hook routines through the real ``install`` command. + + Only the process boundary is mocked (pip subprocess, the dynamic + ``goga_tool_`` facade import, the agent re-sync), so these tests + verify the wiring the unit tests mock away: the ``from .hook import`` path + inside ``install.py``, the per-path hook-target lists, the + pip -> hooks -> re-sync order, and the failure containment of the design's + Flows B (single mode, sudo) and C (bulk hook failure). + + The hook-fake construction rule applies: every fake facade is a + ``types.SimpleNamespace`` carrying a REAL recorder function declaring its + parameters — a bare MagicMock has no declared ``user`` parameter and the + signature projection would bare-call it. + """ + + def test_install_hook_flow_b_sudo_user_reaches_hook_in_order(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Flow B: pip -> hook -> re-sync, the hook seeing the REAL person. + + sudo ran and recorded the caller in ``SUDO_USER`` — the hook must + receive ``alice``, the actual person, not the root account the + installer may run under. + """ + monkeypatch.setenv("SUDO_USER", "alice") + order: list[str] = [] + recorder: list[dict[str, str | None]] = [] + + def _viewer_install(user: str | None = None) -> None: + order.append("hook") + recorder.append({"user": user}) + + def _pip(_argv: list[str], **_kwargs: object) -> mock.MagicMock: + order.append("pip") + return _pip_result() + + def _resync(_home: Path) -> int: + order.append("resync") + return 0 + + def _import(name: str) -> types.SimpleNamespace: + if name == "goga_tool_viewer": + return types.SimpleNamespace(install=_viewer_install) + raise ModuleNotFoundError(f"No module named {name!r}", name=name) + + with ( + mock.patch.object(_install_module.subprocess, "run", side_effect=_pip) as mock_run, + mock.patch.object(hook_module.importlib, "import_module", side_effect=_import), + mock.patch.object(_install_module, "resync_registered_agents", side_effect=_resync) as mock_resync, + ): + result = CliRunner().invoke(app, ["install", "viewer"]) + + assert result.exit_code == 0 + mock_run.assert_called_once() + mock_resync.assert_called_once() + # The initiating user is the real person behind the sudo-ed install. + assert recorder == [{"user": "alice"}] + assert order == ["pip", "hook", "resync"] + + def test_install_hook_flow_c_bulk_failure_stops_at_first_hook( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Flow C: bulk stops at the first failing hook — no re-sync, no rollback. + + The viewer hook raises; the wrapped RuntimeError carries the tool name + and hook message out as a user-facing error (exit 1). afm's hook never + runs, the pip package stays, and the agent re-sync is never reached. + """ + _write_config(tmp_path, "language: python\ntools:\n viewer: latest\n afm: 1.0.x\n") + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("SUDO_USER", raising=False) + calls: list[str] = [] + + def _viewer_install(user: str | None = None) -> None: + calls.append("viewer") + raise ValueError("boom") + + def _afm_install(user: str | None = None) -> None: + calls.append("afm") + + def _import(name: str) -> types.SimpleNamespace: + if name == "goga_tool_viewer": + return types.SimpleNamespace(install=_viewer_install) + if name == "goga_tool_afm": + return types.SimpleNamespace(install=_afm_install) + raise ModuleNotFoundError(f"No module named {name!r}", name=name) + + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, + mock.patch.object(hook_module.getpass, "getuser", return_value="bob"), + mock.patch.object(hook_module.importlib, "import_module", side_effect=_import), + mock.patch.object(_install_module, "resync_registered_agents") as mock_resync, + ): + result = CliRunner().invoke(app, ["install"]) + + assert result.exit_code == 1 + assert "install hook for tool 'viewer' failed: boom" in result.output + # No rollback — exactly one pip install, no uninstall anywhere — and + # bulk stops at the FIRST failing hook: afm's hook never runs. + assert mock_run.call_count == 1 + assert calls == ["viewer"] + mock_resync.assert_not_called() + + def test_install_local_suffix_hook_end_to_end(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Edge: ``--local :`` — suffix stripped from pip, names the hook. + + The pip argv carries the bare path, the hook target is the suffixed + tool name, the hook receives the OS user (no ``SUDO_USER``), and the + activation still runs — the suffix governs only the hook, never the + re-sync. + """ + monkeypatch.delenv("SUDO_USER", raising=False) + recorder: list[dict[str, str | None]] = [] + + def _mytool_install(user: str | None = None) -> None: + recorder.append({"user": user}) + + def _import(name: str) -> types.SimpleNamespace: + if name == "goga_tool_mytool": + return types.SimpleNamespace(install=_mytool_install) + raise ModuleNotFoundError(f"No module named {name!r}", name=name) + + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, + mock.patch.object(hook_module.getpass, "getuser", return_value="bob"), + mock.patch.object(hook_module.importlib, "import_module", side_effect=_import), + mock.patch.object(_install_module, "resync_registered_agents", return_value=0) as mock_resync, + ): + result = CliRunner().invoke(app, ["install", "--local", "./my-tool:mytool"]) + + assert result.exit_code == 0 + # The suffix is stripped from the pip target... + assert mock_run.call_count == 1 + assert _pkgs_from_argv(mock_run.call_args[0][0]) == ["./my-tool"] + # ...and names the hook target, which receives the OS user. + assert recorder == [{"user": "bob"}] + mock_resync.assert_called_once() From 213da38e5ad16ddb199fc78adaf895ce55a66f11 Mon Sep 17 00:00:00 2001 From: trifonovmixail Date: Fri, 28 Aug 2026 00:07:08 +0000 Subject: [PATCH 031/229] fix: address code review findings --- .goga/workflows/development.yml | 2 +- README.md | 18 +++-- docs/cli/install.md | 60 ++++++++++----- docs/cli/pipeline.md | 34 ++++++++- docs/getting-started.md | 2 +- docs/pipelines/shipped.md | 7 +- docs/tools.md | 8 ++ docs/workflow/apply.md | 8 +- docs/workflow/brainstorm.md | 10 +-- docs/workflow/build.md | 26 +++---- docs/workflow/define.md | 2 +- docs/workflow/design.md | 8 +- docs/workflow/discover.md | 4 +- docs/workflow/index.md | 28 +++---- docs/workflow/plan.md | 12 +-- docs/workflow/propose.md | 6 +- docs/workflow/review.md | 25 ++++--- goga/assets/pipelines/development.yml | 39 ++++++---- goga/assets/pipelines/refinement.yml | 26 ++++--- goga/commands/install/.usages/install.md | 12 ++- goga/commands/pipeline/branch.py | 11 ++- tests/commands/install/test_hook.py | 15 ++++ tests/commands/install/test_integration.py | 35 ++++++++- tests/commands/pipeline/test_branch.py | 85 +++++++++++++++++++++- 24 files changed, 354 insertions(+), 129 deletions(-) diff --git a/.goga/workflows/development.yml b/.goga/workflows/development.yml index 4183aade..30634beb 100644 --- a/.goga/workflows/development.yml +++ b/.goga/workflows/development.yml @@ -40,6 +40,6 @@ extend: timeout: "8h" script: | branch=$(git branch --show-current) - topic=$(python3 -c "import re,sys; print(re.sub(r'[^a-z0-9]+','-',sys.argv[1].lower()).strip('-'))" "$branch") + topic=$(python3 -c "from goga.commands.pipeline.branch import normalize_topic_slug; import sys; print(normalize_topic_slug(sys.argv[1]))" "$branch") python3 -m goga.build ".goga/history/$(date +%Y)/$topic/plan.md" after_script: rm -rf .ralphex diff --git a/README.md b/README.md index 483bf899..436f6a40 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,9 @@ description: End-to-end feature development title: "Create the task from a user propose" communication: true prompt: | - Save the task file as `.md` + Save the task file as `.goga/history///task.md` + (`` = current year, `YYYY`; `` = lowercase kebab-case slug + of the current git branch name; create the directory lazily) skills: - goga-propose @@ -183,6 +185,7 @@ Pipelines are resolved from `/.goga/pipelines/` (project) and `~/.goga/pipe ```bash goga pipeline development # run the development cycle (opens with brainstorm) +goga pipeline development -b feat/x # first create+switch to a fresh branch and history topic goga pipeline refinement -s discover # shorter run: skip technical discovery goga pipeline development -p 4 # cap parallelism (subject to the pipeline's dependency rules) goga pipeline development --clean # wipe persistent state for a fresh run @@ -296,15 +299,18 @@ goga install # Install a tool from a local source directory (no PyPI lookup) goga install --local + +# Same, naming the tool whose post-install hook runs +goga install --local : ``` -After installing, connect the tool to your agent (only required the first time, or to connect a new agent — `goga install` re-syncs already-connected agents automatically): +After a successful pip, `goga install` runs each freshly installed tool's optional post-install hook (a callable `install` in its facade — skipped quietly when absent), then re-syncs every already-connected agent: ```bash goga connect ``` -Pass `goga install --no-connect` to opt out of post-install activation (CI/Docker escape-hatch). Pass `goga install --sudo` for system-Python installs requiring root. +Pass `goga install --no-connect` to opt out of the post-install agent re-sync (CI/Docker escape-hatch; the post-install hooks still run). Pass `goga install --sudo` for system-Python installs requiring root. See [`goga install`](https://qarium.github.io/goga/cli/install/) for the full version-grammar rules and single/bulk/empty/local semantics. @@ -380,6 +386,8 @@ A valid tool **must**: - Expose a `main(argv: list[str])` function for CLI execution (optionally declaring a keyword-capable `ast` parameter to receive the project AST) - A `pipelines/` directory is **optional**; when present, its flat `*.yml` files are copied into `~/.goga/pipelines/` at `goga connect` time, namespaced as `:.yml` +A tool **may** additionally expose an `install(user: str | None = None)` callable in its facade package: `goga install` calls it after a successful pip, passing the initiating user (`SUDO_USER` when goga itself runs under sudo, else the current OS user) only when the parameter is declared keyword-capable. A missing or non-callable `install` is skipped quietly. + After publication, install into any project: ```bash @@ -569,7 +577,7 @@ These are not special "SDD extension points" — they are exactly the same workf `goga build` is a separate service that materializes a plan into code. Pipelines produce plans; Build executes them — and neither side is a special case of the other. A plan is handed to a ralph-loop running inside an isolated Docker container, which reads the plan, executes each task in sequence (declaration → contract tests → implementation → interface verification → logic tests → lint → review → approval), and writes the implementation into the project tree. `CODEMANIFEST` files stay **read-only** throughout — the contract is the source of truth, the build produces code that satisfies it. ```bash -goga build docs/plans/.md +goga build .goga/history///plan.md ``` The host side assembles the environment and launches the container; the in-container process then guards its environment, prepares the loop's working directory, and runs the loop with the plan as input. Credential files for `claude`, `codex`, and `opencode` are detected on the host and bind-mounted read-only into the container automatically (no flag), so the agent executing the plan runs with your live credentials. @@ -583,7 +591,7 @@ goga build plan.md -e ENV_VAR=value # forward an extra env var into the co goga build plan.md --skip-review # run tasks only, skip the review phase ``` -The review phase is configurable beyond the on/off flag: a `build.review_executor` section in `.goga/config.yml` can hand review to a different agent (`agent: codex` runs a second, review-only pass on the codex wrapper), skip it by default (`skip: true` — `--no-skip-review` forces the full cycle), select the reviewer composition (`roles: [quality, testing]`), and layer environment variables onto the review pass alone (`env: {ANTHROPIC_MODEL: reviewer}` — the variables overlay the container environment for the review subprocess only; the tasks pass never sees them, the values never reach logs or dry-run output, and like a differing agent a non-empty `env` forces a two-pass run, so it cannot be combined with a worktree). After a successful run the plan file itself moves to `docs/plans/completed/`. +The review phase is configurable beyond the on/off flag: a `build.review_executor` section in `.goga/config.yml` can hand review to a different agent (`agent: codex` runs a second, review-only pass on the codex wrapper), skip it by default (`skip: true` — `--no-skip-review` forces the full cycle), select the reviewer composition (`roles: [quality, testing]`), and layer environment variables onto the review pass alone (`env: {ANTHROPIC_MODEL: reviewer}` — the variables overlay the container environment for the review subprocess only; the tasks pass never sees them, the values never reach logs or dry-run output, and like a differing agent a non-empty `env` forces a two-pass run, so it cannot be combined with a worktree). After a successful run the plan file itself moves to `completed/` inside its own topic directory (`.goga/history///completed/`). A running build executes inside a Docker container, where its run-state and logs are written to a persistent host directory and survive across runs of the same project on the same branch — so an interrupted build can be resumed. Pass `--clean` (or `-c`) to wipe that state before launch for a fresh run. After the build, test the implementation manually. diff --git a/docs/cli/install.md b/docs/cli/install.md index b7eeb65d..fab09f82 100644 --- a/docs/cli/install.md +++ b/docs/cli/install.md @@ -2,14 +2,14 @@ `goga install` adds goga-tool packages into the **current runtime interpreter** — the exact Python that runs goga. It targets the running interpreter's pip directly, so the install lands in the correct environment regardless of how goga was deployed (pipx venv, system Python, or any other). -After a successful pip in single, local, or bulk mode, the command **activates** every agent already recorded in `~/.goga/connect.yml` (re-syncing each with its persisted `force_overwrite`) so the freshly installed tool's skills and pipelines appear in `~/.goga/` and in each connected agent's symlink tree. Pass `--no-connect` to skip activation and perform the install only (useful in CI/Docker where a transient activation failure must not fail the install). To execute an installed tool without going through an agent, run the dedicated tool-runner command. +After a successful pip in single, local, or bulk mode, the command runs each freshly installed tool's optional **post-install hook** (see [Post-install hooks](#post-install-hooks)), then **activates** every agent already recorded in `~/.goga/connect.yml` (re-syncing each with its persisted `force_overwrite`) so the freshly installed tool's skills and pipelines appear in `~/.goga/` and in each connected agent's symlink tree. Pass `--no-connect` to skip activation and perform the install only (useful in CI/Docker where a transient activation failure must not fail the install); the post-install hooks still run. To execute an installed tool without going through an agent, run the dedicated tool-runner command. ## Modes `goga install` branches on whether a tool name or `--local` path is given: -- **Single mode** (`goga install `): install one tool, then activate. `--version` resolves through the four-form grammar; the project config is ignored. -- **Local mode** (`goga install --local ` / `-l `): pip-install a local directory (no PyPI lookup). Mutually exclusive with `name`; `--version` is rejected. Activation follows the single/bulk rules. +- **Single mode** (`goga install `): install one tool, run its post-install hook, then activate. `--version` resolves through the four-form grammar; the project config is ignored. +- **Local mode** (`goga install --local [:]` / `-l [:]`): pip-install a local directory (no PyPI lookup). Mutually exclusive with `name`; `--version` is rejected. The optional `:` suffix names the tool whose post-install hook runs; without it no hook runs (a warning names the suffix as the way to enable it). Activation follows the single/bulk rules. - **Bulk mode** (`goga install`): install every tool declared in the `tools` section of `.goga/config.yml`, in a single pip invocation in YAML insertion order, then one activation pass. - **Empty mode** (`goga install` with no `tools` section): no-op — prints `Nothing to install` and exits 0. pip is not invoked, and neither is activation. @@ -73,20 +73,25 @@ Bulk mode issues **exactly one** `pip install` whose argv contains every resolve Install a pip-installable local directory instead of resolving a package from PyPI: ```bash -# Install a tool from a local source checkout +# Install a tool from a local source checkout. +# No hook runs — a warning names the way to enable it goga install --local ./my-tool +# Same path with the : suffix — the post-install hook of +# goga_tool_mytool runs after pip +goga install --local ./my-tool:mytool + # Short alias -goga install -l ./my-tool +goga install -l ./my-tool:mytool -# Local install only, no activation -goga install --local ./my-tool --no-connect +# Local install only, no activation; the hook still runs (suffix present) +goga install --local ./my-tool:mytool --no-connect -# Under sudo (pip only; activation never uses sudo) -goga install --local ./my-tool --sudo +# Under sudo (pip only; hooks and activation never use sudo) +goga install --local ./my-tool:mytool --sudo ``` -Local mode issues a single `pip install -U` (never `-e`/editable) so the directory is installed the same way a named package is. pip owns the missing-path error — there is no CLI-level existence check, so an invalid path surfaces as pip's own failure with its verbatim return code. The `name` positional and `--local` are mutually exclusive, and `--version` is rejected in local mode (both exit 1 with a clear message). The project config is **not** read in local mode; activation follows the single/bulk rules. +Local mode issues a single `pip install -U` (never `-e`/editable) so the directory is installed the same way a named package is. The **first** colon separates path from tool name: `./my-tool:mytool` installs `./my-tool` and runs the hook of `goga_tool_mytool`. A malformed suffix (an empty tool name, a path separator, or a second colon — e.g. a Windows drive path misread as a suffix) exits 1 before any pip. pip owns the missing-path error — there is no CLI-level existence check, so an invalid path surfaces as pip's own failure with its verbatim return code. The `name` positional and `--local` are mutually exclusive, and `--version` is rejected in local mode (both exit 1 with a clear message). The project config is **not** read in local mode; activation follows the single/bulk rules. ## Options @@ -95,8 +100,28 @@ Local mode issues a single `pip install -U` (never `-e`/editable) so the | `name` (positional, optional) | string | None | Tool name without the `goga-tool-` prefix. When absent, bulk/empty mode runs from `.goga/config.yml`. | | `--sudo` | flag | False | Run pip under `sudo --preserve-env=HOME` (Unix-only). Applies to pip only; activation never uses sudo. Applies to single, local, and bulk modes. | | `--version `, `-v ` | string | None | Version form in the four-form grammar. Used by single mode only; ignored in bulk mode. Both forms are aliases on the same option — `-v 1.0.x` is identical to `--version 1.0.x`. | -| `--local `, `-l ` | string | None | Path to a pip-installable local directory (local mode). Mutually exclusive with `name`; `--version` is rejected in local mode. | -| `--no-connect` | flag | False | Skip post-install agent activation. When set, the command performs the install only and the exit code is pip's. | +| `--local [:]`, `-l [:]` | string | None | Path to a pip-installable local directory, optionally followed by `:` — the name of the tool whose post-install hook runs (local mode). Mutually exclusive with `name`; `--version` is rejected in local mode. Without the suffix no hook runs. | +| `--no-connect` | flag | False | Skip post-install agent activation only. Post-install hooks still run after a successful pip; a failing hook exits 1 even with this flag. | + +## Post-install hooks + +After a successful pip in single mode, local mode with a `:` suffix, and bulk mode, the command imports each freshly installed tool's facade module `goga_tool_` and calls its `install` callable when one exists: + +- No facade module or no callable `install` → quiet skip (the hook is optional; tools without one install exactly as before). +- The hook's signature declares a keyword-capable `user` parameter → called as `install(user=)`; otherwise called with no arguments. +- The initiating user is `SUDO_USER` when goga itself runs under sudo (`sudo goga install ...`), otherwise the current OS user — the actual person, not root. The `--sudo` flag runs only pip under sudo and does not set `SUDO_USER`. +- Hook failure (an exception from the hook body): exit 1 with the tool name and the hook message; the pip package stays installed (no rollback) and activation does not run. In bulk mode the sequence stops at the first failing hook — the remaining tools' hooks are not called. +- Hooks run before activation; `--no-connect` suppresses only the activation. +- Hooks are not run by `uninstall` or `upgrade`. + +A tool with a hook looks like this: + +```python +# inside the goga_tool_mytool facade package +def install(user: str | None = None) -> None: + ... # tool-owned setup; `user` receives the initiating user + # only when the parameter is declared keyword-capable +``` ## Post-install activation @@ -125,12 +150,13 @@ The following forms are **rejected** with exit code 1 and a clear error: | Exit code | Condition | |---|---| -| 0 | pip succeeded and activation succeeded (or the registry is missing/empty), or empty mode no-op | -| non-zero (pip) | pip failed — its returncode propagated verbatim, with no translation; activation is not run | -| non-zero (activation) | pip succeeded but activation failed for one or more agents — the first non-zero per-agent failure is returned | -| 1 | a version form was rejected, `name` and `--local` were both given, `--version` was given with `--local`, `.goga/config.yml` could not be loaded in bulk/empty mode, or the pip/sudo executable could not start | +| 0 | pip and hooks succeeded and activation succeeded (or the registry is missing/empty), or empty mode no-op | +| non-zero (pip) | pip failed — its returncode propagated verbatim, with no translation; hooks and activation are not run | +| 1 | a post-install hook raised, or the hook step itself failed (e.g. the initiating user could not be resolved): the tool name and hook message go to stderr; the pip package stays, activation does not run, and bulk stops at the first failing hook | +| non-zero (activation) | pip and hooks succeeded but activation failed for one or more agents — the first non-zero per-agent failure is returned | +| 1 | a version form was rejected, `name` and `--local` were both given, `--version` was given with `--local`, the `--local` tool-name suffix is malformed, `.goga/config.yml` could not be loaded in bulk/empty mode, or the pip/sudo executable could not start | -With `--no-connect`, the exit code is always pip's (install-only semantics). +With `--no-connect`, pip's returncode is the exit code once pip and the post-install hooks have succeeded (install-only semantics); a failing hook still exits 1. ## Notes diff --git a/docs/cli/pipeline.md b/docs/cli/pipeline.md index 56a0cdae..3e6da02d 100644 --- a/docs/cli/pipeline.md +++ b/docs/cli/pipeline.md @@ -11,6 +11,7 @@ goga pipeline --list # flat list: available pipeline names (in-cont goga pipeline --list --info # overview: one bullet block per pipeline with its description goga pipeline --info # card: name, description, stages in execution order goga pipeline # run: execute the pipeline (in-container) +goga pipeline -b # run: first create+switch to a fresh branch (host-side) ``` ## Forms @@ -58,7 +59,7 @@ The card and the run share the same workflow rule set and the same compiler, so ## Run Mode (`goga pipeline `) -Run a pipeline by name. Pass the bare name only (no `.yml` extension); the container resolves the absolute path internally, compiles the goga DSL pipeline-file into an afm flow-file at `/flow.yml`, materializes the four agent prompt files into `/prompts/` (applying any `roles` overrides from the pipeline-file header — see [Custom agent prompts](#custom-agent-prompts)), and runs that via `afm run`. Passing `-p/--parallel N` caps the number of stages afm executes concurrently (it threads through to `afm run --max-parallel `); without it afm runs unbounded. A free port is allocated automatically and published on both sides (`-p :`); `afm` listens on that port inside the container. When a workflow is applied, a single log line naming it is printed to stdout; otherwise the launcher prints no status line. +Run a pipeline by name. Pass the bare name only (no `.yml` extension); the container resolves the absolute path internally, compiles the goga DSL pipeline-file into an afm flow-file at `/flow.yml`, materializes the four agent prompt files into `/prompts/` (applying any `roles` overrides from the pipeline-file header — see [Custom agent prompts](#custom-agent-prompts)), and runs that via `afm run`. Passing `-p/--parallel N` caps the number of stages afm executes concurrently (it threads through to `afm run --max-parallel `); without it afm runs unbounded. A free port is allocated automatically and published on both sides (`-p :`); `afm` listens on that port inside the container. When a workflow is applied, a single log line naming it is printed to stdout; when `-b/--branch` prepared a branch, a single `Pipeline running on branch ` line is printed before the launch; otherwise the launcher prints no status line. Pipelines are flat `*.yml` files (one per pipeline) resolved from two directories, with the project source winning on name conflicts: @@ -87,6 +88,28 @@ Pipeline running with workflow "feature-phases" If the name exists in both sources, the project source wins. The container exit code is propagated as the command's exit code. +### Branch preparation + +The run form can first prepare a fresh git branch and a fresh history topic on the host, before any docker activity: + +```bash +goga pipeline development -b feat/x +``` + +The entered name plays two roles: + +- **branch name** — used exactly as entered when creating and switching (`git switch -c`; git rejects invalid names itself); +- **history topic slug** — the normalized form that names the topic folder `.goga/history///`: lowercase, non-ASCII dropped, anything outside `[a-z0-9]` becomes `-`, repeat hyphens collapse, edge hyphens trim (`Feature/Foo_Bar` → `feature-foo-bar`, `release/1.3.0` → `release-1-3-0`). + +Occupancy is checked against three oracles in order: a local branch with the entered name, a remote-tracking branch with the entered name (local refs only — no network), and an existing `.goga/history///` folder for the current year. On a conflict — or a name that normalizes to an empty slug (a fully non-ASCII name): + +- **interactive terminal**: the reason is printed and a new name is prompted until the name is free (Ctrl-C aborts, nothing is created); +- **no terminal** (CI/scripts): the reason plus the hint `Pass another branch name via -b.` goes to stderr and the command exits 1 — no image refresh, build, or launch happens. + +When the procedure completes (a created-and-switched branch, or the already-on-branch case where the current branch's slug equals the entered slug — nothing is touched), goga prints `Pipeline running on branch ` to stdout once, before the launch. The branch name is never forwarded into the container — the container sees the branch through the mounted project, and goga does not switch back after the launch. + +The flat list, overview, and card forms silently ignore `-b` — passing it there is not an error and has no effect. + ## Prerequisites All forms launch a Docker container via the host **`docker`** CLI: @@ -159,6 +182,7 @@ stages: | `name` (positional) | string | — | Pipeline name without extension. Selects the card (`--info`) or run form; omit it and pass `--list` for the listing forms. `--list` and a name together are rejected (exit 1) | | `-l`, `--list` | flag | off | List available pipelines (flat list). Add `--info` for a one-line description per pipeline | | `-i`, `--info` | flag | off | With `--list`: print the overview. With `NAME`: print the pipeline card instead of running it | +| `-b`, `--branch` | string | — | Create and switch to a fresh branch (and a fresh `.goga/history///` history topic) before the run; see [Branch preparation](#branch-preparation). Run form only — the list/info forms silently ignore it | | `-e`, `--env` | string (repeatable) | — | Additional environment variable (`KEY=VALUE`) forwarded into the container env-file. Run form only | | `--proxy` | string | config | HTTP/HTTPS proxy URL; overrides `pipeline.proxy`. Adds `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` to the container env-file. Run form only | | `--add-host` | string (repeatable) | -- | Add a `docker run --add-host HOST:IP` entry; merges on top of `pipeline.hosts` (CLI wins on key conflict). Run form only — the info forms receive the configured `pipeline.hosts` only | @@ -234,6 +258,12 @@ Wipe persistent afm state for this pipeline/branch before launch: goga pipeline deploy --clean ``` +Start the run on a fresh branch and history topic: + +```bash +goga pipeline development -b feat/x +``` + ## Exit Codes Host side (all forms): @@ -241,7 +271,7 @@ Host side (all forms): | Code | Meaning | |------|---------| | `0` | The operation completed (container exit 0) | -| `1` | A `ClickException`: a form error (bare invocation, `--list` + name, `--workflow` + `--no-workflow`), the `pipeline` section missing in `.goga/config.yml`, an explicit `--workflow ` naming a file that does not exist or escaping the workflows dir, or a fatal image build/refresh. Or the pre-launch version check refusing the launch (a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version — a stderr message plus `SystemExit`, see [Pre-launch version check](#pre-launch-version-check)) | +| `1` | A `ClickException`: a form error (bare invocation, `--list` + name, `--workflow` + `--no-workflow`), the `pipeline` section missing in `.goga/config.yml`, an explicit `--workflow ` naming a file that does not exist or escaping the workflows dir, a branch-procedure failure (an empty topic slug or an unresolved occupancy conflict without a terminal, a failed `git switch -c` or ref listing, or a missing git binary — see [Branch preparation](#branch-preparation)), or a fatal image build/refresh. Or the pre-launch version check refusing the launch (a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version — a stderr message plus `SystemExit`, see [Pre-launch version check](#pre-launch-version-check)) | | other| The container's exit code, propagated unchanged (including the run-mode codes below) | Container side, run form: diff --git a/docs/getting-started.md b/docs/getting-started.md index c8cff640..a1d93103 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -139,7 +139,7 @@ If you want explicit control over each step instead of running the whole cycle a > The slash-command form `/goga:` works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](cli/connect.md)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-propose` (Codex uses the `$` prefix — `$goga-propose`). -The agent walks you through an interactive dialogue, then produces `docs/tasks/.md`. From there, each subsequent command takes the previous artifact as input and produces the next one. See the [Workflow](workflow/index.md) section for the full algorithm of each step in both workrounds — refinement and development — including shortcut paths for smaller changes. +The agent walks you through an interactive dialogue, then produces `.goga/history///task.md`. From there, each subsequent command takes the previous artifact as input and produces the next one. See the [Workflow](workflow/index.md) section for the full algorithm of each step in both workrounds — refinement and development — including shortcut paths for smaller changes. ## View diff --git a/docs/pipelines/shipped.md b/docs/pipelines/shipped.md index 66422fcf..5c6fac25 100644 --- a/docs/pipelines/shipped.md +++ b/docs/pipelines/shipped.md @@ -94,9 +94,10 @@ define → discover → propose → task-review records the settled technical decisions as a short ADR; `propose` formulates the structured task; `task-review` verifies it. The `define`, `discover`, `propose`, and `task-review` stages emit and consume -documents named after the current git branch (`docs/defines/`, -`docs/proposals/`, `docs/tasks/`), and each later stage falls back to -the earlier artifacts when they exist. +documents under the current branch's history topic +(`.goga/history///` — `prd.md`, `adr.md`, `task.md`, with +`` the kebab-case slug of the current git branch), and each later +stage falls back to the earlier artifacts when they exist. ## `development` diff --git a/docs/tools.md b/docs/tools.md index 03f7906a..5fbf1336 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -95,6 +95,14 @@ A valid tool must: - Each skill directory must include a `SKILL.md` file - Expose a `main(argv: list[str])` function for CLI execution +A tool **may** additionally expose an `install(user: str | None = None)` +callable in its facade package — the post-install hook. `goga install` calls +it after a successful pip, passing the initiating user (`SUDO_USER` when goga +itself runs under sudo, else the current OS user) only when the parameter is +declared keyword-capable; otherwise the hook is called with no arguments. A +missing or non-callable `install` is skipped quietly. See +[`goga install` — Post-install hooks](cli/install.md#post-install-hooks). + A `pipelines/` directory is **optional**. When present, `goga connect` copies its flat `*.yml` files into `~/.goga/pipelines/` **namespaced as `:.yml`** (where `` is the package name with the diff --git a/docs/workflow/apply.md b/docs/workflow/apply.md index 3ef65c84..cc2fa162 100644 --- a/docs/workflow/apply.md +++ b/docs/workflow/apply.md @@ -1,6 +1,6 @@ # Apply -Materialize an architecture plan into the cells file structure. Reads `docs/arch/.md` and produces actual `CODEMANIFEST` files and `.usages/` directories on disk. +Materialize an architecture plan into the cells file structure. Reads `.goga/history///arch.md` and produces actual `CODEMANIFEST` files and `.usages/` directories on disk. ## Synopsis @@ -33,7 +33,7 @@ The skill does **not** write implementation code — only the contract skeleton. | Step | Action | |---|---| -| 1. Locate the plan file | Use argument as path or `docs/arch/.md`; if no argument, list `docs/arch/` via `AskUserQuestion`; halt if file missing. | +| 1. Locate the plan file | Use argument as path or `.goga/history///arch.md`; if no argument, scan `.goga/history/*/` topic directories for `arch.md` (4-digit year) and list them via `AskUserQuestion`; halt if file missing. | | 2. Parse the plan structure | Extract implementation order, artifacts per cell, dependency map, verification checklist. | | 3. Classify cells | Mark each as **new** (directory does not exist) or **modification** (directory exists; read current CODEMANIFEST to compute diff). | @@ -78,7 +78,7 @@ Process cells **strictly in plan order** (leaves → root). For each cell: If `` is omitted: -1. Scan `docs/arch/`. +1. Scan `.goga/history/*/` topic directories for `arch.md` (4-digit year). 2. **Single file** — use automatically. 3. **Multiple files** — ask the user via `AskUserQuestion`. 4. **Empty or missing** — halt and report. @@ -95,7 +95,7 @@ If `goga` is unavailable, the skill halts. | | | |---|---| -| **Input** | `docs/arch/.md` | +| **Input** | `.goga/history///arch.md` | | **Output** | Cells on disk: `CODEMANIFEST` files and `.usages/` directories | ## What happens next diff --git a/docs/workflow/brainstorm.md b/docs/workflow/brainstorm.md index ae7cf0ad..07f200cc 100644 --- a/docs/workflow/brainstorm.md +++ b/docs/workflow/brainstorm.md @@ -12,7 +12,7 @@ Examples use the slash-command form `/goga:`, which works in agents tha ## Output artifact -`docs/arch/.md` — an architecture plan with four mandatory sections: +`.goga/history///arch.md` — an architecture plan with four mandatory sections: 1. Implementation order (leaves → root, with rationale per cell) 2. Artifacts per cell (CODEMANIFEST contents + `.usages/` files) @@ -69,7 +69,7 @@ Context flows between phases through these named reports — the orchestrator ne ### Phase 1. Intake -Accept the description (brief, detailed, or a path to `docs/tasks/.md`). When a task file is supplied, its sections feed the pipeline: +Accept the description (brief, detailed, or a path to `.goga/history///task.md`). When a task file is supplied, its sections feed the pipeline: - Current state → context for primary analysis - Description and boundaries → design basis @@ -130,7 +130,7 @@ Assemble the CODEMANIFEST for each cell (Header → Body → Footer) and propose ### Phase 9. Plan Assembly -Write the architecture plan to `docs/arch/.md` with the four mandatory sections. Each CODEMANIFEST must be syntactically correct; modifications are described as diffs; file names must match the project structure. +Write the architecture plan to `.goga/history///arch.md` with the four mandatory sections. Each CODEMANIFEST must be syntactically correct; modifications are described as diffs; file names must match the project structure. **WAIT:** present the plan and obtain confirmation. **STOP if:** plan incomplete. @@ -156,8 +156,8 @@ Applies to every interactive phase: | | | |------------|--------------------------------------------------| -| **Input** | `docs/tasks/.md` (approved task) | -| **Output** | `docs/arch/.md` — cells architecture plan | +| **Input** | `.goga/history///task.md` (approved task) | +| **Output** | `.goga/history///arch.md` — cells architecture plan | ## What happens next diff --git a/docs/workflow/build.md b/docs/workflow/build.md index 9e9586d9..1acffddd 100644 --- a/docs/workflow/build.md +++ b/docs/workflow/build.md @@ -39,14 +39,14 @@ Inside the container, the ralph-loop executes the plan: one task per iteration, ## When to use - After `plan` and `review(plan)`, when the plan is approved. -- Whenever an execution plan exists in `docs/plans/` and is ready to be executed. +- Whenever an execution plan exists under `.goga/history/` and is ready to be executed. ## Inputs and outputs | | | |---|---| -| **Input** | `docs/plans/.md` — the execution plan | -| **Output** | Implemented code in the project tree; after a successful run the plan itself moves to `docs/plans/completed/.md` | +| **Input** | `.goga/history///plan.md` — the execution plan | +| **Output** | Implemented code in the project tree; after a successful run the plan itself moves to `.goga/history///completed/plan.md` | ## Options @@ -73,23 +73,23 @@ Timeout and iteration options fall back to `.goga/config.yml` when not provided ## Examples ```bash -goga build docs/plans/json-export.md -goga build docs/plans/json-export.md --dry-run -goga build docs/plans/json-export.md --skip-manifest-check -goga build docs/plans/json-export.md -e ANTHROPIC_API_KEY=sk-xxx +goga build .goga/history//json-export/plan.md +goga build .goga/history//json-export/plan.md --dry-run +goga build .goga/history//json-export/plan.md --skip-manifest-check +goga build .goga/history//json-export/plan.md -e ANTHROPIC_API_KEY=sk-xxx # Force-refresh the image before launch (build when dockerfile is declared, else pull) -goga build docs/plans/json-export.md --update +goga build .goga/history//json-export/plan.md --update # Route container traffic through a corporate proxy and add a local host entry -goga build docs/plans/json-export.md --proxy http://corp:3128 --add-host foo.local:127.0.0.1 +goga build .goga/history//json-export/plan.md --proxy http://corp:3128 --add-host foo.local:127.0.0.1 # Wipe persistent ralph-loop state before launch (start fresh) -goga build docs/plans/json-export.md --clean +goga build .goga/history//json-export/plan.md --clean # Without --clean, ralph-loop state persists across runs of the same project+branch -goga build docs/plans/json-export.md -goga build docs/plans/json-export.md # second run reuses .ralphex/ from the first +goga build .goga/history//json-export/plan.md +goga build .goga/history//json-export/plan.md # second run reuses .ralphex/ from the first ``` ## Exit codes @@ -101,7 +101,7 @@ goga build docs/plans/json-export.md # second run reuses .ralphex/ from the fir ## What happens next -- The completed plan now lives in `docs/plans/completed/` — nothing further reads it, but it stays for reference. +- The completed plan now lives in `.goga/history///completed/` — nothing further reads it, but it stays for reference. - Test the produced implementation manually. - If bugs or defects are found — fix them with [`change`](change.md). - Once the implementation is stable — run [`accept`](accept.md) for final sign-off. diff --git a/docs/workflow/define.md b/docs/workflow/define.md index 81631b03..64a2d626 100644 --- a/docs/workflow/define.md +++ b/docs/workflow/define.md @@ -12,7 +12,7 @@ Examples use the slash-command form `/goga:`, which works in agents tha ## Output artifact -`docs/defines/.md` — a PRD with sections for the problem, users, goals, user experience, requirements, constraints, scope, and success criteria. If a PRD with the same topic already exists, the collision is handled explicitly rather than silently overwriting unrelated work. +`.goga/history///prd.md` — a PRD with sections for the problem, users, goals, user experience, requirements, constraints, scope, and success criteria. If a PRD with the same topic already exists, the collision is handled explicitly rather than silently overwriting unrelated work. ## Pipeline diff --git a/docs/workflow/design.md b/docs/workflow/design.md index 3c10bd42..1cfbbbc0 100644 --- a/docs/workflow/design.md +++ b/docs/workflow/design.md @@ -12,7 +12,7 @@ Examples use the slash-command form `/goga:`, which works in agents tha ## Output artifact -`docs/design/.md` — a design document with these sections: +`.goga/history///design.md` — a design document with these sections: - Contract changes (added/removed/modified entities) - Applied CODEMANIFEST fixes @@ -72,13 +72,13 @@ The document does **not** contain implementation code and does **not** produce a | Step | Action | |---|---| | 1. Write from template | Use the design-doc template. | -| 2. Save | Path: `docs/design/.md`. Create directory if missing; overwrite if exists. | +| 2. Save | Path: `.goga/history///design.md`. Create directory if missing; overwrite if exists. | ## Resolving the function name If `` is omitted: -1. Scan `docs/design/`. +1. Scan `.goga/history/*/` topic directories for `design.md` (4-digit year). 2. **Single file** — use automatically. 3. **Multiple files** — ask the user. 4. **Empty or missing** — halt and ask the user to run `design` first. @@ -88,7 +88,7 @@ If `` is omitted: | | | |---|---| | **Input** | Modified CODEMANIFEST files (from `apply`) | -| **Output** | `docs/design/.md` | +| **Output** | `.goga/history///design.md` | ## What happens next diff --git a/docs/workflow/discover.md b/docs/workflow/discover.md index 3026690a..f563dfff 100644 --- a/docs/workflow/discover.md +++ b/docs/workflow/discover.md @@ -12,7 +12,7 @@ Examples use the slash-command form `/goga:`, which works in agents tha ## Output artifact -`docs/proposals/.md` — a short ADR (slug name, lowercase kebab-case; the directory is created lazily if needed). An ADR is 1–3 sentences: the context, the decision, and why. Optional sections (`Status`, `Considered Options`, `Consequences`) are included only when they add genuine value. +`.goga/history///adr.md` — a short ADR (slug name, lowercase kebab-case; the directory is created lazily if needed). An ADR is 1–3 sentences: the context, the decision, and why. Optional sections (`Status`, `Considered Options`, `Consequences`) are included only when they add genuine value. ## Algorithm @@ -68,7 +68,7 @@ If a decision is easy to reverse, skip it. If it is not surprising, nobody will | | | |---|---| | **Input** | A decision worth recording, in natural language | -| **Output** | `docs/proposals/.md` — a short ADR | +| **Output** | `.goga/history///adr.md` — a short ADR | ## What happens next diff --git a/docs/workflow/index.md b/docs/workflow/index.md index b41671ad..01188c64 100644 --- a/docs/workflow/index.md +++ b/docs/workflow/index.md @@ -9,7 +9,7 @@ Goga organizes feature development as two global workrounds — **refinement** a | **Refinement** | Settle the *what and why*: turn a product idea into a verified engineering task | `define` → `discover` → `propose` → `review(task)` | | **Development** | Build the *how*: from a verified task to accepted implementation | `brainstorm` → `apply` → `design` → `plan` → `build` → `change` → `accept` | -The refinement workround ends with a task review: once the task in `docs/tasks/` is verified, the product side is settled and development can start. The development workround picks up the verified task and takes it all the way to an acceptance report. +The refinement workround ends with a task review: once the task in `.goga/history///task.md` is verified, the product side is settled and development can start. The development workround picks up the verified task and takes it all the way to an acceptance report. > Command examples in this section use the slash-command form `/goga:`. This form works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](../cli/connect.md)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-` (Codex uses the `$` prefix — for example, `$goga-propose`). @@ -17,9 +17,9 @@ The refinement workround ends with a task review: once the task in `docs/tasks/` Refinement turns a raw idea into a task definition that is worth engineering. Three entry depths exist, depending on how much elaboration the idea needs: -- **[`define`](define.md)** — the product is not yet understood: the problem, users, goals, and success criteria are extracted into a PRD (`docs/defines/.md`). The longest refinement path opens here. -- **[`discover`](discover.md)** — the product is clear, but hard-to-reverse technical decisions are not: `discover` interviews them out and records short ADRs (`docs/proposals/.md`). -- **[`propose`](propose.md)** — everything above is already settled: the request is formulated directly as a structured task (`docs/tasks/.md`). +- **[`define`](define.md)** — the product is not yet understood: the problem, users, goals, and success criteria are extracted into a PRD (`.goga/history///prd.md`). The longest refinement path opens here. +- **[`discover`](discover.md)** — the product is clear, but hard-to-reverse technical decisions are not: `discover` interviews them out and records short ADRs (`.goga/history///adr.md`). +- **[`propose`](propose.md)** — everything above is already settled: the request is formulated directly as a structured task (`.goga/history///task.md`). ``` define → discover → propose → review(task) @@ -90,18 +90,20 @@ define → discover → propose → review(task) | Command | Workround | Input artifact | Output artifact | |---|---|---|---| -| [`define`](define.md) | Refinement | Product idea | `docs/defines/.md` (PRD) | -| [`discover`](discover.md) | Refinement | A decision worth recording | `docs/proposals/.md` (short ADR) | -| [`propose`](propose.md) | Refinement | User request text | `docs/tasks/.md` | -| [`review`](review.md) | both | Any artifact in `docs/` | Review report | -| [`brainstorm`](brainstorm.md) | Development | `docs/tasks/.md` | `docs/arch/.md` | -| [`apply`](apply.md) | Development | `docs/arch/.md` | Cell file structure (CODEMANIFEST, `.usages/`) | -| [`design`](design.md) | Development | Modified CODEMANIFEST | `docs/design/.md` | -| [`plan`](plan.md) | Development | `docs/design/.md` | `docs/plans/.md` | -| [`build`](build.md) | Development | `docs/plans/.md` | Implemented code (via a ralph-loop); the plan moves to `docs/plans/completed/` on success | +| [`define`](define.md) | Refinement | Product idea | `.goga/history///prd.md` (PRD) | +| [`discover`](discover.md) | Refinement | A decision worth recording | `.goga/history///adr.md` (short ADR) | +| [`propose`](propose.md) | Refinement | User request text | `.goga/history///task.md` | +| [`review`](review.md) | both | Any artifact in `.goga/history/` (or a cell) | Review report | +| [`brainstorm`](brainstorm.md) | Development | `.goga/history///task.md` | `.goga/history///arch.md` | +| [`apply`](apply.md) | Development | `.goga/history///arch.md` | Cell file structure (CODEMANIFEST, `.usages/`) | +| [`design`](design.md) | Development | Modified CODEMANIFEST | `.goga/history///design.md` | +| [`plan`](plan.md) | Development | `.goga/history///design.md` | `.goga/history///plan.md` | +| [`build`](build.md) | Development | `.goga/history///plan.md` | Implemented code (via a ralph-loop); the plan moves to `.goga/history///completed/` on success | | [`change`](change.md) | Development | Change description | Modified code + reconciled contracts and usages | | [`accept`](accept.md) | Development | Completed implementation | Final acceptance report | +Workflow artifacts live at `.goga/history///.md` (`` ∈ `prd | adr | task | arch | design | plan`): `` is the current year as `YYYY`, and `` is a lowercase kebab-case slug — non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed (branch `release/1.3.0` → `release-1-3-0`). The topic directory is created lazily by the stage that first writes into it, and the whole `.goga/history/` tree is git-ignored by default. `goga pipeline -b ` prepares both a fresh branch and its fresh topic before a run. + ## Next steps - Product side not settled — open [`define`](define.md). diff --git a/docs/workflow/plan.md b/docs/workflow/plan.md index 611b4334..714ac83a 100644 --- a/docs/workflow/plan.md +++ b/docs/workflow/plan.md @@ -12,7 +12,7 @@ Examples use the slash-command form `/goga:`, which works in agents tha ## Output artifact -`docs/plans/.md` — an execution plan with this structure: +`.goga/history///plan.md` — an execution plan with this structure: - `### Task N: ` headings define individual tasks - `- [ ]` checkboxes mark incomplete items @@ -32,7 +32,7 @@ Only ONE task is executed per ralph-loop iteration. | 3 | Load language implementation rules via `goga-lang-disp`. | | 4 | Load the plan template from `output-template.md`. | | 5 | Load project conventions from `conventions.md`. | -| 6 | Read the design document from `docs/design/<function-name>.md`. Halt if missing. | +| 6 | Read the design document from `.goga/history/<year>/<topic>/design.md`. Halt if missing. | ### Phase 2. Compile design document into plan @@ -40,7 +40,7 @@ Only ONE task is executed per ralph-loop iteration. |---|---| | 1. Extract data from design | Contract changes → task scope; applied fixes → context; entity interactions → diagrams; code stack traces → verified chains; algorithm design → checkboxes; cross-cutting concerns → distributed across tasks; usages analysis → task context; `.usages/` updates → tasks; test stack traces → test instructions. Transfer traces and diagrams **verbatim**, do not summarize. | | 2. Compile into ralph-loop tasks | For each entity: create tasks following DSL compilation rules, cell boundaries, ralphex plan-format requirements, task ordering, TDD workflow, task formation rules, templates, and project conventions. | -| 3. Save the plan | Write to `docs/plans/<function-name>.md`. Create directory if missing. | +| 3. Save the plan | Write to `.goga/history/<year>/<topic>/plan.md`. Create directory if missing. | ### Phase 3. Plan verification @@ -119,7 +119,7 @@ After completion: `→ REVIEW → APPROVAL → NEXT_TASK`. If `<function-name>` is omitted: -1. Scan `docs/design/`. +1. Scan `.goga/history/*/` topic directories for `design.md` (4-digit year). 2. **Single file** — use automatically. 3. **Multiple files** — ask the user. 4. **Empty or missing** — halt and ask the user to run `design` first. @@ -128,8 +128,8 @@ If `<function-name>` is omitted: | | | |---|---| -| **Input** | `docs/design/<function-name>.md` | -| **Output** | `docs/plans/<function-name>.md` | +| **Input** | `.goga/history/<year>/<topic>/design.md` | +| **Output** | `.goga/history/<year>/<topic>/plan.md` | ## What happens next diff --git a/docs/workflow/propose.md b/docs/workflow/propose.md index b66e783f..48f1964b 100644 --- a/docs/workflow/propose.md +++ b/docs/workflow/propose.md @@ -12,7 +12,7 @@ Examples use the slash-command form `/goga:<command>`, which works in agents tha ## Output artifact -`docs/tasks/<topic>.md` — a structured task with the following sections: +`.goga/history/<year>/<topic>/task.md` — a structured task with the following sections: - Current State - Description @@ -70,7 +70,7 @@ Assess scale (number of entities, subsystems, interaction complexity). Present a ### Phase 7. Task persistence -Write the task to `docs/tasks/<topic>.md` using the task template. Present a summary: task name, stack, dependency count, scope, risks. +Write the task to `.goga/history/<year>/<topic>/task.md` using the task template. Present a summary: task name, stack, dependency count, scope, risks. ## Dialogue rules @@ -83,7 +83,7 @@ Write the task to `docs/tasks/<topic>.md` using the task template. Present a sum | | | |---|---| | **Input** | Free-form description in natural language | -| **Output** | `docs/tasks/<topic>.md` | +| **Output** | `.goga/history/<year>/<topic>/task.md` | ## What happens next diff --git a/docs/workflow/review.md b/docs/workflow/review.md index c8993ec8..8caa615c 100644 --- a/docs/workflow/review.md +++ b/docs/workflow/review.md @@ -13,13 +13,18 @@ Examples use the slash-command form `/goga:<command>`, which works in agents tha ## Dispatch logic -| Argument shape | Type | Specialized skill | +When the argument is a path under `.goga/history/`, it must match `.goga/history/<year>/<topic>/<kind>.md` (a 4-digit `<year>`); the review type comes from the `<kind>` filename: + +| Artifact kind | Type | Specialized skill | |---|---|---| -| Path under `docs/tasks/` | `task` | `goga-review-task` | -| Path under `docs/arch/` | `architecture` | `goga-review-arch` | -| Path under `docs/design/` | `design` | `goga-review-design` | -| Path under `docs/plans/` | `plan` | `goga-review-plan` | -| Cell directory or other | `cell` | `goga-review-cell` | +| `prd.md` | `prd` | *review not supported* (no dedicated skill yet) | +| `adr.md` | `adr` | *review not supported* (no dedicated skill yet) | +| `task.md` | `task` | `goga-review-task` | +| `arch.md` | `architecture` | `goga-review-arch` | +| `design.md` | `design` | `goga-review-design` | +| `plan.md` | `plan` | `goga-review-plan` | + +Any other filename, a path outside `.goga/history/`, or a cell directory → **cell** (`goga-review-cell`). For `prd` and `adr` the dispatcher stops with "review not supported" — no dedicated review skill exists for those kinds yet. If the argument is empty, the dispatcher asks which type to review via `AskUserQuestion`. @@ -43,7 +48,7 @@ The task review `review(task)` is the checkpoint that closes refinement: it veri ## Algorithm — Task review (`goga-review-task`) -Validates `docs/tasks/<topic>.md` for completeness, correctness, and consistency. +Validates `.goga/history/<year>/<topic>/task.md` for completeness, correctness, and consistency. | Phase | Action | |---|---| @@ -61,7 +66,7 @@ Validates `docs/tasks/<topic>.md` for completeness, correctness, and consistency ## Algorithm — Architecture review (`goga-review-arch`) -Validates `docs/arch/<topic>.md` for semantic correctness across the whole plan — model cohesion, cell boundaries, requirement sufficiency. +Validates `.goga/history/<year>/<topic>/arch.md` for semantic correctness across the whole plan — model cohesion, cell boundaries, requirement sufficiency. | Phase | Action | |---|---| @@ -79,7 +84,7 @@ Validates `docs/arch/<topic>.md` for semantic correctness across the whole plan ## Algorithm — Design review (`goga-review-design`) -Verifies `docs/design/<feature-name>.md` for logical correctness by tracing the full code stack for each entry point and test scenario. +Verifies `.goga/history/<year>/<topic>/design.md` for logical correctness by tracing the full code stack for each entry point and test scenario. | Phase | Action | |---|---| @@ -92,7 +97,7 @@ Verifies `docs/design/<feature-name>.md` for logical correctness by tracing the ## Algorithm — Plan review (`goga-review-plan`) -Verifies `docs/plans/<feature-name>.md` for completeness and correctness before passing to the ralph-loop. +Verifies `.goga/history/<year>/<topic>/plan.md` for completeness and correctness before passing to the ralph-loop. | Phase | Action | |---|---| diff --git a/goga/assets/pipelines/development.yml b/goga/assets/pipelines/development.yml index cedad760..a858cda7 100644 --- a/goga/assets/pipelines/development.yml +++ b/goga/assets/pipelines/development.yml @@ -6,9 +6,10 @@ description: "Development process" title: "Task-based architecture development" communication: true prompt: | - Use the task `.goga/history/<year>/<git branch --show-current>/task.md`, if it exists - Save the architecture plan as `.goga/history/<year>/<git branch --show-current>/arch.md` - (`<year>` = current year, `YYYY`; create the directory lazily) + Use the task `.goga/history/<year>/<topic>/task.md`, if it exists + Save the architecture plan as `.goga/history/<year>/<topic>/arch.md` + (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the + current git branch name (`release/1.3.0` → `release-1-3-0`); create the directory lazily) **CODEMANIFEST files** must be described at a functional and business-logic level, remaining strictly implementation-agnostic: - Focus on defining "what" the system should achieve (expected behavior, business rules, inputs, and outputs) rather than "how" to code it. @@ -35,16 +36,18 @@ description: "Development process" title: "Review of the created architectural plan" communication: true prompt: | - Review the architecture plan `.goga/history/<year>/<git branch --show-current>/arch.md` - (`<year>` = current year, `YYYY`) + Review the architecture plan `.goga/history/<year>/<topic>/arch.md` + (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the + current git branch name) skills: - goga-review-arch - name: apply-architecture title: "Apply the created architectural plan" prompt: | - Apply the architecture plan `.goga/history/<year>/<git branch --show-current>/arch.md` - (`<year>` = current year, `YYYY`) + Apply the architecture plan `.goga/history/<year>/<topic>/arch.md` + (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the + current git branch name) skills: - goga-apply @@ -52,8 +55,9 @@ description: "Development process" title: "Designing architecture into code" communication: true prompt: | - Save the design document as `.goga/history/<year>/<git branch --show-current>/design.md` - (`<year>` = current year, `YYYY`; create the directory lazily) + Save the design document as `.goga/history/<year>/<topic>/design.md` + (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the + current git branch name; create the directory lazily) skills: - goga-design @@ -61,8 +65,9 @@ description: "Development process" title: "Review of the created design plan" communication: true prompt: | - Review the design document `.goga/history/<year>/<git branch --show-current>/design.md` - (`<year>` = current year, `YYYY`) + Review the design document `.goga/history/<year>/<topic>/design.md` + (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the + current git branch name) skills: - goga-review-design @@ -70,9 +75,10 @@ description: "Development process" title: "Create the coding plan" communication: true prompt: | - Use the design document `.goga/history/<year>/<git branch --show-current>/design.md` - Save the plan as `.goga/history/<year>/<git branch --show-current>/plan.md` - (`<year>` = current year, `YYYY`; create the directory lazily) + Use the design document `.goga/history/<year>/<topic>/design.md` + Save the plan as `.goga/history/<year>/<topic>/plan.md` + (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the + current git branch name; create the directory lazily) skills: - goga-plan @@ -80,8 +86,9 @@ description: "Development process" title: "Review of the created coding plan" communication: true prompt: | - Review the plan `.goga/history/<year>/<git branch --show-current>/plan.md` - (`<year>` = current year, `YYYY`) + Review the plan `.goga/history/<year>/<topic>/plan.md` + (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the + current git branch name) skills: - goga-review-plan diff --git a/goga/assets/pipelines/refinement.yml b/goga/assets/pipelines/refinement.yml index 1af9c91b..3750db67 100644 --- a/goga/assets/pipelines/refinement.yml +++ b/goga/assets/pipelines/refinement.yml @@ -6,8 +6,9 @@ description: "Task refinement process" title: "Product definition & create PRD" communication: true prompt: | - Save the PRD file as `.goga/history/<year>/<git branch --show-current>/prd.md` - (`<year>` = current year, `YYYY`; create the directory lazily) + Save the PRD file as `.goga/history/<year>/<topic>/prd.md` + (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the + current git branch name (`release/1.3.0` → `release-1-3-0`); create the directory lazily) Constrains: - Don't research a project until you receive the task @@ -18,11 +19,12 @@ description: "Task refinement process" title: "Technical discovery & create ADR" communication: true prompt: | - Use `.goga/history/<year>/<git branch --show-current>/prd.md` as the PRD file, if it exists (4-digit year). + Use `.goga/history/<year>/<topic>/prd.md` as the PRD file, if it exists (4-digit year). If PRD file does not exist — ask user about task. - Save the ADR file as `.goga/history/<year>/<git branch --show-current>/adr.md` - (`<year>` = current year, `YYYY`; create the directory lazily) + Save the ADR file as `.goga/history/<year>/<topic>/adr.md` + (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the + current git branch name (`release/1.3.0` → `release-1-3-0`); create the directory lazily) Communication: - You **MUST** follow the `File-Based Dialog Protocol` for every round of questions. @@ -33,12 +35,13 @@ description: "Task refinement process" title: "Task decomposition & create Task(s)" communication: true prompt: | - Use the ADR `.goga/history/<year>/<git branch --show-current>/adr.md` as the input for task formulation, if it exists (4-digit year). - If ADR does not exist — try `.goga/history/<year>/<git branch --show-current>/prd.md` (4-digit year) as the PRD file. + Use the ADR `.goga/history/<year>/<topic>/adr.md` as the input for task formulation, if it exists (4-digit year). + If ADR does not exist — try `.goga/history/<year>/<topic>/prd.md` (4-digit year) as the PRD file. If PRD file does not exists — ask user about task. - Save the task file as `.goga/history/<year>/<git branch --show-current>/task.md` - (`<year>` = current year, `YYYY`; create the directory lazily) + Save the task file as `.goga/history/<year>/<topic>/task.md` + (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the + current git branch name (`release/1.3.0` → `release-1-3-0`); create the directory lazily) skills: - goga-propose @@ -46,7 +49,8 @@ description: "Task refinement process" title: "Review of the created task" communication: true prompt: | - Review the task `.goga/history/<year>/<git branch --show-current>/task.md` - (`<year>` = current year, `YYYY`) + Review the task `.goga/history/<year>/<topic>/task.md` + (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the + current git branch name) skills: - goga-review-task diff --git a/goga/commands/install/.usages/install.md b/goga/commands/install/.usages/install.md index 2822e78e..4af4087a 100644 --- a/goga/commands/install/.usages/install.md +++ b/goga/commands/install/.usages/install.md @@ -43,7 +43,8 @@ activation only — the hooks still run. goga install foo --version 1.0.x # Install with sudo (system-Python installs requiring root); hooks and - # activation run without sudo, the hook receives SUDO_USER + # activation run without sudo, the hook receives the current OS user + # (SUDO_USER only when goga itself runs under sudo) goga install foo --sudo # Install only — skip activation; the post-install hook still runs @@ -94,7 +95,8 @@ Install a goga-tool from a local source directory instead of PyPI: goga install --local ./my-tool:mytool --no-connect # Local install under sudo (system-Python); hooks and activation run - # without sudo, the hook receives SUDO_USER + # without sudo, the hook receives the current OS user (SUDO_USER only + # when goga itself runs under sudo) goga install --local ./my-tool:mytool --sudo Local mode issues exactly one `pip install <path> -U` against the current @@ -143,8 +145,10 @@ suffix, and bulk), the command imports each installed tool's facade module - The hook's signature declares a keyword-capable parameter `user` → it is called as `install(user=<initiating user>)`; otherwise it is called with no arguments. -- The initiating user is `SUDO_USER` when the install runs under sudo, - otherwise the current OS user — the actual initiator, not root. What the +- The initiating user is `SUDO_USER` when goga itself runs under sudo + (`sudo goga install ...`), otherwise the current OS user — the actual + initiator, not root. The `--sudo` flag runs only pip under sudo and does + not set `SUDO_USER`. What the tool does with the string (chown, per-user config, git identity) is the tool's business; goga does not re-execute the hook as that user. - Hook failure (an exception from the hook body): non-zero exit with the diff --git a/goga/commands/pipeline/branch.py b/goga/commands/pipeline/branch.py index 3d69b73d..f30c2091 100644 --- a/goga/commands/pipeline/branch.py +++ b/goga/commands/pipeline/branch.py @@ -208,7 +208,8 @@ def ensure_pipeline_branch(branch_name: str) -> str: Raises: click.ClickException: an empty topic slug or an unresolved occupancy conflict without a terminal, a failed create-and-switch (carrying - git's stderr), or a missing git binary. + git's stderr), a git infrastructure failure of the occupancy + oracles (carrying git's stderr), or a missing git binary. click.Abort: Ctrl-C or EOF at the re-ask prompt — the repository is left untouched. """ @@ -234,6 +235,14 @@ def ensure_pipeline_branch(branch_name: str) -> str: ) except FileNotFoundError as exc: raise click.ClickException(_GIT_REQUIRED_MESSAGE) from exc + except subprocess.CalledProcessError as exc: + # A git infrastructure failure of the oracles themselves (e.g. the + # ref listing exiting 128 outside a repository) — not an occupancy + # answer; surfaced as a clean failure, never a traceback. + stderr = exc.stderr if isinstance(exc.stderr, str) else "" + raise click.ClickException( + f"git failed to check branch occupancy for {branch_name!r}: {stderr.strip()}" + ) from exc if conflict is not None: branch_name = _reask_branch_name(conflict) continue diff --git a/tests/commands/install/test_hook.py b/tests/commands/install/test_hook.py index a194ad61..d7c0073b 100644 --- a/tests/commands/install/test_hook.py +++ b/tests/commands/install/test_hook.py @@ -119,6 +119,21 @@ def _fake_install(user: str | None = None) -> None: assert calls == [{"user": "alice"}] mock_import.assert_called_once_with("goga_tool_fake") + def test_call_install_hook_keyword_only_user_injected(self) -> None: + """``def install(*, user)`` — keyword-only IS keyword-capable (the value is injected).""" + calls: list[dict[str, str | None]] = [] + + def _fake_install(*, user: str | None = None) -> None: + calls.append({"user": user}) + + fake_module = types.SimpleNamespace(install=_fake_install) + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module): + invoked = hook_module.call_install_hook("fake", "alice") + assert invoked is True + # A bare call would raise TypeError (missing keyword-only argument); + # the recorded value proves the keyword injection happened. + assert calls == [{"user": "alice"}] + def test_call_install_hook_bare_call_when_no_user_parameter(self) -> None: """A hook without a ``user`` parameter is called with NO arguments.""" calls: list[tuple[()]] = [] diff --git a/tests/commands/install/test_integration.py b/tests/commands/install/test_integration.py index 316d2ecb..cf7619a6 100644 --- a/tests/commands/install/test_integration.py +++ b/tests/commands/install/test_integration.py @@ -6,6 +6,7 @@ from pathlib import Path from unittest import mock +import click import pytest from click.testing import CliRunner from goga.cli import app @@ -52,10 +53,11 @@ def test_install_cli_help_lists_options(self) -> None: assert result.exit_code == 0 assert "--sudo" in result.output assert "--version" in result.output - # Click uppercases the argument metavar (NAME) in the usage line and - # emits an ``Arguments:`` section; either marker confirms the required - # positional argument is declared. - assert "NAME" in result.output.upper() or "Arguments" in result.output + # Structural check — the --help prose contains the word "name", so a + # substring assert on the rendered text cannot fail; assert the + # positional argument is declared on the command instead. + arguments = [param for param in _install_module.install.params if isinstance(param, click.Argument)] + assert [param.name for param in arguments] == ["name"] def test_install_cli_plain_dispatch(self) -> None: """``goga install foo`` composes the canonical argv and exits with pip's returncode.""" @@ -332,6 +334,31 @@ def _import(name: str) -> types.SimpleNamespace: assert calls == ["viewer"] mock_resync.assert_not_called() + def test_install_identity_resolution_failure_is_clean_cli_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + """An unwrapped hook-step failure (identity resolution) → clean exit 1, no traceback. + + The user-resolution step runs before any hook; its raw failure message + surfaces as a user-facing error (the exit-code matrix's "unwrapped + hook-step failure" row): pip's package stays, no hook runs, and the + activation re-sync is never reached. + """ + monkeypatch.delenv("SUDO_USER", raising=False) + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, + mock.patch.object(hook_module.getpass, "getuser", side_effect=KeyError("uid not found")), + mock.patch.object(hook_module.importlib, "import_module") as mock_import, + mock.patch.object(_install_module, "resync_registered_agents") as mock_resync, + ): + result = CliRunner().invoke(app, ["install", "viewer"]) + + assert result.exit_code == 1 + # click's exception formatter produced the message — not a raw traceback. + assert "Error:" in result.output + assert "'uid not found'" in result.output + mock_run.assert_called_once() + mock_import.assert_not_called() + mock_resync.assert_not_called() + def test_install_local_suffix_hook_end_to_end(self, monkeypatch: pytest.MonkeyPatch) -> None: """Edge: ``--local <path>:<tool>`` — suffix stripped from pip, names the hook. diff --git a/tests/commands/pipeline/test_branch.py b/tests/commands/pipeline/test_branch.py index 3b70180d..25a262c4 100644 --- a/tests/commands/pipeline/test_branch.py +++ b/tests/commands/pipeline/test_branch.py @@ -18,6 +18,7 @@ import subprocess import typing +from datetime import datetime from pathlib import Path from unittest import mock @@ -62,7 +63,7 @@ def _git_run_dispatch( def _run(argv: list[str], **_kwargs: object) -> _GitResult: for key, default_outcome in outcomes.items(): - if tuple(argv[1 : 1 + len(key)]) == key or tuple(argv[1:]) == key: + if tuple(argv[1 : 1 + len(key)]) == key: outcome = default_outcome if queues[key] is not None: if not queues[key]: @@ -145,8 +146,11 @@ def test_ensure_pipeline_branch_signature(self) -> None: hints = typing.get_type_hints(branch_module.ensure_pipeline_branch) assert hints == {"branch_name": str, "return": str} - def test_ensure_pipeline_branch_free_name_returns_entered_name(self) -> None: + def test_ensure_pipeline_branch_free_name_returns_entered_name( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """A free name creates-and-switches and returns the entered name (str → str).""" + monkeypatch.chdir(tmp_path) run_mock = _git_run_dispatch( show_current=_GitResult(stdout="main\n"), show_ref=subprocess.CalledProcessError(1, "git"), @@ -295,6 +299,19 @@ def test_check_branch_occupancy_stray_file_is_not_a_topic( with mock.patch.object(branch_module.subprocess, "run", run_mock): assert branch_module.check_branch_occupancy("feat/x", "feat-x", "2026") is None + def test_check_branch_occupancy_ref_listing_failure_propagates(self) -> None: + """A git infrastructure failure of oracle 2 itself propagates (not an occupancy answer).""" + run_mock = _git_run_dispatch( + show_current=_GitResult(stdout="main\n"), + show_ref=subprocess.CalledProcessError(1, "git"), + for_each_ref=subprocess.CalledProcessError(128, "git", stderr="fatal: not a git repository"), + ) + with ( + mock.patch.object(branch_module.subprocess, "run", run_mock), + pytest.raises(subprocess.CalledProcessError), + ): + branch_module.check_branch_occupancy("feat/x", "feat-x", "2026") + # --- Logic tests: ensure_pipeline_branch (the branch-procedure orchestrator) --- @@ -399,8 +416,11 @@ def test_ensure_pipeline_branch_abort_leaves_repository_untouched(self) -> None: branch_module.ensure_pipeline_branch("feat/x") assert _switch_argv_calls(run_mock) == [] - def test_ensure_pipeline_branch_git_rejects_invalid_name_surfaces_stderr(self) -> None: + def test_ensure_pipeline_branch_git_rejects_invalid_name_surfaces_stderr( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """git owns name validity — its stderr is surfaced in the ClickException.""" + monkeypatch.chdir(tmp_path) run_mock = _git_run_dispatch( show_current=_GitResult(stdout="main\n"), show_ref=subprocess.CalledProcessError(1, "git"), @@ -427,6 +447,65 @@ def test_ensure_pipeline_branch_missing_git_binary_fails_cleanly(self) -> None: assert str(excinfo.value) == "git is required for -b/--branch: git binary not found" assert _switch_argv_calls(run_mock) == [] + def test_ensure_pipeline_branch_history_topic_conflict_no_tty_fails( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Oracle 3 through the orchestrator: the topic dir is checked by SLUG for the current year. + + The year is pinned via the mandated ``branch_module.datetime`` mock + target, so the composed ``f"{datetime.now().year:04d}"`` argument is + asserted against a directory created for that exact year. + """ + + class _FixedClock: + @staticmethod + def now() -> datetime: + return datetime(2031, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock + + (tmp_path / ".goga" / "history" / "2031" / "feat-x").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + run_mock = _git_run_dispatch( + show_current=_GitResult(stdout="main\n"), + show_ref=subprocess.CalledProcessError(1, "git"), + for_each_ref=_GitResult(stdout=""), + ) + with ( + mock.patch.object(branch_module.subprocess, "run", run_mock), + mock.patch.object(branch_module, "datetime", _FixedClock), + mock.patch.object(branch_module.sys.stdin, "isatty", return_value=False), + pytest.raises(click.ClickException) as excinfo, + ): + branch_module.ensure_pipeline_branch("feat/x") + message = str(excinfo.value) + assert "history topic '.goga/history/2031/feat-x' already exists" in message + assert "Pass another branch name via -b." in message + assert _switch_argv_calls(run_mock) == [] + + def test_ensure_pipeline_branch_ref_listing_failure_fails_cleanly( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A git infra failure of the oracles (e.g. outside a repository) is a clean error. + + ``git for-each-ref`` exiting non-zero (128 outside a repository) must + surface as a ClickException carrying git's stderr — never a raw + ``CalledProcessError`` traceback out of the CLI. + """ + monkeypatch.chdir(tmp_path) + run_mock = _git_run_dispatch( + show_current=_GitResult(stdout="main\n"), + show_ref=subprocess.CalledProcessError(1, "git"), + for_each_ref=subprocess.CalledProcessError(128, "git", stderr="fatal: not a git repository"), + ) + with ( + mock.patch.object(branch_module.subprocess, "run", run_mock), + pytest.raises(click.ClickException) as excinfo, + ): + branch_module.ensure_pipeline_branch("feat/x") + message = str(excinfo.value) + assert "git failed to check branch occupancy" in message + assert "fatal: not a git repository" in message + assert _switch_argv_calls(run_mock) == [] + def test_ensure_pipeline_branch_reask_validates_new_name_fully(self) -> None: """The re-asked name re-runs slug + already-on-branch + occupancy — fully.""" run_mock = _git_run_dispatch( From 6f4d7322a704ae1faf2dc73d32d36a1805d3829d Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 00:24:42 +0000 Subject: [PATCH 032/229] fix: address code review findings --- .goga/workflows/development.yml | 1 + goga/commands/install/hook.py | 12 ++++++++-- tests/commands/install/test_hook.py | 34 +++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/.goga/workflows/development.yml b/.goga/workflows/development.yml index 30634beb..efdc9efd 100644 --- a/.goga/workflows/development.yml +++ b/.goga/workflows/development.yml @@ -41,5 +41,6 @@ extend: script: | branch=$(git branch --show-current) topic=$(python3 -c "from goga.commands.pipeline.branch import normalize_topic_slug; import sys; print(normalize_topic_slug(sys.argv[1]))" "$branch") + test -n "$topic" || { echo "branch name '$branch' normalizes to an empty topic slug" >&2; exit 1; } python3 -m goga.build ".goga/history/$(date +%Y)/$topic/plan.md" after_script: rm -rf .ralphex diff --git a/goga/commands/install/hook.py b/goga/commands/install/hook.py index 8279871a..2938935f 100644 --- a/goga/commands/install/hook.py +++ b/goga/commands/install/hook.py @@ -54,7 +54,10 @@ def call_install_hook(tool: str, user: str) -> bool: """Run the post-install hook of one installed tool when it provides one. Imports the tool facade ``goga_tool_<tool>`` and calls its optional - ``install`` callable. The injection follows the signature projection: + ``install`` callable. The identifier is normalized to a module name first — + hyphens and dots become underscores, so the canonical hyphenated tool name + (``hello-world``) imports ``goga_tool_hello_world``, the spelling pip lays + out on disk. The injection follows the signature projection: only a declared keyword-capable ``user`` parameter receives the value — a positional-only ``user`` or a bare ``**kwargs`` is not an opt-in and the hook is called without arguments. No argument other than ``user`` is @@ -75,7 +78,12 @@ def call_install_hook(tool: str, user: str) -> bool: differs), a failure rather than a skip. Exception: whatever the hook itself raises, propagated unchanged. """ - module_name = f"goga_tool_{tool}" + # The pip-style tool identifier is hyphenated (``hello-world``) while the + # installed top-level module is underscored (``goga_tool_hello_world``) — + # the same duality `goga connect` normalizes for pipeline namespacing. + # Without this the import of every multi-word tool's facade misses and the + # hook degrades to the quiet-skip path. + module_name = f"goga_tool_{tool.replace('-', '_').replace('.', '_')}" try: module = importlib.import_module(module_name) except ModuleNotFoundError as exc: diff --git a/tests/commands/install/test_hook.py b/tests/commands/install/test_hook.py index d7c0073b..4c7bee88 100644 --- a/tests/commands/install/test_hook.py +++ b/tests/commands/install/test_hook.py @@ -119,6 +119,40 @@ def _fake_install(user: str | None = None) -> None: assert calls == [{"user": "alice"}] mock_import.assert_called_once_with("goga_tool_fake") + def test_call_install_hook_normalizes_hyphenated_tool_to_module_name(self) -> None: + """The canonical hyphenated tool name imports the underscored facade module. + + pip lays the ``goga-tool-hello-world`` distribution out as the top-level + module ``goga_tool_hello_world``; a literal ``goga_tool_hello-world`` + import can never resolve and the hook would degrade to the quiet skip. + """ + calls: list[dict[str, str | None]] = [] + + def _fake_install(user: str | None = None) -> None: + calls.append({"user": user}) + + fake_module = types.SimpleNamespace(install=_fake_install) + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module) as mock_import: + invoked = hook_module.call_install_hook("hello-world", "alice") + assert invoked is True + assert calls == [{"user": "alice"}] + mock_import.assert_called_once_with("goga_tool_hello_world") + + def test_call_install_hook_hyphenated_tool_runs_real_module_hook(self) -> None: + """End-to-end against a real importable facade: the hyphenated spelling runs the hook.""" + calls: list[dict[str, str | None]] = [] + + def _fake_install(user: str | None = None) -> None: + calls.append({"user": user}) + + # importlib.import_module consults sys.modules first, so a registered + # throwaway facade is found without a file on sys.path. + fake_facade = types.SimpleNamespace(install=_fake_install) + with mock.patch.dict(sys.modules, {"goga_tool_hello_world": fake_facade}): + invoked = hook_module.call_install_hook("hello-world", "alice") + assert invoked is True + assert calls == [{"user": "alice"}] + def test_call_install_hook_keyword_only_user_injected(self) -> None: """``def install(*, user)`` — keyword-only IS keyword-capable (the value is injected).""" calls: list[dict[str, str | None]] = [] From 70f64e36eb796ab955079ac249dbe89ec3228a16 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 00:39:15 +0000 Subject: [PATCH 033/229] fix: address code review findings --- goga/commands/install/.usages/install.md | 5 ++-- goga/commands/install/hook.py | 17 +++++++----- tests/commands/install/test_hook.py | 33 ++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/goga/commands/install/.usages/install.md b/goga/commands/install/.usages/install.md index 4af4087a..a453ffc5 100644 --- a/goga/commands/install/.usages/install.md +++ b/goga/commands/install/.usages/install.md @@ -241,8 +241,9 @@ clear error. - Do not declare `tools:` values with YAML-null (`viewer:`) — write `viewer: latest`. - Do not bypass the command and call `pip` with `sudo` directly without - `--preserve-env=HOME`: the hook's initiating-user resolution and the - post-install activation depend on the caller's `$HOME`. + `--preserve-env=HOME`: the post-install activation depends on the caller's + `$HOME` (the hook's initiating-user resolution reads `SUDO_USER` / the OS + user, never `$HOME`). - Do not expect `--version` to apply in bulk mode — it is ignored when `name` is absent. - Do not expect a hook for a local install without the suffix — goga does diff --git a/goga/commands/install/hook.py b/goga/commands/install/hook.py index 2938935f..4434d525 100644 --- a/goga/commands/install/hook.py +++ b/goga/commands/install/hook.py @@ -55,9 +55,11 @@ def call_install_hook(tool: str, user: str) -> bool: Imports the tool facade ``goga_tool_<tool>`` and calls its optional ``install`` callable. The identifier is normalized to a module name first — - hyphens and dots become underscores, so the canonical hyphenated tool name - (``hello-world``) imports ``goga_tool_hello_world``, the spelling pip lays - out on disk. The injection follows the signature projection: + hyphens and dots become underscores and the result is lowercased, so the + canonical hyphenated tool name (``hello-world``) imports + ``goga_tool_hello_world``, the spelling pip lays out on disk (pip resolves + distribution names case-insensitively, the import lookup does not). The + injection follows the signature projection: only a declared keyword-capable ``user`` parameter receives the value — a positional-only ``user`` or a bare ``**kwargs`` is not an opt-in and the hook is called without arguments. No argument other than ``user`` is @@ -81,9 +83,12 @@ def call_install_hook(tool: str, user: str) -> bool: # The pip-style tool identifier is hyphenated (``hello-world``) while the # installed top-level module is underscored (``goga_tool_hello_world``) — # the same duality `goga connect` normalizes for pipeline namespacing. - # Without this the import of every multi-word tool's facade misses and the - # hook degrades to the quiet-skip path. - module_name = f"goga_tool_{tool.replace('-', '_').replace('.', '_')}" + # pip resolves distribution names case-insensitively but the import lookup + # is case-sensitive, so the identifier is lowercased too (the canonical + # tool spelling is lowercase-hyphenated). Without this a case-variant or + # multi-word identifier makes the facade import miss and the hook degrade + # to the quiet-skip path. + module_name = f"goga_tool_{tool.replace('-', '_').replace('.', '_').lower()}" try: module = importlib.import_module(module_name) except ModuleNotFoundError as exc: diff --git a/tests/commands/install/test_hook.py b/tests/commands/install/test_hook.py index 4c7bee88..778db81d 100644 --- a/tests/commands/install/test_hook.py +++ b/tests/commands/install/test_hook.py @@ -153,6 +153,39 @@ def _fake_install(user: str | None = None) -> None: assert invoked is True assert calls == [{"user": "alice"}] + def test_call_install_hook_normalizes_case_variant_tool_to_module_name(self) -> None: + """A case-variant tool name imports the lowercase facade module. + + pip resolves distribution names case-insensitively (``goga install + Hello-World`` installs ``goga-tool-hello-world``), so the install + succeeds — the module lookup must reach the same lowercase module or + the hook would degrade to the quiet-skip path. + """ + calls: list[dict[str, str | None]] = [] + + def _fake_install(user: str | None = None) -> None: + calls.append({"user": user}) + + fake_module = types.SimpleNamespace(install=_fake_install) + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module) as mock_import: + invoked = hook_module.call_install_hook("Hello-World", "alice") + assert invoked is True + assert calls == [{"user": "alice"}] + mock_import.assert_called_once_with("goga_tool_hello_world") + + def test_call_install_hook_case_variant_tool_runs_real_module_hook(self) -> None: + """End-to-end against a real importable facade: the case-variant spelling runs the hook.""" + calls: list[dict[str, str | None]] = [] + + def _fake_install(user: str | None = None) -> None: + calls.append({"user": user}) + + fake_facade = types.SimpleNamespace(install=_fake_install) + with mock.patch.dict(sys.modules, {"goga_tool_hello_world": fake_facade}): + invoked = hook_module.call_install_hook("Hello-World", "alice") + assert invoked is True + assert calls == [{"user": "alice"}] + def test_call_install_hook_keyword_only_user_injected(self) -> None: """``def install(*, user)`` — keyword-only IS keyword-capable (the value is injected).""" calls: list[dict[str, str | None]] = [] From 08db50d51ecd821dee4f2eaae4571b5b128fee61 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 01:40:10 +0000 Subject: [PATCH 034/229] fix: align manifests and usages with implementation, cover empty-slug re-ask Acceptance audit of release-1-3-0 (pipeline -b/--branch + install hooks) found the code correct but the contract docs lagging in four places, and one uncovered branch. - install CODEMANIFEST: call_install_hook now documents the facade-module identifier normalization (hyphen/dot -> underscore, lowercase) and the skip-vs-failure split of ModuleNotFoundError; run_install_hooks documents the empty-list early return and the RuntimeError wrapping that carries the tool name - pipeline CODEMANIFEST: check_branch_occupancy oracle 3 states the is_dir() semantics (a stray file named <slug> does not occupy a topic) - install.md: Python example imports from the cell facade instead of the deep module path; the hook section states the tool-name -> module-name mapping - pipeline-command.md: occupancy wording aligned with the directory check - test_branch.py: cover the empty-slug re-ask on a tty (reason on stderr, one prompt, loop restart, switch argv) - branch.py 98% -> 99% goga lint: 58 cells, 0 errors; pytest: 4378 passed; ruff clean. --- goga/commands/install/.usages/install.md | 8 +++-- goga/commands/install/CODEMANIFEST | 25 ++++++++++++---- .../pipeline/.usages/pipeline-command.md | 3 +- goga/commands/pipeline/CODEMANIFEST | 5 ++-- tests/commands/pipeline/test_branch.py | 30 +++++++++++++++++++ 5 files changed, 60 insertions(+), 11 deletions(-) diff --git a/goga/commands/install/.usages/install.md b/goga/commands/install/.usages/install.md index a453ffc5..05b28733 100644 --- a/goga/commands/install/.usages/install.md +++ b/goga/commands/install/.usages/install.md @@ -138,7 +138,11 @@ Neither pip, nor hooks, nor activation is invoked. After a successful pip install (single, local with a `:<tool-name>` suffix, and bulk), the command imports each installed tool's facade module -`goga_tool_<tool>` and calls its `install` callable when one exists: +`goga_tool_<name>` and calls its `install` callable when one exists. The +tool identifier is normalized into the module name first — every hyphen and +dot becomes an underscore and the result is lowercased (`mytool` → +`goga_tool_mytool`, `my-tool` → `goga_tool_my_tool`), matching the +underscored top-level package pip lays out on disk: - No facade module or no callable `install` → quiet skip (the hook is optional; existing tools without a hook install exactly as before). @@ -197,7 +201,7 @@ clear error. ## Python API - from goga.commands.install.install import install + from goga.commands.install import install # Click commands are normally invoked via the CLI. For testing or # programmatic invocation, use click.testing.CliRunner to drive the diff --git a/goga/commands/install/CODEMANIFEST b/goga/commands/install/CODEMANIFEST index 3b47495b..006ea0e6 100644 --- a/goga/commands/install/CODEMANIFEST +++ b/goga/commands/install/CODEMANIFEST @@ -297,11 +297,17 @@ Annotations: | imports, and structured logging. Algorithm: - 1. Resolve the initiating user once via `resolve_initiating_user` - 2. For each name in `tools` in order: run `call_install_hook` with the + 1. An empty `tools` list -> return at once (no user resolution, no log + lines) + 2. Resolve the initiating user once via `resolve_initiating_user` + 3. For each name in `tools` in order: run `call_install_hook` with the name and the user; log the outcome — hook invoked or skipped - 3. A hook exception propagates: the loop stops and the hooks of the - remaining tools are not run + 4. A hook exception is wrapped in a RuntimeError carrying the tool name + ("install hook for tool '<name>' failed: <message>") with the original + exception as its cause; the loop stops and the hooks of the remaining + tools are not run. A failure of the user-resolution step itself + propagates as-is — it is not a hook failure and carries no tool + context Requirements: - One initiating user for the whole call — every hook of the run sees @@ -327,8 +333,15 @@ Annotations: | imports. Algorithm: - 1. Import the facade module goga_tool_<tool>; a missing module -> False - (a quiet skip — a package without the facade convention is normal) + 1. Normalize `tool` into the facade module identifier goga_tool_<name>: + every hyphen and dot becomes an underscore and the result is + lowercased (the pip identifier is hyphenated while the on-disk + top-level module is underscored, and the import lookup is + case-sensitive). Import the module; when the facade itself is missing + (ModuleNotFoundError naming exactly that module) -> False (a quiet + skip — a package without the facade convention is normal). A + ModuleNotFoundError naming a DIFFERENT module means the facade exists + but its own imports are broken -> propagate (a failure, not a skip) 2. A callable install on the facade is absent -> False (a quiet skip — the hook is optional for every tool) 3. The hook signature declares a keyword-capable parameter named user diff --git a/goga/commands/pipeline/.usages/pipeline-command.md b/goga/commands/pipeline/.usages/pipeline-command.md index fde0b512..cf63c720 100644 --- a/goga/commands/pipeline/.usages/pipeline-command.md +++ b/goga/commands/pipeline/.usages/pipeline-command.md @@ -56,7 +56,8 @@ The entered name plays two roles: Occupancy = a local branch with the entered name, OR a remote-tracking branch with the entered name, OR an existing `.goga/history/<YYYY>/<slug>/` -folder for the current year. +topic directory for the current year (a stray file named `<slug>` does not +occupy a topic). - Interactive terminal: the reason is printed and a new name is prompted until the name is free (Ctrl-C aborts, nothing is created); a fully diff --git a/goga/commands/pipeline/CODEMANIFEST b/goga/commands/pipeline/CODEMANIFEST index 5051f0b3..1d1e75fe 100644 --- a/goga/commands/pipeline/CODEMANIFEST +++ b/goga/commands/pipeline/CODEMANIFEST @@ -345,8 +345,9 @@ Annotations: | 1. A local branch ref for `branch_name` exists -> return the reason 2. A remote-tracking ref for `branch_name` exists -> return the reason (local remote-tracking refs only — no network call) - 3. The history topic folder .goga/history/<history_year>/<slug> exists - -> return the reason + 3. The history topic directory .goga/history/<history_year>/<slug> + exists as a directory -> return the reason (a stray file named + <slug> does not occupy a topic) 4. All three oracles are free -> None Requirements: diff --git a/tests/commands/pipeline/test_branch.py b/tests/commands/pipeline/test_branch.py index 25a262c4..6601a756 100644 --- a/tests/commands/pipeline/test_branch.py +++ b/tests/commands/pipeline/test_branch.py @@ -519,3 +519,33 @@ def test_ensure_pipeline_branch_reask_validates_new_name_fully(self) -> None: ): assert branch_module.ensure_pipeline_branch("feat/one") == "main" assert _switch_argv_calls(run_mock) == [] + + def test_ensure_pipeline_branch_empty_slug_tty_reasks_new_name( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """An empty slug on a terminal re-asks; the loop restarts with the new name. + + The empty-slug branch shares the re-ask machinery with the conflict + branch, but it fires BEFORE the already-on-branch and occupancy steps — + so the first iteration must not reach a single oracle. The re-asked + name then runs the full procedure: occupancy is free, the branch is + created exactly as entered. + """ + monkeypatch.chdir(tmp_path) + run_mock = _git_run_dispatch( + show_current=_GitResult(stdout="main\n"), + show_ref=subprocess.CalledProcessError(1, "git"), + for_each_ref=_GitResult(stdout=""), + switch=_GitResult(returncode=0), + ) + with ( + mock.patch.object(branch_module.subprocess, "run", run_mock), + mock.patch.object(branch_module.sys.stdin, "isatty", return_value=True), + mock.patch.object(branch_module.click, "prompt", return_value="feat/two") as prompt_mock, + ): + assert branch_module.ensure_pipeline_branch("Релиз/Один") == "feat/two" + assert prompt_mock.call_count == 1 + reason = capsys.readouterr().err + assert "normalizes to an empty topic slug" in reason + assert "Релиз/Один" in reason + assert run_mock.call_args.args[0] == ["git", "switch", "-c", "feat/two"] From b75ebf36e3e35faf12757d32780a3e0c07b2f108 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 13:19:47 +0000 Subject: [PATCH 035/229] feat: thread base_ref through build review contracts Spec layer of add-ref-for-review: ralphex auto-detects the default branch for review diffs via the remote HEAD, and when detection fails the review agents lose the diff scope. The contracts now thread an explicit review diff base from config/CLI down to the ralphex command. - config/project: ReviewExecutorConfig gains base_ref (str | None, empty normalized to None) and patience (int | None, structural type check only); the legacy build.review_patience key is no longer parsed (moved to build.review_executor.patience - accepted breaking change, never released) - build: ReviewOptions carries the resolved base_ref/patience; option resolution splits into universal options (CLI > BuildConfig > omit, every pass) and review-scoped options (CLI > build.review_executor.* > omit via resolve_review_options) - base_ref/review_patience join only the review-carrying passes (full-mode single pass and two-pass review pass); skip runs and tasks-only passes carry universal options only - commands/build: host CLI --base-ref forwarded verbatim by the value-option pattern; the host does not resolve precedence - an unset flag leaves the decision to build.review_executor.base_ref in-container - ralphex: run_ralphex options map base_ref -> --base-ref, omitted when None or empty, forwarded verbatim (ralphex resolves the ref) - usages: cooks/ralphex, build-usage, build, project-configuration, run-ralphex document the new flag, config keys, and pass composition Implementation and tests follow per the ralphex plan. goga lint: 58 cells, 0 errors. --- .goga/usages/cooks/ralphex.md | 12 +++ goga/build/.usages/build-usage.md | 25 ++++++- goga/build/CODEMANIFEST | 78 +++++++++++++++++--- goga/commands/build/.usages/build.md | 10 ++- goga/commands/build/CODEMANIFEST | 12 ++- goga/config/.usages/project-configuration.md | 22 ++++-- goga/config/project/CODEMANIFEST | 50 ++++++++++--- goga/ralphex/.usages/run-ralphex.md | 13 ++++ goga/ralphex/CODEMANIFEST | 1 + 9 files changed, 191 insertions(+), 32 deletions(-) diff --git a/.goga/usages/cooks/ralphex.md b/.goga/usages/cooks/ralphex.md index a3f6e157..ff67ffec 100644 --- a/.goga/usages/cooks/ralphex.md +++ b/.goga/usages/cooks/ralphex.md @@ -106,6 +106,11 @@ ralphex will find the first incomplete task (`- [ ]`) and continue from there. | `-p, --port` | Web dashboard port (with `--serve`) | 8080 | | `-d, --debug` | Debug output | false | | `--no-color` | Disable colored output | false | +| `-b, --base-ref` | Override default branch for review diffs | — | +| `--review-patience` | Stop external review after N unchanged rounds | 0 (disabled) | +| `--session-timeout` | Session timeout (Go duration) | disabled | +| `--idle-timeout` | Idle timeout (Go duration) | disabled | +| `--wait` | Rate-limit retry wait (Go duration) | — | Note: `--review` ignores `--worktree` — the review runs against the current branch/repository state, not the worktree branch. @@ -113,6 +118,13 @@ branch/repository state, not the worktree branch. Note: `--tasks-only` skips every review phase — the internal review agents and the external codex review alike; `codex_enabled` has no effect in that mode. +Note: `--base-ref` overrides ralphex's default-branch detection for review +diffs; the value is a branch name or a commit hash. ralphex auto-detects the +default branch via the remote HEAD (fallbacks: main/master/trunk/develop) — +when detection fails, review agents lose the diff scope. goga threads +`--base-ref` from its `build.review_executor.base_ref` config key / CLI +`--base-ref` onto review-carrying passes only. + ## Configuration ralphex uses `~/.config/ralphex/` (global) or `.ralphex/` in the project root (local). diff --git a/goga/build/.usages/build-usage.md b/goga/build/.usages/build-usage.md index 5bcb553d..6ea8da99 100644 --- a/goga/build/.usages/build-usage.md +++ b/goga/build/.usages/build-usage.md @@ -27,6 +27,7 @@ exit_code = build( "worktree": True, "skip_finalize": False, "skip_manifest_check": False, + "base_ref": "origin/1.2.x", # review diff base (review-scoped) }, ) ``` @@ -36,7 +37,8 @@ exit_code = build( - `plan` — path to the plan file (markdown) - `config` — ProjectConfig object loaded via `load_project_config` - `cli_options` — options dictionary (dry_run, worktree, skip_finalize, skip_manifest_check, - skip_review, session_timeout, idle_timeout, wait, max_iterations, review_patience) + skip_review, session_timeout, idle_timeout, wait, max_iterations, review_patience, + base_ref) ## Review-phase control @@ -59,6 +61,25 @@ runs. With skip: true the review env is ignored entirely. build.review_executor.roles filters {{agent:X}} lines in both review prompts; empty list or absent = full default set; files of all 5 agents are always present in .ralphex/agents/. +### Review-scoped options + +`base_ref` (review diff base — branch name or commit hash) and `patience` +(external-review stop threshold) are review-scoped: they resolve with +precedence CLI > `build.review_executor.*` > omit and join the ralphex +options of review-carrying passes only — the full-mode single pass and the +two-pass review pass. A skipped run and the tasks-only pass never carry +them. + +cli_options={'base_ref': 'origin/1.2.x'} or .goga/config.yml +build.review_executor.base_ref: origin/1.2.x → ralphex receives +--base-ref origin/1.2.x on the review-carrying pass. The same precedence +holds for the review_patience cli_options key / +build.review_executor.patience → --review-patience. + +When neither source sets them, the keys stay absent and the assembled +ralphex command is unchanged. The legacy build.review_patience config key +is not parsed — declare build.review_executor.patience instead. + ## Review-pass environment build.review_executor.env (mapping of strings) overrides same-named variables @@ -103,4 +124,4 @@ prepares the mount before launch and wipes it only on `goga build --clean`. The ## Docker entry point -`main()` calls `ensure_in_docker()` first, then argparse handles parsing and calls `build()`. +`main()` calls `ensure_in_docker()` first, then argparse handles parsing (including `--base-ref`) and calls `build()`. diff --git a/goga/build/CODEMANIFEST b/goga/build/CODEMANIFEST index b22803d6..c7527afa 100644 --- a/goga/build/CODEMANIFEST +++ b/goga/build/CODEMANIFEST @@ -41,6 +41,14 @@ Annotations: | goga/ralphex (per the `run-ralphex` practice) — ralphex is launched through `run_ralphex`, never directly from this cell. + Option resolution follows two zones: universal options (worktree, + skip_finalize, session_timeout, idle_timeout, wait, max_iterations) + resolve with precedence CLI > `BuildConfig` > omit and apply to every + ralphex pass; review-scoped options (base_ref, patience) resolve with + precedence CLI > `ReviewExecutorConfig` > omit inside `resolve_review_options` + and apply only to review-carrying passes (the full-mode single pass and the + two-pass review pass). + This cell also owns the review-phase orchestration of the build: tri-state skip resolution, review-env handling (two-pass induction by a non-empty review env, the env-requires-agent gate, the per-pass env layer of the @@ -72,7 +80,8 @@ Annotations: | `config`: loaded project configuration object `cli_options`: dictionary of CLI options (dry_run, worktree, skip_finalize, skip_manifest_check, skip_review, session_timeout, - idle_timeout, wait, max_iterations, review_patience) + idle_timeout, wait, max_iterations, review_patience, + base_ref) `exit_code`: process exit code (0 = success, 1 = failure) Algorithm: @@ -83,18 +92,29 @@ Annotations: | agent field of `TaskExecutorConfig`, per the `resolve-wrapper-path` practice (absolute in-container path /home/goga/bin/<name>-as-claude.sh per `agent-wrappers`) 2. Resolve the review options (skip, review agent, roles, review env, - two-pass mode) via `resolve_review_options` + two-pass mode, review-scoped options base_ref and patience) via + `resolve_review_options` 3. Validate the review configuration via `validate_review_config` when the review phase will run 4. Write .ralphex/config for the first pass via `write_ralphex_config` 5. Fully rewrite .ralphex/prompts/ and .ralphex/agents/ from the vendored defaults via `sync_ralphex_defaults`, applying the declared roles to the review prompts - 6. Resolve the ralphex options with precedence CLI options > `BuildConfig` > omit, - producing the resolved options for `run_ralphex` + 6. Resolve the universal ralphex options with precedence CLI options > + `BuildConfig` > omit — worktree, skip_finalize, session_timeout, + idle_timeout, wait, max_iterations — producing the universal options for + `run_ralphex`; review-scoped options are NOT resolved here (their owner + is `resolve_review_options` of step 2) 7. Launch each planned pass via `run_build_pass` (which delegates the launch to `run_ralphex`), forwarding the dry_run flag of `cli_options` so a dry - run prints the commands of every planned pass instead of launching: + run prints the commands of every planned pass instead of launching; + compose the options of each pass: + - universal options join the options of every pass + - review-scoped options — base_ref (options key base_ref) and patience + (options key review_patience) of `ReviewOptions` — join the options of + review-carrying passes ONLY: the full-mode single pass and the two-pass + review pass; a skip run and the tasks-only pass carry universal + options only - skip run: one pass with tasks_only — the review env is ignored entirely; other phases unchanged - two-pass run: pass 1 with tasks_only and the task wrapper, no env @@ -130,12 +150,19 @@ Annotations: | in place - On dry-run print the commands of every planned pass without the env layer contents — the review env never reaches logs or dry-run output + - Review-scoped options never appear in the options of a skip run or the + tasks-only pass + - When neither the CLI nor the config sets base_ref/patience, the keys stay + absent from the options and the assembled ralphex command is + byte-identical to the current behavior Constraints: - Wrappers live in the image at /home/goga/bin/ and are referenced by absolute path - Agent resolution is uniform — do not branch by agent name - Do not assemble the ralphex command or invoke ralphex directly — delegate to `run_ralphex` - The .ralphex/ directory lifecycle is owned by the host launcher (goga/commands/build) + - Do not resolve review-scoped option precedence in the universal resolution + step — the owner is `resolve_review_options` "main() -> exit_code:int": location: __main__.py @@ -150,10 +177,11 @@ Annotations: | `ensure-in-docker` practice) 1. Parse CLI arguments via argparse (plan + options); the argparse surface carries the --skip-review / --no-skip-review pair resolving to - skip_review: bool | None (None when neither flag is given) + skip_review: bool | None (None when neither flag is given) and the + --base-ref option (type str, default None — the review diff base) 2. Load project configuration via `load_project_config` 3. Build cli_options from the parsed argparse results; cli_options carries - the skip_review key + the skip_review key and the base_ref key 4. Invoke `build`(plan, `ProjectConfig`, cli_options) 5. Return the resulting `exit_code` @@ -172,7 +200,10 @@ Annotations: | value and the project configuration. `config`: build configuration (`BuildConfig`) with the optional review_executor sub-configuration - `cli_options`: CLI options dictionary; the skip_review key is bool | None (None = flag not given) + `cli_options`: CLI options dictionary; the keys read here are skip_review + (bool | None — None = flag not given), base_ref (str | None — an empty + or whitespace-only value counts as unset), and review_patience + (int | None) `review`: resolved review options (`ReviewOptions`) Algorithm: @@ -186,17 +217,30 @@ Annotations: | 5. Take the review env verbatim into review_env (an empty dict stays empty; a review env equal to task_executor.env is still non-empty and keeps two_pass true) + 6. Resolve base_ref: take the cli_options base_ref when not None, otherwise + the base_ref field of `ReviewExecutorConfig`, otherwise None; an empty + or whitespace-only value from either source resolves as unset (None) + 7. Resolve patience: take the cli_options review_patience when not None, + otherwise the patience field of `ReviewExecutorConfig`, otherwise None Requirements: - Precedence CLI > ProjectConfig > omit - An empty roles list reaches the consumers as an empty list - A non-empty review env induces two_pass regardless of dictionary equality with task_executor.env + - Precedence for base_ref and patience: CLI > build.review_executor.* > omit + - An absent review_executor section leaves base_ref and patience None (like + the other review fields) + - The code docstring of `resolve_review_options` lists the same three + cli_options keys — the current "only skip_review is read" wording is + updated with the two review-scoped keys Constraints: - Pure — no side effects, no validation of values (separate routine) + - The resolved base_ref is never checked for resolvability or format — + diagnostics of the review diff base belong to ralphex -"ReviewOptions(skip: bool, review_agent: str | None, roles: list[str] | None, two_pass: bool, review_env: dict[str, str])": +"ReviewOptions(skip: bool, review_agent: str | None, roles: list[str] | None, two_pass: bool, review_env: dict[str, str], base_ref: str | None, patience: int | None)": location: review_options.py annotations: | Resolved review-phase execution plan of a single build. @@ -209,6 +253,10 @@ Annotations: | `review_env`: review-pass environment layer, verbatim from `ReviewExecutorConfig` (an empty dict when unset); forwarded as the env layer of the review pass by the orchestrator + `base_ref`: resolved review diff base (branch name or commit hash), None + when unset; forwarded to review-carrying passes only + `patience`: resolved external-review stop threshold, None when unset; + forwarded to review-carrying passes only Requirements: - Immutable frozen dataclass (frozen=True, kw_only=True), per `conventions` @@ -227,6 +275,18 @@ Annotations: | Review-pass environment layer, verbatim; an empty dict when the configuration declares no env. The layer overlays the container environment on the review-pass subprocess only. + "base_ref -> str | None": | + Resolved review diff base — branch name or commit hash, verbatim. + None when neither the CLI option nor the build.review_executor.base_ref + config field declares one (an empty or whitespace-only value counts as + unset). Consumed by the pass composition: forwarded to `run_ralphex` as + the base_ref options key on review-carrying passes only. + "patience -> int | None": | + Resolved external-review stop threshold — stop the external review + after N consecutive unchanged rounds. None when neither the CLI option + nor the build.review_executor.patience config field declares one. + Forwarded as the review_patience options key on review-carrying passes + only. "validate_review_config(config: BuildConfig, review: ReviewOptions) -> none: None": location: review_config.py diff --git a/goga/commands/build/.usages/build.md b/goga/commands/build/.usages/build.md index 088fe421..6849f774 100644 --- a/goga/commands/build/.usages/build.md +++ b/goga/commands/build/.usages/build.md @@ -9,7 +9,7 @@ CLI wrapper for the build command. Parses click options, loads configuration, an ``` goga build <plan> [--dry-run] [--worktree] [--skip-finalize] [--skip-manifest-check] [--session-timeout T] [--idle-timeout T] [--wait T] - [--max-iterations N] [--review-patience N] + [--max-iterations N] [--review-patience N] [--base-ref REF] [--skip-review | --no-skip-review] [-e KEY=VALUE ...] [--proxy URL] [--add-host HOST:IP ...] [--clean] [--update | -u] @@ -34,6 +34,7 @@ goga build <plan> [--dry-run] [--worktree] [--skip-finalize] [--skip-manifest-ch | `--wait` | str | from config | Wait on rate limit | | `--max-iterations` | int | from config | Maximum iterations | | `--review-patience` | int | from config | Review stop threshold | +| `--base-ref` | str | from config | Review diff base (branch name or commit hash). Overrides `build.review_executor.base_ref` in `.goga/config.yml`; forwarded to the container only when set. Reaches ralphex as `--base-ref` on the review-carrying pass only | | `--skip-review` / `--no-skip-review` | bool pair | tri-state | Skip the review phase (`--skip-review`) or force the full cycle (`--no-skip-review`). Overrides `build.review_executor.skip` in `.goga/config.yml`; when neither flag is given, the config decides | | `-e` / `--env` | str (multiple) | — | Pass environment variables to the container (KEY=VALUE) | | `--proxy` | str | from config | HTTP/HTTPS proxy URL; overrides `build.proxy` in `.goga/config.yml`. When set, adds HTTP_PROXY/HTTPS_PROXY/NO_PROXY to the container env-file | @@ -64,6 +65,9 @@ goga build docs/plans/my-plan.md # Route container traffic through a corporate proxy and add a local host entry goga build docs/plans/my-plan.md --proxy http://corp:3128 --add-host foo.local:127.0.0.1 +# Scope the review diff to a release branch base +goga build docs/plans/my-plan.md --base-ref origin/1.2.x + # Wipe ralphex state before launch (start fresh) goga build docs/plans/my-plan.md --clean @@ -99,6 +103,10 @@ A differing build.review_executor.agent OR a non-empty build.review_executor.env (the review pass cannot follow the worktree branch). The guard is config-level and skip-independent — the host does not resolve the tri-state --skip-review. +`--base-ref` follows the same forwarding discipline: the host does not +resolve it against config — an unset flag leaves the decision to +`build.review_executor.base_ref` in-container. + ## Proxy and hosts `--proxy URL` (and `build.proxy` in config) drive three env-file entries when set: diff --git a/goga/commands/build/CODEMANIFEST b/goga/commands/build/CODEMANIFEST index 3d23100a..a2f65b25 100644 --- a/goga/commands/build/CODEMANIFEST +++ b/goga/commands/build/CODEMANIFEST @@ -138,6 +138,10 @@ Annotations: | - --dry-run, --worktree, --skip-finalize, --skip-manifest-check - --session-timeout, --idle-timeout, --wait - --max-iterations, --review-patience + - --base-ref REF (str) — review diff base (branch name or commit hash); + overrides build.review_executor.base_ref; forwarded to the container + and applied to the review-carrying pass only (per the `build-usage` + practice) - --skip-review / --no-skip-review (bool pair) — skip the review phase; tri-state, overrides build.review_executor.skip - -e / --env KEY=VALUE (multiple) — pass environment variables to the container @@ -191,7 +195,10 @@ Annotations: | in-container orchestrator). 3. Collect cli_flags from click parameters; forward the review pair by the --worktree pattern: skip_review True → --skip-review; False → - --no-skip-review; None → neither flag (the tri-state survives to the container) + --no-skip-review; None → neither flag (the tri-state survives to the + container); forward --base-ref by the existing value-option pattern — + the flag with its value joins cli_flags only when the option is set; + unset (None) adds no flag, leaving the decision to the container config 4. Resolve the proxy: take `proxy` when not None, otherwise fall back to config.build.proxy 5. Resolve hosts: merge config.build.hosts with parsed `add_host` entries; @@ -326,6 +333,9 @@ Annotations: | - The env-induced two-pass × worktree conflict is the same host-side guard as the differing-agent one — it fires before docker run, before any filesystem or container work, regardless of --skip-review + - The host does NOT resolve --base-ref precedence against config — + forwarding only; the unset case resolves in-container (CLI > + build.review_executor.base_ref > omit) Constraints: - Do not refresh an EXISTING image by default — refresh only when `update` diff --git a/goga/config/.usages/project-configuration.md b/goga/config/.usages/project-configuration.md index 2c9ee194..ab9d963e 100644 --- a/goga/config/.usages/project-configuration.md +++ b/goga/config/.usages/project-configuration.md @@ -46,10 +46,11 @@ config = load_project_config() - A present-but-non-mapping `pipeline` or `build` value (e.g. `pipeline: 5`, `pipeline:` null, `build: true`) raises `ValueError`, not `AttributeError` - Raises `yaml.YAMLError` on invalid YAML syntax - Optional `build.review_executor` follows structural-only validation: field - types, a list-of-strings check for `roles`, and a strings-mapping check for - `env`; an empty `roles` list and an empty `env` mapping pass through verbatim - — the empty-to-full-set (roles) and env-requires-agent (env) semantics belong - to the consuming command + types, a list-of-strings check for `roles`, a strings-mapping check for + `env`, and scalar type checks for `base_ref` (string) and `patience` + (integer); an empty `roles` list and an empty `env` mapping pass through + verbatim — the empty-to-full-set (roles) and env-requires-agent (env) + semantics belong to the consuming command **Error handling**: @@ -131,7 +132,6 @@ build: idle_timeout: "1h" wait: "5m" max_iterations: 10 - review_patience: 3 prompts_dir: /custom/prompts agents_dir: /custom/agents codex_review: true @@ -143,6 +143,8 @@ build: - testing env: # mapping | absent — review-pass env layer ANTHROPIC_MODEL: reviewer-model + base_ref: origin/1.2.x # str | absent — review diff base (branch or hash) + patience: 3 # int | absent — stop external review after N unchanged rounds codemanifest: usages: usage_name: path/to/file.md @@ -214,7 +216,6 @@ afm) that consume these fields. | `build.idle_timeout` | str | None | Idle timeout (Go duration format) | | `build.wait` | str | None | Rate-limit retry wait (Go duration format) | | `build.max_iterations` | int | None | Maximum task iteration count | -| `build.review_patience` | int | None | Review convergence threshold | | `build.prompts_dir` | str | None | Custom prompt directory path | | `build.agents_dir` | str | None | Custom agent directory path | | `build.codex_review` | bool | None | Enable external codex review (mapped to ralphex `codex_enabled`) | @@ -223,6 +224,8 @@ afm) that consume these fields. | `build.review_executor.agent` | str | None | Review executor name (resolved by the consumer) | | `build.review_executor.roles` | list | None | Reviewer composition; empty list passes verbatim (full default set is consumer semantics) | | `build.review_executor.env` | mapping | `{}` | Review-pass env layer ({str: str}); empty when absent/YAML-null/`{}`; requires `agent` when non-empty (enforced by the consumer) | +| `build.review_executor.base_ref` | str | None | Review diff base — branch name or commit hash; overrides ralphex's default-branch detection for review diffs. Verbatim, no validation at the config layer | +| `build.review_executor.patience` | int | None | Stop the external review after N consecutive unchanged rounds (moved from `build.review_patience`, which is no longer parsed) | | `codemanifest` | mapping | None | CODEMANIFEST usage and annotation config | | `codemanifest.usages` | mapping | `{}` | Usage name-to-path mapping (`{str: str}`) | | `codemanifest.annotations` | str | None | Freeform annotations for the AI agent | @@ -283,7 +286,9 @@ config.build.review_executor # ReviewExecutorConfig | None config.build.review_executor.skip # bool | None — tri-state skip source config.build.review_executor.agent # str | None — review executor name config.build.review_executor.roles # list[str] | None — verbatim; [] means the full default set to the consumer -config.build.review_executor.env # dict — {str: str}, empty when absent +config.build.review_executor.env # dict — {str: str}, empty when absent +config.build.review_executor.base_ref # str | None — review diff base, verbatim +config.build.review_executor.patience # int | None — external-review stop threshold # CodemanifestConfig fields — None when the `codemanifest` section is absent config.codemanifest # CodemanifestConfig | None @@ -291,6 +296,9 @@ config.codemanifest.usages # dict — {str: str} config.codemanifest.annotations # str | None ``` +The legacy `build.review_patience` key is no longer parsed — declare review +patience as `build.review_executor.patience`. + ### `tools` accessor — no-validation contract `config.tools` exposes the raw mapping from `.goga/config.yml`. The loader diff --git a/goga/config/project/CODEMANIFEST b/goga/config/project/CODEMANIFEST index 4a201628..2997de48 100644 --- a/goga/config/project/CODEMANIFEST +++ b/goga/config/project/CODEMANIFEST @@ -32,10 +32,11 @@ Annotations: | to the owning consumer. The optional build.review_executor sub-section follows the same structural-only - stance: field types, a list-of-strings check for roles, and a strings-mapping - check for env; an empty roles list and an empty env mapping pass through - verbatim (the empty-to-full-set and the env-requires-agent meanings belong to - the consuming cell). + stance: field types, a list-of-strings check for roles, a strings-mapping + check for env, and scalar type checks for base_ref (string) and patience + (integer); an empty roles list and an empty env mapping pass through + verbatim (the empty-to-full-set and the env-requires-agent meanings belong + to the consuming cell). --- @@ -81,8 +82,12 @@ Annotations: | mapping → ValueError; a non-string key or value → ValueError (messages follow the build.task_executor.env pattern; YAML-null is a valid empty mapping — null-tolerance mirrors agent and roles of this step). - Construct a `ReviewExecutorConfig` from the resolved fields and pass it - into `BuildConfig`. + base_ref: absent/YAML-null/empty/whitespace → None; a non-string value → + ValueError (the agent pattern). + patience: absent/YAML-null → None; a non-int value (including a YAML + boolean) → ValueError — a structural type check. + Construct a `ReviewExecutorConfig` from the resolved fields (including + base_ref and patience) and pass it into `BuildConfig`. 8. Extract the optional codemanifest block; when present, construct a `CodemanifestConfig` from its usages and annotations fields, otherwise None 9. Extract the optional lint block. When the lint key is absent or @@ -141,6 +146,9 @@ Annotations: | - build.hosts is optional, defaults to an empty mapping - review_executor.env is stored verbatim — semantic validation (env requires agent) belongs to the consumer + - review_executor.base_ref and review_executor.patience are parsed + structural-only and stored verbatim — branch resolvability and value + semantics belong to the consumer - codemanifest is optional - tools is optional; values are stored verbatim with NO semantic validation — invalid forms (operator-prefixed, malformed numerics) pass through @@ -190,6 +198,9 @@ Annotations: | at the loader level — semantics belong to the consumer - Do NOT validate review_executor.env semantics at the loader level — semantics belong to the consumer + - Do NOT parse the legacy build.review_patience key — the field moved to + build.review_executor.patience; a config declaring the old key is silently + ignored (accepted breaking change: the field was never released) - The final `ProjectConfig` assembly MUST include lint "ProjectConfig(lang: str, image: str | None, dockerfile: str | None, build: BuildConfig | None, pipeline: PipelineConfig | None, commands: dict, codemanifest: CodemanifestConfig | None, tools: dict[str, str] | None, usages: dict[str, dict[str, DepConfig]] | None = None, lint: LintConfig | None = None)": @@ -271,7 +282,7 @@ Annotations: | `LintConfig`, or None when the lint section is absent. Defaults to None (kw_only) so ProjectConfig(...) callers may omit lint=. -"BuildConfig(task_executor: TaskExecutorConfig, worktree: bool | None, skip_finalize: bool | None, session_timeout: str | None, idle_timeout: str | None, wait: str | None, max_iterations: int | None, review_patience: int | None, prompts_dir: str | None, agents_dir: str | None, codex_review: bool | None, review_executor: ReviewExecutorConfig | None, proxy: str | None, hosts: dict[str, str])": +"BuildConfig(task_executor: TaskExecutorConfig, worktree: bool | None, skip_finalize: bool | None, session_timeout: str | None, idle_timeout: str | None, wait: str | None, max_iterations: int | None, prompts_dir: str | None, agents_dir: str | None, codex_review: bool | None, review_executor: ReviewExecutorConfig | None, proxy: str | None, hosts: dict[str, str])": location: config.py annotations: | Build execution configuration. Constructed by load_project_config from the build section of .goga/config.yml. @@ -303,8 +314,6 @@ Annotations: | Rate-limit retry wait duration. Go duration format. "max_iterations -> int | None": | Maximum number of task iterations. - "review_patience -> int | None": | - Stop review after N consecutive rounds with no changes. "prompts_dir -> str | None": | Custom ralphex prompt directory path. "agents_dir -> str | None": | @@ -351,12 +360,13 @@ Annotations: | Passed to the AI executor at launch to configure models and endpoints. Optional — defaults to an empty dict. -"ReviewExecutorConfig(skip: bool | None, agent: str | None, roles: list[str] | None, env: dict[str, str])": +"ReviewExecutorConfig(skip: bool | None, agent: str | None, roles: list[str] | None, env: dict[str, str], base_ref: str | None, patience: int | None)": location: config.py annotations: | Review-executor configuration of the build — whether the review phase is - skipped, which agent runs it, which reviewer roles participate, and which - environment variables the review pass carries. + skipped, which agent runs it, which reviewer roles participate, which + environment variables the review pass carries, the base of the review + diff, and the external-review stop threshold. `skip`: tri-state source for skipping the review phase — None when the field is absent @@ -366,6 +376,8 @@ Annotations: | stored verbatim and means the full default set to the consumer `env`: environment variable dictionary for the review pass — an empty dict when the field is absent, YAML-null, or an empty mapping + `base_ref`: review diff base — None when unset + `patience`: external-review stop threshold — None when unset Requirements: - Immutable frozen dataclass (frozen=True, kw_only=True), per `convention` @@ -376,6 +388,8 @@ Annotations: | Constraints: - Do not validate role names, agent names, or env applicability at this level — structural typing only + - Do not validate base_ref or patience values at this level — branch + resolvability and threshold semantics belong to the consumer properties: "skip -> bool | None": | Tri-state source for skipping the review phase. None when the field is @@ -392,6 +406,18 @@ Annotations: | from .goga/config.yml. Empty dict when the field is absent, YAML-null, or an empty mapping. Structural typing is enforced by `load_project_config`; the env-requires-agent rule belongs to the consumer. + "base_ref -> str | None": | + Review diff base for the review pass — a branch name or a commit + hash, verbatim from .goga/config.yml. None when the field is absent, + YAML-null, or empty/whitespace-only (normalized by + `load_project_config`). No resolution or validation here — the value + flows to the consumer as the source of the review-scoped diff base. + "patience -> int | None": | + External-review stop threshold — stop the external review after N + consecutive unchanged rounds. None when the field is absent or + YAML-null. Structural typing (int) is enforced by + `load_project_config`; range and semantic checks belong to the + consumer. "PipelineConfig(agent: str | None, env: dict, proxy: str | None, hosts: dict[str, str])": location: config.py diff --git a/goga/ralphex/.usages/run-ralphex.md b/goga/ralphex/.usages/run-ralphex.md index cf2d41c1..cbc4eea6 100644 --- a/goga/ralphex/.usages/run-ralphex.md +++ b/goga/ralphex/.usages/run-ralphex.md @@ -20,6 +20,7 @@ options = { # resolved ralphex options (CLI > ProjectConfig > omit applied) "worktree": True, "max_iterations": 50, "session_timeout": "30m", + "base_ref": "origin/1.2.x", # → --base-ref (review diff base) "tasks_only": False, # True → --tasks-only (skip all review phases) "review": False, # True → --review (review-only pass) } @@ -39,6 +40,12 @@ if exit_code == 0: exit_code = run_ralphex(plan, {**options, "review": True}, dry_run) ``` +```python +# Review pass scoped to a diff base: base_ref maps to --base-ref and is +# omitted when None or empty — run_ralphex never validates the ref +exit_code = run_ralphex(plan, {**options, "review": True, "base_ref": "origin/1.2.x"}, dry_run) +``` + ```python # Review pass with an env layer: keys of env override the inherited # environment for this subprocess only; the tasks pass runs without a layer. @@ -55,6 +62,10 @@ exit_code = run_ralphex(plan, {**options, "review": True}, dry_run, env={"ANTHRO precedence resolution. Bool keys include `tasks_only` (True → bare `--tasks-only`, tasks without any review) and `review` (True → bare `--review`, review-only pass); False or absent omits the flag. + Review-scoped keys — `review_patience` and `base_ref` — map like any other + key but belong on review-carrying passes only (the caller decides the pass + composition). `base_ref` is forwarded verbatim and omitted from the command + when None or an empty string. - `dry_run: bool` — when True, print the assembled ralphex command to sys.stderr and return 0 without launching. - `env: dict[str, str] | None` — optional environment layer for the ralphex @@ -95,3 +106,5 @@ the docker env-file by the host launcher). in the caller. - Do not pass a build config object (`BuildConfig`/`TaskExecutorConfig`) — `run_ralphex` takes resolved primitives only and imports nothing from `goga/config`. +- Do not pass `base_ref` expecting `run_ralphex` to validate or resolve the + ref — the value is forwarded verbatim; ralphex resolves it. diff --git a/goga/ralphex/CODEMANIFEST b/goga/ralphex/CODEMANIFEST index 273c2e15..0f34da1c 100644 --- a/goga/ralphex/CODEMANIFEST +++ b/goga/ralphex/CODEMANIFEST @@ -49,6 +49,7 @@ Annotations: | - wait (str) → --wait (value flag) - max_iterations (int) → --max-iterations (value flag) - review_patience (int) → --review-patience (value flag) + - base_ref (str) → --base-ref (value flag) `dry_run`: when True, print the assembled ralphex command to sys.stderr and return 0 without launching. `env`: optional environment layer ({str: str}) applied on top of the inherited From 0bf39fc578d7f351db48c9eae90ccb462315cf2a Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 13:27:21 +0000 Subject: [PATCH 036/229] feat: add review-scoped base_ref/patience to project config model and loader --- goga/config/project/config.py | 8 +- goga/config/project/loader.py | 62 +++++++++- tests/config/test_config.py | 24 +++- tests/config/test_integration.py | 9 +- tests/config/test_loader.py | 190 ++++++++++++++++++++++++++++++- 5 files changed, 273 insertions(+), 20 deletions(-) diff --git a/goga/config/project/config.py b/goga/config/project/config.py index 96bde57e..068928f5 100644 --- a/goga/config/project/config.py +++ b/goga/config/project/config.py @@ -66,12 +66,19 @@ class ReviewExecutorConfig: ``env`` is the review-pass environment layer, stored verbatim from ``.goga/config.yml``: an empty dict when the field is absent, YAML-null, or an empty mapping. The env-requires-agent rule belongs to the consumer, not here. + + The section also carries the review diff base (``base_ref``) and the + external-review stop threshold (``patience``). Both are stored verbatim — + structural typing only: branch resolvability and threshold semantics belong to + the consumer, never to this dataclass or the loader. """ skip: bool | None = None agent: str | None = None roles: list[str] | None = None env: dict[str, str] = field(default_factory=dict) + base_ref: str | None = None + patience: int | None = None @dataclass(kw_only=True, frozen=True) @@ -85,7 +92,6 @@ class BuildConfig: idle_timeout: str | None = None wait: str | None = None max_iterations: int | None = None - review_patience: int | None = None prompts_dir: str | None = None agents_dir: str | None = None codex_review: bool | None = None diff --git a/goga/config/project/loader.py b/goga/config/project/loader.py index 11221a27..59a8c3f3 100644 --- a/goga/config/project/loader.py +++ b/goga/config/project/loader.py @@ -394,6 +394,49 @@ def _optional_mapping(data: dict, key: str) -> dict | None: return section +def _parse_review_scoped_fields(raw: dict) -> tuple[str | None, int | None]: + """Parse the review-scoped ``base_ref``/``patience`` pair of ``review_executor``. + + Structural typing only, mirroring ``_parse_optional_agent``: ``base_ref`` is + stored stripped (an empty or whitespace-only string resolves to None) and a + present non-string is a type error. ``patience`` must be a real int — the + bool check precedes the int check because ``isinstance(True, int)`` is True, + so a YAML ``true`` is rejected instead of slipping through as ``1``. Both + are stored verbatim beyond that gate: no range checks, no + branch-resolvability checks (those belong to the consumer). + + Args: + raw: The already-parsed ``review_executor`` mapping. + + Returns: + The ``(base_ref, patience)`` pair, each None when its key is absent or + YAML-null. + + Raises: + ValueError: When ``base_ref`` is present but not a string, or when + ``patience`` is present but not an int (a bool included). + """ + base_ref_raw = raw.get("base_ref") + + if base_ref_raw is None: + base_ref = None + elif not isinstance(base_ref_raw, str): + raise ValueError("build.review_executor.base_ref must be a string in .goga/config.yml") + else: + base_ref = base_ref_raw.strip() or None + + patience_raw = raw.get("patience") + + if patience_raw is None: + patience = None + elif isinstance(patience_raw, bool) or not isinstance(patience_raw, int): + raise ValueError("build.review_executor.patience must be an int in .goga/config.yml") + else: + patience = patience_raw + + return base_ref, patience + + def _parse_review_executor(build_data: dict) -> ReviewExecutorConfig | None: """Parse the optional ``build.review_executor`` section (loader step 6.5). @@ -411,6 +454,9 @@ def _parse_review_executor(build_data: dict) -> ReviewExecutorConfig | None: whitelists and no env semantics live here — validation beyond structure belongs to the consumer. + The review-scoped pair (``base_ref``/``patience``) is parsed by + ``_parse_review_scoped_fields`` under the same structural-only stance. + Args: build_data: The already-parsed ``build`` mapping. @@ -421,8 +467,8 @@ def _parse_review_executor(build_data: dict) -> ReviewExecutorConfig | None: Raises: ValueError: When the section is present but not a mapping, or when - ``skip``/``agent``/``roles``/``env`` is present with an invalid - type. + ``skip``/``agent``/``roles``/``env``/``base_ref``/``patience`` is + present with an invalid type. """ raw = build_data.get("review_executor") @@ -459,7 +505,16 @@ def _parse_review_executor(build_data: dict) -> ReviewExecutorConfig | None: else: env = dict(env_raw) - return ReviewExecutorConfig(skip=skip, agent=agent, roles=roles, env=env) + base_ref, patience = _parse_review_scoped_fields(raw) + + return ReviewExecutorConfig( + skip=skip, + agent=agent, + roles=roles, + env=env, + base_ref=base_ref, + patience=patience, + ) def _parse_build(build_data: dict) -> BuildConfig: @@ -492,7 +547,6 @@ def _parse_build(build_data: dict) -> BuildConfig: idle_timeout=build_data.get("idle_timeout"), wait=build_data.get("wait"), max_iterations=build_data.get("max_iterations"), - review_patience=build_data.get("review_patience"), prompts_dir=build_data.get("prompts_dir"), agents_dir=build_data.get("agents_dir"), codex_review=build_data.get("codex_review"), diff --git a/tests/config/test_config.py b/tests/config/test_config.py index bbcc454c..8a874926 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -11,6 +11,7 @@ LintConfig, PipelineConfig, ProjectConfig, + ReviewExecutorConfig, TaskExecutorConfig, ) from goga.config.project.config import DepConfig @@ -134,10 +135,18 @@ def test_review_executor_declared_fields(self): from goga.config import ReviewExecutorConfig names = [f.name for f in dataclasses.fields(ReviewExecutorConfig)] - assert names == ["skip", "agent", "roles", "env"] + assert names == ["skip", "agent", "roles", "env", "base_ref", "patience"] assert ReviewExecutorConfig.__dataclass_fields__["env"].type == dict[str, str] assert ReviewExecutorConfig(skip=None, agent=None, roles=None).env == {} + def test_review_executor_config_declares_base_ref_and_patience_fields(self): + """ReviewExecutorConfig carries the review-scoped base_ref/patience fields and + BuildConfig no longer declares the relocated review_patience.""" + from goga.config import ReviewExecutorConfig + + assert {"base_ref", "patience"} <= set(ReviewExecutorConfig.__dataclass_fields__) + assert "review_patience" not in BuildConfig.__dataclass_fields__ + def test_has_worktree_field(self): assert "worktree" in BuildConfig.__dataclass_fields__ @@ -156,8 +165,9 @@ def test_has_wait_field(self): def test_has_max_iterations_field(self): assert "max_iterations" in BuildConfig.__dataclass_fields__ - def test_has_review_patience_field(self): - assert "review_patience" in BuildConfig.__dataclass_fields__ + def test_review_patience_field_removed(self): + """The relocated review_patience is gone from BuildConfig.""" + assert "review_patience" not in BuildConfig.__dataclass_fields__ def test_has_prompts_dir_field(self): assert "prompts_dir" in BuildConfig.__dataclass_fields__ @@ -417,7 +427,7 @@ def test_all_none_optional_fields(self): assert bc.idle_timeout is None assert bc.wait is None assert bc.max_iterations is None - assert bc.review_patience is None + assert not hasattr(bc, "review_patience") assert bc.prompts_dir is None assert bc.agents_dir is None assert bc.codex_review is None @@ -427,6 +437,7 @@ def test_all_none_optional_fields(self): def test_all_fields_populated(self): te = TaskExecutorConfig(agent="gemini", env={"X": "1"}) + review = ReviewExecutorConfig(agent="codex", base_ref="origin/1.2.x", patience=3) bc = BuildConfig( task_executor=te, worktree=True, @@ -435,10 +446,10 @@ def test_all_fields_populated(self): idle_timeout="1h", wait="5m", max_iterations=10, - review_patience=3, prompts_dir="/custom/prompts", agents_dir="/custom/agents", codex_review=True, + review_executor=review, ) assert bc.task_executor.agent == "gemini" assert bc.task_executor.env == {"X": "1"} @@ -448,7 +459,8 @@ def test_all_fields_populated(self): assert bc.idle_timeout == "1h" assert bc.wait == "5m" assert bc.max_iterations == 10 - assert bc.review_patience == 3 + assert bc.review_executor.patience == 3 + assert bc.review_executor.base_ref == "origin/1.2.x" assert bc.prompts_dir == "/custom/prompts" assert bc.agents_dir == "/custom/agents" assert bc.codex_review is True diff --git a/tests/config/test_integration.py b/tests/config/test_integration.py index b08f0b46..ff4753b4 100644 --- a/tests/config/test_integration.py +++ b/tests/config/test_integration.py @@ -29,10 +29,12 @@ idle_timeout: "2h" wait: "10m" max_iterations: 20 - review_patience: 5 prompts_dir: "/etc/goga/prompts" agents_dir: "/etc/goga/agents" codex_review: false + review_executor: + base_ref: origin/1.2.x + patience: 5 commands: build: cargo build --release test: cargo test @@ -91,7 +93,8 @@ def test_full_object_graph_from_yaml(self, tmp_path, monkeypatch): assert config.build.idle_timeout == "2h" assert config.build.wait == "10m" assert config.build.max_iterations == 20 - assert config.build.review_patience == 5 + assert config.build.review_executor.patience == 5 + assert config.build.review_executor.base_ref == "origin/1.2.x" assert config.build.prompts_dir == "/etc/goga/prompts" assert config.build.agents_dir == "/etc/goga/agents" assert config.build.codex_review is False @@ -127,7 +130,7 @@ def test_minimal_yaml_produces_defaults(self, tmp_path, monkeypatch): assert config.build.idle_timeout is None assert config.build.wait is None assert config.build.max_iterations is None - assert config.build.review_patience is None + assert config.build.review_executor is None assert config.build.prompts_dir is None assert config.build.agents_dir is None assert config.build.codex_review is None diff --git a/tests/config/test_loader.py b/tests/config/test_loader.py index c8e8fe0a..1f2469a4 100644 --- a/tests/config/test_loader.py +++ b/tests/config/test_loader.py @@ -75,10 +75,12 @@ def _write_goga_yml(path, content: str): idle_timeout: "1h" wait: "5m" max_iterations: 10 - review_patience: 3 prompts_dir: "/custom/prompts" agents_dir: "/custom/agents" codex_review: true + review_executor: + base_ref: origin/1.2.x + patience: 3 """ HAPPY_YAML = """\ @@ -161,7 +163,8 @@ def test_load_config_full_yaml(self, goga_project): assert config.build.idle_timeout == "1h" assert config.build.wait == "5m" assert config.build.max_iterations == 10 - assert config.build.review_patience == 3 + assert config.build.review_executor.base_ref == "origin/1.2.x" + assert config.build.review_executor.patience == 3 assert config.build.prompts_dir == "/custom/prompts" assert config.build.agents_dir == "/custom/agents" assert config.build.codex_review is True @@ -3102,17 +3105,19 @@ def test_review_executor_config_importable_from_project_cell(self): assert project_mod.ReviewExecutorConfig is ReviewExecutorConfig def test_review_executor_config_is_frozen_kw_only_dataclass(self): - """ReviewExecutorConfig is a frozen kw_only dataclass with four fields.""" + """ReviewExecutorConfig is a frozen kw_only dataclass with six fields.""" from goga.config.project.config import ReviewExecutorConfig assert dataclasses.is_dataclass(ReviewExecutorConfig) params = {f.name: f for f in dataclasses.fields(ReviewExecutorConfig)} - assert set(params) == {"skip", "agent", "roles", "env"} + assert set(params) == {"skip", "agent", "roles", "env", "base_ref", "patience"} assert params["skip"].default is None assert params["agent"].default is None assert params["roles"].default is None assert params["env"].default is dataclasses.MISSING assert params["env"].default_factory is dict + assert params["base_ref"].default is None + assert params["patience"].default is None def test_review_executor_config_reexport_from_facade_alive(self): """goga.config re-exports the same class object as the project cell.""" @@ -3298,11 +3303,12 @@ def test_loader_parses_review_executor_env_mapping(self, goga_project): ) def test_review_executor_config_declared_fields_include_env(self): - """Declared fields are skip, agent, roles, env; env is a factory-defaulted dict[str, str].""" + """Declared fields are skip, agent, roles, env, base_ref, patience; env is + a factory-defaulted dict[str, str].""" from goga.config.project.config import ReviewExecutorConfig names = [f.name for f in dataclasses.fields(ReviewExecutorConfig)] - assert names == ["skip", "agent", "roles", "env"] + assert names == ["skip", "agent", "roles", "env", "base_ref", "patience"] assert ReviewExecutorConfig.__dataclass_fields__["env"].type == dict[str, str] env_field = {f.name: f for f in dataclasses.fields(ReviewExecutorConfig)}["env"] assert env_field.default is dataclasses.MISSING @@ -3374,3 +3380,175 @@ def test_loader_review_executor_env_absent_null_empty_all_empty_dict(self, goga_ config = load_project_config() assert config.build.review_executor is not None, env_id assert config.build.review_executor.env == {}, env_id + + def test_review_executor_base_ref_parsed_verbatim(self, goga_project): + """review_executor.base_ref string is stored verbatim as a str.""" + _write_goga_yml( + goga_project, + """\ +language: python +build: + task_executor: + agent: claude + review_executor: + agent: claude + base_ref: origin/1.2.x +""", + ) + config = load_project_config() + assert config.build.review_executor.base_ref == "origin/1.2.x" + assert isinstance(config.build.review_executor.base_ref, str) + + def test_review_executor_patience_int_parsed(self, goga_project): + """review_executor.patience YAML int is stored verbatim as an int.""" + _write_goga_yml( + goga_project, + """\ +language: python +build: + task_executor: + agent: claude + review_executor: + patience: 3 +""", + ) + config = load_project_config() + assert config.build.review_executor.patience == 3 + assert isinstance(config.build.review_executor.patience, int) + assert not isinstance(config.build.review_executor.patience, bool) + + def test_review_executor_base_ref_non_string_raises(self, goga_project): + """review_executor.base_ref: 12 → ValueError with the exact contract message.""" + _write_goga_yml( + goga_project, + """\ +language: python +build: + task_executor: + agent: claude + review_executor: + base_ref: 12 +""", + ) + with pytest.raises(ValueError, match=r"review_executor\.base_ref must be a string"): + load_project_config() + + @pytest.mark.parametrize( + "patience_snippet", + ['patience: "3"', "patience: 3.5"], + ids=["quoted-string", "float"], + ) + def test_review_executor_patience_non_int_raises(self, goga_project, patience_snippet): + """A non-int patience (str, float) raises ValueError with the exact message.""" + _write_goga_yml( + goga_project, + f"""\ +language: python +build: + task_executor: + agent: claude + review_executor: + {patience_snippet} +""", + ) + with pytest.raises(ValueError, match=r"review_executor\.patience must be an int"): + load_project_config() + + def test_review_executor_patience_yaml_bool_rejected(self, goga_project): + """patience: true → ValueError — guards the bool-before-int check order.""" + _write_goga_yml( + goga_project, + """\ +language: python +build: + task_executor: + agent: claude + review_executor: + patience: true +""", + ) + with pytest.raises(ValueError, match=r"review_executor\.patience must be an int"): + load_project_config() + + def test_legacy_build_review_patience_key_not_parsed(self, goga_project): + """A legacy build.review_patience key is silently ignored — no field, no error.""" + _write_goga_yml( + goga_project, + """\ +language: python +build: + task_executor: + agent: claude + review_patience: 5 +""", + ) + config = load_project_config() + assert not hasattr(config.build, "review_patience") + + @pytest.mark.parametrize( + "base_ref_snippet", + ["", "base_ref: null\n", 'base_ref: ""\n', 'base_ref: " "\n'], + ids=["absent", "yaml-null", "empty-string", "whitespace-only"], + ) + def test_review_executor_base_ref_unset_variants_resolve_none(self, goga_project, base_ref_snippet): + """Absent, YAML-null, empty and whitespace-only base_ref all resolve to None.""" + _write_goga_yml( + goga_project, + f"""\ +language: python +build: + task_executor: + agent: claude + review_executor: + agent: claude + {base_ref_snippet}""", + ) + config = load_project_config() + assert config.build.review_executor is not None + assert config.build.review_executor.base_ref is None + + @pytest.mark.parametrize( + ("patience_snippet", "section_present"), + [("agent: claude\n", True), ("agent: claude\n patience: null\n", True), ("", False)], + ids=["absent", "yaml-null", "section-absent"], + ) + def test_review_executor_patience_unset_variants_resolve_none( + self, goga_project, patience_snippet, section_present + ): + """Absent and YAML-null patience both resolve to None, as does an absent section.""" + _write_goga_yml( + goga_project, + f"""\ +language: python +build: + task_executor: + agent: claude + review_executor: + {patience_snippet}""", + ) + config = load_project_config() + if section_present: + assert config.build.review_executor is not None + assert config.build.review_executor.patience is None + else: + assert config.build.review_executor is None + + @pytest.mark.parametrize( + ("patience_literal", "patience_id"), + [("0", "zero"), ("-1", "negative")], + ) + def test_review_executor_patience_zero_and_negative_verbatim(self, goga_project, patience_literal, patience_id): + """patience 0 and -1 are stored verbatim — structural typing, no range check.""" + _write_goga_yml( + goga_project, + f"""\ +language: python +build: + task_executor: + agent: claude + review_executor: + patience: {patience_literal} +""", + ) + config = load_project_config() + assert config.build.review_executor.patience == int(patience_literal), patience_id From 8801146f35c21fbbf9663287f747e45d6cea4921 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 13:30:17 +0000 Subject: [PATCH 037/229] feat: add base_ref scalar flag mapping to goga/ralphex --- goga/ralphex/run_ralphex.py | 1 + tests/ralphex/test_run_ralphex.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/goga/ralphex/run_ralphex.py b/goga/ralphex/run_ralphex.py index a08fdf48..4ec73ab7 100644 --- a/goga/ralphex/run_ralphex.py +++ b/goga/ralphex/run_ralphex.py @@ -21,6 +21,7 @@ ("wait", "--wait"), ("max_iterations", "--max-iterations"), ("review_patience", "--review-patience"), + ("base_ref", "--base-ref"), ) diff --git a/tests/ralphex/test_run_ralphex.py b/tests/ralphex/test_run_ralphex.py index 311fed20..78358080 100644 --- a/tests/ralphex/test_run_ralphex.py +++ b/tests/ralphex/test_run_ralphex.py @@ -93,6 +93,7 @@ def test_bool_flag_mapping_is_exact(self, key: str, flag: str) -> None: ("wait", "--wait", "60s"), ("max_iterations", "--max-iterations", 10), ("review_patience", "--review-patience", 3), + ("base_ref", "--base-ref", "origin/1.2.x"), ], ) def test_scalar_flag_mapping_is_exact(self, key: str, flag: str, value: object) -> None: @@ -102,6 +103,19 @@ def test_scalar_flag_mapping_is_exact(self, key: str, flag: str, value: object) assert flag in cmd assert str(value) in cmd + @pytest.mark.parametrize( + "value", + [None, ""], + ids=["none", "empty_string"], + ) + def test_base_ref_unset_omits_flag(self, value: str | None) -> None: + """An unset base_ref adds no token: neither --base-ref nor any value + token reaches the argv (pins the scalar omit rule for the new key).""" + cmd = _build_command("plan.md", {"base_ref": value}) + + assert cmd == ["ralphex", "plan.md", "--config-dir", ".ralphex/"] + assert "--base-ref" not in cmd + class TestRunRalphexLogic: def test_run_ralphex_dry_run_prints_command_and_returns_0(self, capsys: pytest.CaptureFixture[str]) -> None: From 28c37514750db9507feddd2fe449b4a5c997500a Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 13:34:27 +0000 Subject: [PATCH 038/229] feat: resolve review-scoped base_ref and patience in review options --- goga/build/review_options.py | 49 +++++++++++++--- tests/build/test_review_options.py | 91 +++++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 10 deletions(-) diff --git a/goga/build/review_options.py b/goga/build/review_options.py index b0657c64..62a52fe0 100644 --- a/goga/build/review_options.py +++ b/goga/build/review_options.py @@ -15,8 +15,12 @@ class ReviewOptions: `two_pass` is True when a review executor agent is set and either differs from the task executor agent or carries a non-empty review env; `review_env` is the review-pass environment layer, verbatim (an empty dict - when the section declares no env). Branch priority between `skip` and - `two_pass` belongs to the orchestrator, not to this value-object. + when the section declares no env); `base_ref` is the resolved review diff + base (None when unset, whitespace-normalized by the resolver); `patience` + is the resolved external-review stop threshold (None when unset). The two + review-scoped fields are forwarded to review-carrying passes only. Branch + priority between `skip` and `two_pass` belongs to the orchestrator, not to + this value-object. """ skip: bool @@ -24,16 +28,18 @@ class ReviewOptions: roles: list[str] | None two_pass: bool review_env: dict[str, str] + base_ref: str | None = None + patience: int | None = None def resolve_review_options(config: BuildConfig, cli_options: dict) -> ReviewOptions: """Reduce the tri-state skip flag and the review executor section to a decision. - Pure function — no I/O, no validation, no normalization. Precedence for - `skip` is CLI > ProjectConfig > omit: a non-None `cli_options["skip_review"]` - wins, otherwise `build.review_executor.skip` (when the section exists), - otherwise False. An empty roles list travels to the consumer as an empty - list (the "full default set" reading belongs to the consumer). + Pure function — no I/O, no validation of values. Precedence for `skip` is + CLI > ProjectConfig > omit: a non-None `cli_options["skip_review"]` wins, + otherwise `build.review_executor.skip` (when the section exists), otherwise + False. An empty roles list travels to the consumer as an empty list (the + "full default set" reading belongs to the consumer). `two_pass` is True when a review agent is set and it differs from the task executor agent OR the review env is non-empty (dictionary equality with @@ -41,9 +47,24 @@ def resolve_review_options(config: BuildConfig, cli_options: dict) -> ReviewOpti `review_env` is `build.review_executor.env` verbatim (shared by reference, like `TaskExecutorConfig.env`) — an empty dict when there is no section. + The review-scoped options resolve with the precedence CLI > + `build.review_executor.*` > omit: `base_ref` takes + `cli_options["base_ref"]` when set, otherwise + `build.review_executor.base_ref`; an empty or whitespace-only value from + either source counts as unset (resolved to None; a padded value resolves + to its stripped form — the CLI path and directly-constructed configs are + not loader-normalized). `patience` takes `cli_options["review_patience"]` + when set, otherwise `build.review_executor.patience`. An absent + review_executor section leaves both None. The resolved `base_ref` is never + checked for resolvability or format — diagnostics of the review diff base + belong to ralphex. + Args: config: Build configuration with the optional review_executor section. - cli_options: In-container CLI options; only `skip_review` is read. + cli_options: In-container CLI options; the keys read here are + `skip_review` (bool | None — None = flag not given), `base_ref` + (str | None — an empty or whitespace-only value counts as unset), + and `review_patience` (int | None). """ review_executor = config.review_executor @@ -61,10 +82,22 @@ def resolve_review_options(config: BuildConfig, cli_options: dict) -> ReviewOpti review_env = review_executor.env if review_executor is not None else {} two_pass = review_agent is not None and (review_agent != config.task_executor.agent or bool(review_env)) + base_ref = cli_options.get("base_ref") + if base_ref is None and review_executor is not None: + base_ref = review_executor.base_ref + if base_ref is not None: + base_ref = base_ref.strip() or None + + patience = cli_options.get("review_patience") + if patience is None and review_executor is not None: + patience = review_executor.patience + return ReviewOptions( skip=skip, review_agent=review_agent, roles=roles, two_pass=two_pass, review_env=review_env, + base_ref=base_ref, + patience=patience, ) diff --git a/tests/build/test_review_options.py b/tests/build/test_review_options.py index 2c3d5fdf..d8050906 100644 --- a/tests/build/test_review_options.py +++ b/tests/build/test_review_options.py @@ -38,7 +38,7 @@ def test_resolve_review_options_returns_review_options(self) -> None: def test_review_options_declared_fields(self) -> None: fields = {f.name for f in dataclasses.fields(ReviewOptions)} - assert fields == {"skip", "review_agent", "roles", "two_pass", "review_env"} + assert fields == {"skip", "review_agent", "roles", "two_pass", "review_env", "base_ref", "patience"} def test_review_options_field_types(self) -> None: hints = typing.get_type_hints(ReviewOptions) @@ -47,11 +47,13 @@ def test_review_options_field_types(self) -> None: assert hints["roles"] == list[str] | None assert hints["two_pass"] is bool assert hints["review_env"] == dict[str, str] + assert hints["base_ref"] == str | None + assert hints["patience"] == int | None def test_review_options_declared_fields_include_review_env(self) -> None: """`review_env` is the fifth field, required — no default factory.""" names = [f.name for f in dataclasses.fields(ReviewOptions)] - assert names == ["skip", "review_agent", "roles", "two_pass", "review_env"] + assert names == ["skip", "review_agent", "roles", "two_pass", "review_env", "base_ref", "patience"] env_field = next(f for f in dataclasses.fields(ReviewOptions) if f.name == "review_env") assert env_field.default is dataclasses.MISSING assert env_field.default_factory is dataclasses.MISSING @@ -62,6 +64,16 @@ def test_review_options_is_kw_only_and_frozen(self) -> None: with pytest.raises(TypeError): ReviewOptions(False, None, None, False, {}) # type: ignore[misc] + def test_review_options_declares_base_ref_and_patience_fields(self) -> None: + assert {"base_ref", "patience"} <= set(ReviewOptions.__dataclass_fields__) + + def test_resolve_review_options_docstring_lists_three_keys(self) -> None: + """The docstring names the three cli_options keys; the old one-key wording is gone.""" + doc = resolve_review_options.__doc__ or "" + for key in ("skip_review", "base_ref", "review_patience"): + assert key in doc + assert "only `skip_review` is read" not in doc + class TestResolveReviewOptionsLogic: @pytest.mark.parametrize("cli", [None, True, False]) @@ -185,3 +197,78 @@ def test_resolve_review_options_env_without_agent_single_pass(self) -> None: assert result.two_pass is False assert result.review_agent is None assert result.review_env == {"X": "y"} + + def test_resolve_review_options_base_ref_cli_overrides_config(self) -> None: + config = _make_build_config( + review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/main"), + ) + + result = resolve_review_options(config, {"base_ref": "origin/1.2.x"}) + + assert result.base_ref == "origin/1.2.x" + + def test_resolve_review_options_base_ref_from_config_when_cli_absent(self) -> None: + config = _make_build_config( + review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/1.2.x"), + ) + + result = resolve_review_options(config, {}) + + assert result.base_ref == "origin/1.2.x" + + def test_resolve_review_options_patience_cli_overrides_config(self) -> None: + """Pins the naming split: the cli_options KEY is review_patience, + the ReviewOptions FIELD is patience.""" + config = _make_build_config( + review_executor=ReviewExecutorConfig(agent="claude", patience=3), + ) + + result = resolve_review_options(config, {"review_patience": 7}) + + assert result.patience == 7 + + def test_resolve_review_options_base_ref_empty_whitespace_resolves_unset(self) -> None: + config = _make_build_config( + review_executor=ReviewExecutorConfig(agent="claude", base_ref=" "), + ) + + result = resolve_review_options(config, {}) + + assert result.base_ref is None + + @pytest.mark.parametrize( + ("config_base_ref", "cli_options"), + [ + (" origin/1.2.x ", {}), + (None, {"base_ref": " origin/1.2.x "}), + ], + ids=["config-source", "cli-source"], + ) + def test_resolve_review_options_base_ref_padded_value_stripped(self, config_base_ref, cli_options) -> None: + """Exact equality — an implementation that only checks emptiness + without assigning the stripped value fails.""" + config = _make_build_config( + review_executor=ReviewExecutorConfig(agent="claude", base_ref=config_base_ref), + ) + + result = resolve_review_options(config, cli_options) + + assert result.base_ref == "origin/1.2.x" + + def test_resolve_review_options_base_ref_empty_cli_means_unset_not_fallback(self) -> None: + """An explicitly empty CLI value does NOT fall through to the config.""" + config = _make_build_config( + review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/main"), + ) + + result = resolve_review_options(config, {"base_ref": ""}) + + assert result.base_ref is None + + def test_resolve_review_options_no_review_executor_leaves_scoped_none(self) -> None: + config = _make_build_config(review_executor=None) + + result = resolve_review_options(config, {}) + + assert result.base_ref is None + assert result.patience is None From 653f2f5c91bc747db03ad9461b3d64eff1c6c3a1 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 13:38:58 +0000 Subject: [PATCH 039/229] feat: compose review-scoped options into review-carrying build passes --- goga/build/__main__.py | 2 + goga/build/build.py | 56 +++++++++++++++++-- tests/build/test_build.py | 115 +++++++++++++++++++++++++++++++++++++- tests/build/test_main.py | 33 +++++++++++ 4 files changed, 199 insertions(+), 7 deletions(-) diff --git a/goga/build/__main__.py b/goga/build/__main__.py index bfbfbf58..a7a61dcc 100644 --- a/goga/build/__main__.py +++ b/goga/build/__main__.py @@ -29,6 +29,7 @@ def main() -> int: parser.add_argument("--wait", type=str, default=None) parser.add_argument("--max-iterations", type=int, default=None) parser.add_argument("--review-patience", type=int, default=None) + parser.add_argument("--base-ref", type=str, default=None) args = parser.parse_args() config = load_project_config() @@ -43,6 +44,7 @@ def main() -> int: "wait": args.wait, "max_iterations": args.max_iterations, "review_patience": args.review_patience, + "base_ref": args.base_ref, "dry_run": args.dry_run, } diff --git a/goga/build/build.py b/goga/build/build.py index 215dc733..e588880a 100644 --- a/goga/build/build.py +++ b/goga/build/build.py @@ -10,7 +10,7 @@ from .plan_relocation import move_completed_plan from .ralphex_runtime import sync_ralphex_defaults from .review_config import validate_review_config -from .review_options import resolve_review_options +from .review_options import ReviewOptions, resolve_review_options logger = logging.getLogger(__name__) @@ -59,7 +59,7 @@ def _find_uncommitted_manifests() -> list[str]: def _resolve_options(config: ProjectConfig, cli_options: dict) -> dict[str, str | int | bool]: - """Resolve ralphex options with precedence CLI > BuildConfig > omit. + """Resolve the universal ralphex options with precedence CLI > BuildConfig > omit. Applies the precedence HERE in the build domain so `run_ralphex` performs no resolution. For store_true bool keys, a CLI value of False is treated as @@ -68,6 +68,13 @@ def _resolve_options(config: ProjectConfig, cli_options: dict) -> dict[str, str CLI value wins when present (not None) and otherwise falls back to BuildConfig. This helper knows no ralphex flag names; `run_ralphex` maps the resolved keys. + Only the universal options (worktree, skip_finalize, session_timeout, + idle_timeout, wait, max_iterations) are resolved here — they apply to every + ralphex pass. The review-scoped keys (`review_patience`, `base_ref`) are NOT + resolved here: their owner is `resolve_review_options`, which applies the + precedence CLI > `ReviewExecutorConfig` > omit, and the orchestrator joins + them onto review-carrying passes only. + The pass-mode keys `tasks_only`/`review` are deliberately NOT resolved here — they are mode flags of a single pass, laid on top of the base options by the orchestrator with a dict copy, never read from config or CLI passthrough. @@ -84,13 +91,40 @@ def _resolve_options(config: ProjectConfig, cli_options: dict) -> dict[str, str for key in ("worktree", "skip_finalize"): resolved[key] = bool(cli_options.get(key) or getattr(config.build, key)) - for key in ("session_timeout", "idle_timeout", "wait", "max_iterations", "review_patience"): + for key in ("session_timeout", "idle_timeout", "wait", "max_iterations"): cli_value = cli_options.get(key) resolved[key] = cli_value if cli_value is not None else getattr(config.build, key) return resolved +def _review_scoped_options(review: ReviewOptions) -> dict[str, str | int]: + """Project the review-scoped fields of a resolved ReviewOptions into an options fragment. + + The inverse end of the naming split fixed by the contract: the options key + for the diff base is `base_ref` (the ralphex option name), the key for the + stop threshold re-expands to `review_patience`. Both keys are ABSENT from + the fragment when unset — never present-with-None — so an unset source + yields an empty dict and the composed pass options stay byte-identical to + a run that never declared review bounds. + + Args: + review: Resolved review options of the run (already precedence-reduced + and whitespace-normalized by `resolve_review_options`). + + Returns: + The review-scoped options fragment joined onto review-carrying passes. + """ + options: dict[str, str | int] = {} + + if review.base_ref is not None: + options["base_ref"] = review.base_ref + if review.patience is not None: + options["review_patience"] = review.patience + + return options + + def build(plan: str, config: ProjectConfig, cli_options: dict) -> int: """Execute the build pipeline for a plan, orchestrating its review phase. @@ -108,7 +142,10 @@ def build(plan: str, config: ProjectConfig, cli_options: dict) -> int: first — without an env layer — and, when it succeeds, a review-only pass with the review wrapper and the review env as its environment layer; a pass-1 failure exits with its code and skips pass 2 (and its env layer); - anything else is one full pass, without a layer. + anything else is one full pass, without a layer. Review-scoped options + (base_ref, review_patience) join the options of review-carrying passes + only — the full-mode single pass and the two-pass review pass; a skip run + and the tasks-only pass carry universal options only. Args: plan: Path to the build plan file. @@ -134,6 +171,8 @@ def build(plan: str, config: ProjectConfig, cli_options: dict) -> int: review = resolve_review_options(config.build, cli_options) + review_scoped = _review_scoped_options(review) + if not review.skip: try: validate_review_config(config.build, review) @@ -159,10 +198,15 @@ def build(plan: str, config: ProjectConfig, cli_options: dict) -> int: if exit_code == 0: exit_code = run_build_pass( - plan, config.build, {**base, "review": True}, review_wrapper, dry_run, env=review.review_env + plan, + config.build, + {**base, "review": True, **review_scoped}, + review_wrapper, + dry_run, + env=review.review_env, ) else: - exit_code = run_build_pass(plan, config.build, base, task_wrapper, dry_run) + exit_code = run_build_pass(plan, config.build, {**base, **review_scoped}, task_wrapper, dry_run) move_completed_plan(plan, outcome=(exit_code == 0), dry_run=dry_run) diff --git a/tests/build/test_build.py b/tests/build/test_build.py index 4a76ca2b..515f0772 100644 --- a/tests/build/test_build.py +++ b/tests/build/test_build.py @@ -169,7 +169,16 @@ def test_resolve_options_omits_when_config_none(self) -> None: assert resolved["idle_timeout"] is None assert resolved["wait"] is None assert resolved["max_iterations"] is None - assert resolved["review_patience"] is None + assert "review_patience" not in resolved + + def test_resolve_options_universal_zone_drops_review_patience(self) -> None: + # Two-zone contract: the universal resolver owns worktree/skip_finalize/ + # session_timeout/idle_timeout/wait/max_iterations only — the + # review-scoped keys (review_patience, base_ref) are resolved by + # resolve_review_options, never here, even when the CLI carries them. + resolved = _resolve_options(_make_config(), {"review_patience": 5, "base_ref": "x"}) + assert "review_patience" not in resolved + assert "base_ref" not in resolved def test_resolve_options_skip_finalize_config_value_when_cli_absent(self) -> None: # Mirror of the worktree case for skip_finalize (the second bool key): @@ -1168,6 +1177,110 @@ def test_build_skip_run_skips_validation(self, tmp_path, monkeypatch) -> None: assert mock_run.call_args.args[1]["tasks_only"] is True +class TestReviewScopedPassComposition: + """Contract: review-scoped options (base_ref, review_patience) join the + options of review-carrying passes ONLY — the full-mode single pass and the + two-pass review pass. A skip run and the tasks-only pass carry universal + options only. Key-presence per pass is the API surface under contract.""" + + def test_full_pass_carries_review_scoped_options(self, tmp_path, monkeypatch) -> None: + # Same agent as the task executor and an empty review env -> a single + # full pass, which IS review-carrying: the scoped options ride along. + config = _make_config( + review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/1.2.x", patience=3) + ) + with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + config=config, + cli_options={"skip_manifest_check": True}, + ) + + assert result == 0 + assert mock_run.call_count == 1 + assert mock_run.call_args.args[1]["base_ref"] == "origin/1.2.x" + assert mock_run.call_args.args[1]["review_patience"] == 3 + + def test_two_pass_review_scoped_options_only_on_review_pass(self, tmp_path, monkeypatch) -> None: + # A differing review agent induces the two-pass mode: pass 1 is + # tasks-only (universal options only), pass 2 is the review pass and + # carries the scoped options. + config = _make_config( + review_executor=ReviewExecutorConfig(agent="codex", base_ref="origin/1.2.x", patience=3) + ) + review_wrapper = tmp_path / "codex-as-claude.sh" + review_wrapper.write_text("#!/bin/sh\n") + + with ( + mock.patch("goga.build.review_config.resolve_wrapper_path", return_value=str(review_wrapper)), + mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run, + ): + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + config=config, + cli_options={"skip_manifest_check": True}, + ) + + assert result == 0 + assert mock_run.call_count == 2 + first = mock_run.call_args_list[0].args[1] + assert "base_ref" not in first + assert "review_patience" not in first + second = mock_run.call_args_list[1].args[1] + assert second["base_ref"] == "origin/1.2.x" + assert second["review_patience"] == 3 + assert second["review"] is True + + def test_skip_run_omits_review_scoped_options(self, tmp_path, monkeypatch) -> None: + # A skip run has no review phase of any kind: even with review bounds + # declared, the single tasks-only pass carries universal options only. + config = _make_config( + review_executor=ReviewExecutorConfig(skip=True, base_ref="origin/1.2.x", patience=3) + ) + with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + config=config, + cli_options={"skip_manifest_check": True}, + ) + + assert result == 0 + assert mock_run.call_count == 1 + assert "base_ref" not in mock_run.call_args.args[1] + assert "review_patience" not in mock_run.call_args.args[1] + assert mock_run.call_args.args[1]["tasks_only"] is True + + def test_no_source_review_scoped_keys_absent_command_byte_identical(self, tmp_path, monkeypatch) -> None: + # Backward-compat criterion: with neither a review_executor section nor + # scoped CLI options, the keys stay absent from the captured options and + # the assembled ralphex command is byte-identical to the pre-change + # behavior — the bare prefix plus only the universal flags the fixture + # actually sets (here: none). + from goga.ralphex.run_ralphex import _build_command + + with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + config=_make_config(), + cli_options={"skip_manifest_check": True}, + ) + + assert result == 0 + captured_options = mock_run.call_args.args[1] + assert "base_ref" not in captured_options + assert "review_patience" not in captured_options + assert _build_command("plan.md", captured_options) == [ + "ralphex", + "plan.md", + "--config-dir", + ".ralphex/", + ] + + # --- Integration: secret-safe dry-run across the orchestration/launcher seam --- diff --git a/tests/build/test_main.py b/tests/build/test_main.py index 9b5c0209..f16c8859 100644 --- a/tests/build/test_main.py +++ b/tests/build/test_main.py @@ -121,6 +121,39 @@ def test_main_review_patience_flag(self, mock_build, mock_config, tmp_path, monk cli_options = mock_build.call_args[0][2] assert cli_options["review_patience"] == 5 + @mock.patch("goga.build.__main__.load_project_config") + @mock.patch("goga.build.__main__.build", return_value=0) + def test_main_base_ref_flag(self, mock_build, mock_config, tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + _write_goga_yml(tmp_path) + + with ( + mock.patch.dict(os.environ, {"GOGA_DOCKER": "1"}), + mock.patch("sys.argv", ["goga.build", "plan.md", "--base-ref", "origin/1.2.x", "--skip-manifest-check"]), + ): + main() + + cli_options = mock_build.call_args[0][2] + assert cli_options["base_ref"] == "origin/1.2.x" + + @mock.patch("goga.build.__main__.load_project_config") + @mock.patch("goga.build.__main__.build", return_value=0) + def test_main_base_ref_absent_defaults_none(self, mock_build, mock_config, tmp_path, monkeypatch) -> None: + # Key present, value None — the tri-state survives to the resolver, + # which then falls through to build.review_executor.base_ref. + monkeypatch.chdir(tmp_path) + _write_goga_yml(tmp_path) + + with ( + mock.patch.dict(os.environ, {"GOGA_DOCKER": "1"}), + mock.patch("sys.argv", ["goga.build", "plan.md", "--skip-manifest-check"]), + ): + main() + + cli_options = mock_build.call_args[0][2] + assert "base_ref" in cli_options + assert cli_options["base_ref"] is None + @mock.patch("goga.build.__main__.load_project_config") @mock.patch("goga.build.__main__.build", return_value=0) def test_main_idle_timeout_flag(self, mock_build, mock_config, tmp_path, monkeypatch) -> None: From 37c6277b155425a4538f774fc4b630c9124890d8 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 13:42:19 +0000 Subject: [PATCH 040/229] feat: forward host --base-ref option into the build container --- goga/commands/build/build.py | 10 ++++- .../build/test_build_proxy_hosts_update.py | 4 +- tests/commands/conftest.py | 4 +- tests/commands/test_build.py | 39 ++++++++++++++++++- 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/goga/commands/build/build.py b/goga/commands/build/build.py index 5356a474..9262a0aa 100644 --- a/goga/commands/build/build.py +++ b/goga/commands/build/build.py @@ -131,7 +131,7 @@ def _cli_flags_to_args(cli_flags: dict[str, bool | str | int | None]) -> list[st elif sr is False: args.append("--no-skip-review") - for flag in ("session_timeout", "idle_timeout", "wait", "max_iterations", "review_patience"): + for flag in ("session_timeout", "idle_timeout", "wait", "max_iterations", "review_patience", "base_ref"): val = cli_flags.get(flag) if val is not None: args.extend([f"--{flag.replace('_', '-')}", str(val)]) @@ -220,6 +220,12 @@ def _cleanup_ralphex_in_project(project_dir: Path) -> None: @click.option("--wait", type=str, default=None, help="Wait time") @click.option("--max-iterations", type=int, default=None, help="Max iterations") @click.option("--review-patience", type=int, default=None, help="Review patience") +@click.option( + "--base-ref", + type=str, + default=None, + help="Review diff base (branch name or commit hash); overrides build.review_executor.base_ref", +) @click.option("-e", "--env", "extra_env", multiple=True, help="Pass env var to container (KEY=VALUE)") @click.option("--proxy", type=str, default=None, help="HTTP/HTTPS proxy URL; overrides config.build.proxy") @click.option( @@ -264,6 +270,7 @@ def build( # noqa: PLR0913, C901, PLR0915, PLR0912, PLR0917 wait: str | None, max_iterations: int | None, review_patience: int | None, + base_ref: str | None, extra_env: tuple[str, ...], proxy: str | None, add_host: tuple[str, ...], @@ -348,6 +355,7 @@ def build( # noqa: PLR0913, C901, PLR0915, PLR0912, PLR0917 "wait": wait, "max_iterations": max_iterations, "review_patience": review_patience, + "base_ref": base_ref, "dry_run": dry_run, } diff --git a/tests/commands/build/test_build_proxy_hosts_update.py b/tests/commands/build/test_build_proxy_hosts_update.py index 18b0060c..41dc6706 100644 --- a/tests/commands/build/test_build_proxy_hosts_update.py +++ b/tests/commands/build/test_build_proxy_hosts_update.py @@ -86,9 +86,9 @@ def test_build_update_has_short_flag(self) -> None: update_param = next(p for p in build_cmd.params if p.name == "update") assert "-u" in update_param.opts - def test_build_fifteen_options(self) -> None: + def test_build_sixteen_options(self) -> None: options = [p for p in build_cmd.params if isinstance(p, click.Option)] - assert len(options) == 15 + assert len(options) == 16 def test_help_lists_new_options(self) -> None: runner = CliRunner() diff --git a/tests/commands/conftest.py b/tests/commands/conftest.py index 2557a486..4f4e5519 100644 --- a/tests/commands/conftest.py +++ b/tests/commands/conftest.py @@ -36,7 +36,9 @@ def full_config(tmp_path: Path) -> Path: " idle_timeout: '1h'\n" " wait: '5m'\n" " max_iterations: 10\n" - " review_patience: 3\n" + " review_executor:\n" + " base_ref: origin/1.2.x\n" + " patience: 3\n" " prompts_dir: /custom/prompts\n" " agents_dir: /custom/agents\n" " codex_review: true\n" diff --git a/tests/commands/test_build.py b/tests/commands/test_build.py index 9ffdaebc..191acc4d 100644 --- a/tests/commands/test_build.py +++ b/tests/commands/test_build.py @@ -77,9 +77,9 @@ def test_build_plan_is_required(self, tmp_path, monkeypatch) -> None: assert result.exit_code == 2 assert "Missing argument" in result.output - def test_build_has_fifteen_options(self) -> None: + def test_build_has_sixteen_options(self) -> None: options = [p for p in build_cmd.params if isinstance(p, click.Option)] - assert len(options) == 15 + assert len(options) == 16 def test_build_has_dry_run_option(self) -> None: param_names = [p.name for p in build_cmd.params] @@ -121,6 +121,10 @@ def test_build_has_review_patience_option(self) -> None: param_names = [p.name for p in build_cmd.params] assert "review_patience" in param_names + def test_build_has_base_ref_option(self) -> None: + param_names = [p.name for p in build_cmd.params] + assert "base_ref" in param_names + def test_build_has_skip_review_option(self) -> None: param_names = [p.name for p in build_cmd.params] assert "skip_review" in param_names @@ -146,6 +150,7 @@ def test_help_contains_all_options(self) -> None: "--wait", "--max-iterations", "--review-patience", + "--base-ref", "--skip-review", "--no-skip-review", "-e", @@ -608,6 +613,36 @@ def test_skip_finalize_forwarded(self, mock_env, mock_docker, tmp_path, monkeypa assert "--skip-finalize" in args +# --- _cli_flags_to_args base_ref forwarding tests --- + + +class TestCliFlagsToArgsBaseRef: + """--base-ref renders as a value-option pair; unset adds no token. + + The host performs no precedence resolution for the review diff base — + forwarding only. A set value yields the exact ``["--base-ref", REF]`` + tokens docker run hands to the container argv; unset (None) adds no + flag, leaving the decision to the container config. + """ + + def test_cli_flags_to_args_base_ref_set(self) -> None: + from goga.commands.build.build import _cli_flags_to_args + + args = _cli_flags_to_args({"base_ref": "origin/1.2.x", "dry_run": False}) + + assert "--base-ref" in args + assert "origin/1.2.x" in args + + def test_cli_flags_to_args_base_ref_unset_adds_no_flag(self) -> None: + from goga.commands.build.build import _cli_flags_to_args + + args = _cli_flags_to_args({"base_ref": None}) + + assert "--base-ref" not in args + # No bare token leaks: every rendered token belongs to another flag. + assert args == [] + + # --- Git config tests --- From 7f7ea80c408a1f5c4673053ee3840b32da413cfe Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 13:45:17 +0000 Subject: [PATCH 041/229] feat: dogfood review base bounds and document base_ref/patience --- .goga/config.yml | 5 +++++ docs/cli/build.md | 9 +++++++++ docs/configuration/project.md | 7 +++++-- docs/workflow/build.md | 4 ++-- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.goga/config.yml b/.goga/config.yml index 435011ed..df59a8fe 100644 --- a/.goga/config.yml +++ b/.goga/config.yml @@ -8,6 +8,9 @@ dockerfile: Dockerfile ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic" build: + session_timeout: 1h + idle_timeout: 15m + max_iterations: 10 task_executor: agent: claude env: @@ -15,6 +18,8 @@ build: ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" review_executor: agent: claude + base_ref: origin/1.2.x + patience: 3 env: <<: *claude-env ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3[1m]" diff --git a/docs/cli/build.md b/docs/cli/build.md index 380c88cf..73f3f771 100644 --- a/docs/cli/build.md +++ b/docs/cli/build.md @@ -42,6 +42,7 @@ The build pipeline performs these steps: | `--wait` | string | config | Wait time before starting | | `--max-iterations` | int | config | Maximum number of build iterations | | `--review-patience` | int | config | Review patience count | +| `--base-ref` | string | config | Review diff base (branch name or commit hash); overrides `build.review_executor.base_ref` | | `-e`, `--env` | string | -- | Additional environment variable (`KEY=VALUE`, repeatable) | | `--proxy` | string | config | HTTP/HTTPS proxy URL; overrides `build.proxy`. Adds `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` to the container env-file | | `--add-host` | string (repeatable) | -- | Add a `docker run --add-host HOST:IP` entry; merges on top of `build.hosts` (CLI wins on key conflict) | @@ -50,6 +51,8 @@ The build pipeline performs these steps: Timeout and iteration options fall back to values in `.goga/config.yml` when not provided on the command line. +`--review-patience` and `--base-ref` are review-scoped: they resolve with precedence CLI > `build.review_executor.*` in `.goga/config.yml` > omit, and they apply to review-carrying passes only — the single full-cycle pass, or the review pass of a two-pass run; a tasks-only run carries neither. The legacy `build.review_patience` key is not parsed (the setting moved to `build.review_executor.patience`). + ### Proxy and hosts `--proxy URL` (and `build.proxy` in `.goga/config.yml`) route the container's traffic through a corporate proxy. When a proxy is resolved, three variables are written to the container env-file: @@ -130,6 +133,12 @@ Skip the review phase (run tasks only): goga build plan.md --skip-review ``` +Review against a specific branch or commit instead of the detected default: + +```bash +goga build plan.md --base-ref origin/1.2.x +``` + Pull the latest image, then build (default skips the pull): ```bash diff --git a/docs/configuration/project.md b/docs/configuration/project.md index 3df6c369..a0093c7c 100644 --- a/docs/configuration/project.md +++ b/docs/configuration/project.md @@ -36,6 +36,8 @@ build: # roles: [quality, testing] # reviewer composition; absent/[] → full default set # env: # review-pass env layer (requires agent when non-empty) # ANTHROPIC_MODEL: reviewer + # base_ref: origin/1.2.x # review diff base — branch name or commit hash + # patience: 3 # stop the external review after N unchanged rounds # proxy: http://corp:3123 # optional HTTP/HTTPS proxy URL for the build container # hosts: # optional docker run --add-host entries # foo.local: 127.0.0.1 @@ -105,7 +107,6 @@ codemanifest: | `idle_timeout` | `string` | No | Idle timeout in Go duration format | | `wait` | `string` | No | Wait time on rate limit in Go duration format | | `max_iterations` | `int` | No | Maximum task iterations | -| `review_patience` | `int` | No | Stop review after N unchanged rounds | | `prompts_dir` | `string` | No | Path to custom ralph-loop prompts | | `agents_dir` | `string` | No | Path to custom ralph-loop agents | | `codex_review` | `bool` | No | Enable external codex review | @@ -132,6 +133,8 @@ Optional section controlling the review phase of `goga build`. When absent, the | `agent` | `string` | No | Review executor agent name (same resolution mechanic as `build.task_executor.agent`; its wrapper must exist in the image). When it differs from `task_executor.agent`, **or when a non-empty `env` is declared alongside it**, the build runs two passes: tasks with the task wrapper, then the review pass with the review wrapper. Combining either two-pass form with an active worktree (`--worktree` or `build.worktree: true`) is rejected with exit 1 on the host | | `roles` | list of `string` | No | Reviewer composition for the review prompts: keeps only the `{{agent:X}}` lines of the selected roles and adapts the counters of the accompanying text. Whitelist: `quality`, `implementation`, `testing`, `simplification`, `documentation`. Absent or `[]` means the full default set (prompts stay byte-identical to the vendored defaults) | | `env` | mapping of `string` | No | Review-pass environment layer (`{str: str}`). Keys overlay same-named container variables for the review-pass subprocess only — the tasks pass and the container env-file are unaffected, and the values never reach logs or dry-run output. Absent/YAML-null/`{}` all resolve to `{}` (unlike `build.task_executor.env`, where YAML-null is an error). A non-empty `env` induces a two-pass run like a differing agent does, and requires `agent` — a non-empty `env` without `agent` fails in-container validation when the review phase runs; a skipped run ignores the layer entirely | +| `base_ref` | `string` | No | Review diff base — a branch name or commit hash, stored verbatim (no resolvability or format check; ralphex owns the diagnostics). Overrides ralphex's default-branch detection on review-carrying passes. Absent/YAML-null/empty/whitespace resolves to `None`. Overridden by the `--base-ref` CLI option | +| `patience` | `int` | No | Stop the external review after N consecutive unchanged rounds. Absent/YAML-null resolves to `None`; a YAML boolean is rejected. Moved from `build.review_patience`, which is no longer parsed. Overridden by the `--review-patience` CLI option | Precedence: the `--skip-review`/`--no-skip-review` CLI pair overrides `skip`; an explicit `--no-skip-review` forces the full cycle even when the config sets `skip: true`. Role names, the env-requires-agent rule, and the review wrapper are validated in-container before any pass runs — but only when the review phase will actually run (a skipped run never validates them). @@ -195,7 +198,7 @@ The config loader raises specific exceptions for invalid configuration: |-------|-------| | `FileNotFoundError` | `.goga/config.yml` does not exist or is empty | | `KeyError` | Missing required field (`language`, or `build.task_executor` when `build` is present) | -| `ValueError` | Invalid field value (wrong type, empty string, non-mapping where mapping expected), or the deprecated `build.image` field is present. `build.review_executor` adds: non-mapping section (`build.review_executor must be a mapping`), non-bool `skip` (a YAML `1` is rejected), non-string `agent`, `roles` that is not a list of strings, a non-mapping `env` (`build.review_executor.env must be a mapping in .goga/config.yml`), or `env` with non-string keys/values (`build.review_executor.env must have string keys and values`) | +| `ValueError` | Invalid field value (wrong type, empty string, non-mapping where mapping expected), or the deprecated `build.image` field is present. `build.review_executor` adds: non-mapping section (`build.review_executor must be a mapping`), non-bool `skip` (a YAML `1` is rejected), non-string `agent`, `roles` that is not a list of strings, a non-mapping `env` (`build.review_executor.env must be a mapping in .goga/config.yml`), `env` with non-string keys/values (`build.review_executor.env must have string keys and values`), a non-string `base_ref` (`build.review_executor.base_ref must be a string in .goga/config.yml`), or a non-int `patience`, including a YAML boolean (`build.review_executor.patience must be an int in .goga/config.yml`) | ## Implementation details diff --git a/docs/workflow/build.md b/docs/workflow/build.md index 1acffddd..271bd142 100644 --- a/docs/workflow/build.md +++ b/docs/workflow/build.md @@ -24,14 +24,14 @@ Implemented code in the project tree, produced by the ralph-loop executing each | 4. Project preconditions | host → in-container | Resolve proxy (CLI `--proxy` wins over `config.build.proxy`); resolve hosts (CLI `--add-host` merges on top of `config.build.hosts`, CLI wins on conflict); when `--skip-manifest-check` is not set, scan `git status` for uncommitted `CODEMANIFEST` files and reject with exit 1 if any are found. | | 5. Agent preconditions | host → in-container | The in-container entrypoint resolves the agent wrapper via `resolve_wrapper_path(config.build.task_executor.agent)` and writes `.ralphex/config` per pass with `claude_command` set to the pass's wrapper path (`/home/goga/bin/<agent>-as-claude.sh`), `claude_args` defaults when missing, `codex_enabled` from `BuildConfig`, `preserve_anthropic_api_key: true`, and `move_plan_on_completion: false` (goga owns the plan relocation itself). A review executor whose agent differs from the task executor, or that declares a non-empty `env`, combined with an active worktree is rejected on the host with exit 1 before any container launch (skip-independent — `--skip-review` does not bypass it). | | 6. Defaults sync | in-container | Fully rewrite `.ralphex/prompts/` and `.ralphex/agents/` (stale files removed) from the configured `build.prompts_dir`/`build.agents_dir`, or from the vendored ralph-loop defaults shipped with goga (`goga/assets/ralphex/`). When `build.review_executor.roles` is a non-empty list, both review prompts are filtered to the selected roles and their counters adapted; with the full role set or no roles the files are byte-identical to the source. Custom directories are copied as-is, without filtering. | -| 7. ralph-loop option resolution | in-container | Resolve ralph-loop options with precedence CLI options > `BuildConfig` > omit — `worktree`, `skip_finalize`, `session_timeout`, `idle_timeout`, `wait`, `max_iterations`, `review_patience`. The resolved options are forwarded to `run_ralphex`; the launcher does not resolve precedence itself. | +| 7. ralph-loop option resolution | in-container | Two zones. Universal options resolve once in `_resolve_options` with precedence CLI options > `BuildConfig` > omit — `worktree`, `skip_finalize`, `session_timeout`, `idle_timeout`, `wait`, `max_iterations` — and join every pass. Review-scoped options — `base_ref`, `patience` — resolve in `resolve_review_options` with precedence CLI options > `ReviewExecutorConfig` > omit and join review-carrying passes only. The resolved options are forwarded to `run_ralphex`; the launcher does not resolve precedence itself. | | 8. Image refresh (optional, `--update`/`-u`) | host | When set, refresh the image via `docker_update`: build when a top-level `dockerfile` is declared in `.goga/config.yml` (fatal on failure — exit 1), otherwise `docker pull <image>` (warning on failure, non-fatal — the build proceeds with the locally available image). Off by default. | | 8b. First-run safety net | host | Runs unconditionally at launch entry via `docker_build_if_not_exist`: when `config.image` is absent locally AND `config.dockerfile` is declared, build it once before launch (fatal on failure — surfaces as a `ClickException`, launch skipped). No-op when the image is present or no Dockerfile is set. This closes the corner case where a project Dockerfile is declared but the image was never built and `--update` is not passed. | | 9. Persistent ralph-loop runtime isolation | host | Bind-mount a centralized host directory at `/workspace/.ralphex` inside the container so ralph-loop state never lands in the user's project directory. The host directory is `~/.goga/runtime/builds/<normalized_project>/<branch>/` and survives across runs of the same project on the same branch (useful for resuming interrupted builds). Pass `-c`/`--clean` to wipe it before launch. Any `.ralphex/` Docker creates in the project directory is removed unconditionally on every exit path, including crash/SIGKILL. | | 10. Docker launch | host | Launch the ralph-loop inside the configured Docker image using the in-container build entry point. A SIGTERM/SIGINT handler is installed before the secret env-file is written so a signal during setup unwinds to `finally` and unlinks the file. | | 10a. Pre-launch version check | host | Inside the Docker launch, before the work container starts: one short-lived probe container (`docker run --rm --entrypoint python3 <image> -c "from importlib.metadata import version; print(version('goga'))"`, output captured) reports the image's goga version, compared with the host version at the (major, minor) level. A mismatch, a probe that cannot answer, or an undeterminable host version → one stderr message + exit 1, container not started. An image reporting `0.0.0` (locally built, no stamped version) → stderr warning, launch continues. Set `GOGA_SKIP_VERSION_CHECK=1` to skip the probe and the comparison entirely. | | 11. Docker guard | in-container | The in-container entrypoint refuses to proceed outside the goga Docker image as its very first action. | -| 12. ralph-loop launch via `run_ralphex` | in-container | `build()` resolves the review options (`--skip-review`/`--no-skip-review` CLI pair > `build.review_executor.skip` > full cycle), validates them when the review phase will run, then delegates each pass to `run_ralphex` (goga/ralphex) with `plan`, the pass's options, `dry_run`, and — for the review pass of a two-pass run — the review env layer. One pass by default; a skipped review runs a single tasks-only pass (`ralphex --tasks-only`); a review executor agent differing from the task agent, or a non-empty `build.review_executor.env` (with an agent set), runs two passes — tasks with the task wrapper, then the review pass (`ralphex --review`) with the review wrapper and the review env overlaid on the container environment for that subprocess only (a failed first pass skips the second). `run_ralphex` maps options to ralphex CLI flags, verifies the `ralphex` binary is on `$PATH`, and propagates the subprocess exit code. A binary missing from `$PATH` — including when the env layer's `PATH` override hides it from the exec — or a launch rejected before the exec (an env-layer key that is not a legal environment variable name, an oversized layer, or a `PATH` override resolving a non-executable or non-directory ralphex binary) returns 1 with a clean one-line message on stderr — never a traceback, and never the env layer's values. The build environment is inherited from the docker env-file (`os.environ`) with an optional caller-supplied overlay for the review subprocess — never reconstructed from a config object. | +| 12. ralph-loop launch via `run_ralphex` | in-container | `build()` resolves the review options (`--skip-review`/`--no-skip-review` CLI pair > `build.review_executor.skip` > full cycle), validates them when the review phase will run, then delegates each pass to `run_ralphex` (goga/ralphex) with `plan`, the pass's options, `dry_run`, and — for the review pass of a two-pass run — the review env layer. One pass by default; a skipped review runs a single tasks-only pass (`ralphex --tasks-only`); a review executor agent differing from the task agent, or a non-empty `build.review_executor.env` (with an agent set), runs two passes — tasks with the task wrapper, then the review pass (`ralphex --review`) with the review wrapper and the review env overlaid on the container environment for that subprocess only (a failed first pass skips the second). The review-carrying pass — the single full-cycle pass or the review pass of a two-pass run — also carries the review-scoped options resolved in step 7 (`base_ref` → ralphex `--base-ref`, `patience` → ralphex `--review-patience`); a tasks-only pass carries the universal options only. `run_ralphex` maps options to ralphex CLI flags, verifies the `ralphex` binary is on `$PATH`, and propagates the subprocess exit code. A binary missing from `$PATH` — including when the env layer's `PATH` override hides it from the exec — or a launch rejected before the exec (an env-layer key that is not a legal environment variable name, an oversized layer, or a `PATH` override resolving a non-executable or non-directory ralphex binary) returns 1 with a clean one-line message on stderr — never a traceback, and never the env layer's values. The build environment is inherited from the docker env-file (`os.environ`) with an optional caller-supplied overlay for the review subprocess — never reconstructed from a config object. | | 13. Plan relocation | in-container | After a successful final pass the plan file moves to `<plan_dir>/completed/<plan_name>` (atomic replace, idempotent by name). A failed run or a dry run leaves the plan in place for the ralph-loop to resume. | Inside the container, the ralph-loop executes the plan: one task per iteration, following the ralphex execution protocol (declaration → contract tests → implementation → interface verification → logic tests → debugging → contract re-verification → lint → completion → review → approval → next task). From a3c9fdface645dd95df09d1904ff9d4405c7e065 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 13:49:03 +0000 Subject: [PATCH 042/229] feat: add base_ref end-to-end integration tests --- tests/integration/test_base_ref_end_to_end.py | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 tests/integration/test_base_ref_end_to_end.py diff --git a/tests/integration/test_base_ref_end_to_end.py b/tests/integration/test_base_ref_end_to_end.py new file mode 100644 index 00000000..2750c952 --- /dev/null +++ b/tests/integration/test_base_ref_end_to_end.py @@ -0,0 +1,238 @@ +"""End-to-end integration tests for the review-scoped ``base_ref`` option. + +These stitch together the cross-cell path introduced by the +``add-ref-for-review`` change: + + host container + goga/commands/build (click value goga/build/__main__ (argparse value + option --base-ref) option --base-ref) + -> cli_flags -> docker run args -> cli_options["base_ref"] + -> resolve_review_options step 6-7 + .goga/config.yml build.review_executor (CLI > review_executor > omit) + -> load_project_config (loader step 7) + -> ReviewOptions.base_ref/.patience + -> _review_scoped_options -> pass composition (review-carrying + passes only) -> run_ralphex options keys base_ref / + review_patience -> ralphex flags --base-ref / --review-patience + +Three seams only hold end-to-end and are verified here: the value survives the +host->container handoff as the exact docker-run token pair and is parsed back +by the real in-container argparse wiring; an unset option forwards no token and +still lands as a present-but-None ``cli_options`` key (the tri-state that lets +the resolver defer to the config); and a config-declared review base reaches +the ralphex argv of the review pass only — never the tasks pass. + +Mocks live only on the external boundaries per the project conventions: the +DockerRunner (docker binary), ``run_ralphex`` (ralphex binary), the vendored +defaults constants (maintainers' artifact), ``resolve_wrapper_path`` inside the +validator (existence check), and the host's docker/git subprocess helpers. +""" + +from __future__ import annotations + +import sys +from contextlib import contextmanager +from pathlib import Path +from unittest import mock + +import yaml +from click.testing import CliRunner +from goga.build.__main__ import main as container_main +from goga.build.build import build +from goga.commands import build as build_cmd +from goga.config import load_project_config +from goga.ralphex.run_ralphex import _build_command + +# goga.commands.build.build is shadowed by the function re-exported on the +# package __init__, so the real module is resolved via sys.modules and patched +# by attribute (per [[feedback_mock_patch_module_shadowing]]). +_build_cmd_mod = sys.modules["goga.commands.build.build"] + +_ROLES = ("quality", "implementation", "testing", "simplification", "documentation") + +# Synthetic stand-ins for the vendored ralphex v1.6.1 review prompts. The real +# assets are a maintainers' artifact; tests never depend on it. These tests do +# not exercise role filtering (see test_skip_review_end_to_end.py), so only the +# counter fragments the rewrite touches are carried. +_REVIEW_FIRST_TEMPLATE = ( + "# first review prompt\n" + "launches 5 parallel reviewer agents\n" + "Launch ALL 5 Review Agents\n" + "All 5 agent invocations\n" + "".join(f"{{{{agent:{role}}}}}\n" for role in _ROLES) + "until ALL 5 agents\n" +) + +_REVIEW_SECOND_TEMPLATE = ( + "# second review prompt\n" + "uses 2 agents\n" + "Both agent invocations\n" + "{{agent:quality}}\n" + "{{agent:implementation}}\n" + "until both complete\n" + "until BOTH agents\n" + "emit them both in one response\n" +) + + +def _write_goga_yml(tmp_path: Path, review_executor: dict | None = None) -> None: + """Materialize a .goga/config.yml with the optional build.review_executor section.""" + build_section: dict = {"task_executor": {"agent": "claude"}} + + if review_executor is not None: + build_section["review_executor"] = review_executor + + data = { + "language": "python", + "image": "goga:latest", + "build": build_section, + "pipeline": {"agent": "claude"}, + } + goga_dir = tmp_path / ".goga" + goga_dir.mkdir(parents=True, exist_ok=True) + (goga_dir / "config.yml").write_text(yaml.dump(data)) + + +@contextmanager +def _mock_vendored_sources(tmp_path: Path): + """Point the vendored ralphex defaults at synthetic tmp sources (external boundary).""" + from goga.build import ralphex_runtime + + prompts_dir = tmp_path / "vendored-prompts" + agents_dir = tmp_path / "vendored-agents" + prompts_dir.mkdir(parents=True, exist_ok=True) + agents_dir.mkdir(parents=True, exist_ok=True) + (prompts_dir / "task.txt").write_text("# task prompt\n") + (prompts_dir / "codex.txt").write_text("# codex review prompt\n") + (prompts_dir / "review_first.txt").write_text(_REVIEW_FIRST_TEMPLATE) + (prompts_dir / "review_second.txt").write_text(_REVIEW_SECOND_TEMPLATE) + for role in _ROLES: + (agents_dir / f"{role}.txt").write_text(f"# {role} agent definition\n") + + with ( + mock.patch.object(ralphex_runtime, "_VENDORED_PROMPTS", prompts_dir), + mock.patch.object(ralphex_runtime, "_VENDORED_AGENTS", agents_dir), + ): + yield + + +class TestBaseRefSurvivesHostToContainer: + """The review diff base reaches cli_options["base_ref"] undistorted. + + The host click value option (``--base-ref``, default None) is forwarded as + the exact token pair into the docker run args; the container argparse value + option parses those same tokens back into one dest. Any lossy conversion on + either side (the host resolving None against the config, or the token pair + being dropped in ``_cli_flags_to_args``) would break the CLI > + ``build.review_executor.*`` > omit precedence that lives in + ``resolve_review_options``. + """ + + def test_base_ref_survives_host_to_container(self, tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + _write_goga_yml(tmp_path) + + runner = CliRunner() + with ( + mock.patch.object(_build_cmd_mod, "_check_docker", return_value=True), + mock.patch.object(_build_cmd_mod, "_write_env_file", return_value=Path("/tmp/env")), + mock.patch.object(_build_cmd_mod, "DockerRunner") as mock_runner, + ): + mock_runner.return_value.run.return_value = 0 + runner.invoke(build_cmd, ["plan.md", "--base-ref", "origin/1.2.x"]) + + # The exact tokens handed to docker run form the in-container argv; with + # no other option set, --base-ref and its value are the whole tail. + container_args = mock_runner.return_value.run.call_args.args[0] + forwarded = container_args[container_args.index("plan.md") + 1 :] + assert forwarded == ["--base-ref", "origin/1.2.x"] + + # The container parses those same tokens with its real argparse wiring; + # only the dispatch target is mocked to capture cli_options. + monkeypatch.setenv("GOGA_DOCKER", "1") + monkeypatch.setattr(sys, "argv", ["goga.build", "plan.md", *forwarded]) + with ( + mock.patch("goga.build.__main__.build", return_value=0) as mock_build, + mock.patch("goga.build.__main__.load_project_config"), + ): + container_main() + + assert mock_build.call_args[0][2]["base_ref"] == "origin/1.2.x" + + def test_base_ref_unset_forwards_no_token(self, tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + _write_goga_yml(tmp_path) + + runner = CliRunner() + with ( + mock.patch.object(_build_cmd_mod, "_check_docker", return_value=True), + mock.patch.object(_build_cmd_mod, "_write_env_file", return_value=Path("/tmp/env")), + mock.patch.object(_build_cmd_mod, "DockerRunner") as mock_runner, + ): + mock_runner.return_value.run.return_value = 0 + runner.invoke(build_cmd, ["plan.md"]) + + # An unset value option emits no token at all — the decision is left to + # the container config, not baked in as an empty value. + container_args = mock_runner.return_value.run.call_args.args[0] + forwarded = container_args[container_args.index("plan.md") + 1 :] + assert forwarded == [] + assert "--base-ref" not in container_args + + # The tri-state survives: the key is present in cli_options with value + # None, so the resolver falls through to build.review_executor.base_ref. + monkeypatch.setenv("GOGA_DOCKER", "1") + monkeypatch.setattr(sys, "argv", ["goga.build", "plan.md", *forwarded]) + with ( + mock.patch("goga.build.__main__.build", return_value=0) as mock_build, + mock.patch("goga.build.__main__.load_project_config"), + ): + container_main() + + cli_options = mock_build.call_args[0][2] + assert "base_ref" in cli_options + assert cli_options["base_ref"] is None + + +class TestConfigBaseReachesRalphexFlag: + """A config-declared review base reaches the ralphex argv of the review pass only. + + Loader (step 7) -> resolve_review_options (steps 6-7) -> pass composition + (review-scoped fragment joined onto the review-carrying pass only) -> + ``_build_command`` mapping the composed option keys to the ralphex flags. + """ + + def test_config_base_reach_ralphex_flag_on_review_pass(self, tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + _write_goga_yml( + tmp_path, + review_executor={"agent": "codex", "base_ref": "origin/1.2.x", "patience": 3}, + ) + Path("plan.md").write_text("# plan\n") + review_wrapper = tmp_path / "codex-as-claude.sh" + review_wrapper.write_text("#!/bin/sh\n") + + config = load_project_config() + + with ( + _mock_vendored_sources(tmp_path), + mock.patch("goga.build.review_config.resolve_wrapper_path", return_value=str(review_wrapper)), + mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run, + ): + result = build("plan.md", config, {"skip_manifest_check": True}) + + assert result == 0 + assert mock_run.call_count == 2 + + # The review-carrying pass alone carries the review-scoped flags: the + # options keys base_ref / review_patience map onto the ralphex value + # flags --base-ref / --review-patience. + second_cmd = _build_command("plan.md", mock_run.call_args_list[1].args[1]) + assert "--base-ref" in second_cmd + assert "origin/1.2.x" in second_cmd + assert "--review-patience" in second_cmd + assert "3" in second_cmd + + # The tasks pass carries the universal options only — a diff base on the + # task pass would scope the wrong phase of the run. + first_cmd = _build_command("plan.md", mock_run.call_args_list[0].args[1]) + assert "--base-ref" not in first_cmd + assert "--review-patience" not in first_cmd From 2ba67d65e0c407a6eb02449719db49af5defc65a Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 14:01:24 +0000 Subject: [PATCH 043/229] fix: address code review findings - scope dogfood review base to release/1.3.0 (the branch point) instead of origin/1.2.x, which dragged the whole 1.3.0 body into every review diff - cover CLI-sourced base_ref/review_patience through build() pass composition (config-override and no-section cases) - pin review_patience 0 argv omission and exact ralphex argv in the e2e test - drop the redundant section_present parametrize flag and bool re-assert - document --base-ref in docs/workflow/build.md options table and README review_executor enumeration --- .goga/config.yml | 2 +- README.md | 2 +- docs/workflow/build.md | 3 ++ tests/build/test_build.py | 37 +++++++++++++++++++ tests/config/test_loader.py | 22 +++++------ tests/integration/test_base_ref_end_to_end.py | 28 ++++++++++---- tests/ralphex/test_run_ralphex.py | 8 +++- 7 files changed, 78 insertions(+), 24 deletions(-) diff --git a/.goga/config.yml b/.goga/config.yml index df59a8fe..9d17339b 100644 --- a/.goga/config.yml +++ b/.goga/config.yml @@ -18,7 +18,7 @@ build: ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" review_executor: agent: claude - base_ref: origin/1.2.x + base_ref: release/1.3.0 patience: 3 env: <<: *claude-env diff --git a/README.md b/README.md index 436f6a40..40921364 100644 --- a/README.md +++ b/README.md @@ -591,7 +591,7 @@ goga build plan.md -e ENV_VAR=value # forward an extra env var into the co goga build plan.md --skip-review # run tasks only, skip the review phase ``` -The review phase is configurable beyond the on/off flag: a `build.review_executor` section in `.goga/config.yml` can hand review to a different agent (`agent: codex` runs a second, review-only pass on the codex wrapper), skip it by default (`skip: true` — `--no-skip-review` forces the full cycle), select the reviewer composition (`roles: [quality, testing]`), and layer environment variables onto the review pass alone (`env: {ANTHROPIC_MODEL: reviewer}` — the variables overlay the container environment for the review subprocess only; the tasks pass never sees them, the values never reach logs or dry-run output, and like a differing agent a non-empty `env` forces a two-pass run, so it cannot be combined with a worktree). After a successful run the plan file itself moves to `completed/` inside its own topic directory (`.goga/history/<year>/<topic>/completed/`). +The review phase is configurable beyond the on/off flag: a `build.review_executor` section in `.goga/config.yml` can hand review to a different agent (`agent: codex` runs a second, review-only pass on the codex wrapper), skip it by default (`skip: true` — `--no-skip-review` forces the full cycle), select the reviewer composition (`roles: [quality, testing]`), layer environment variables onto the review pass alone (`env: {ANTHROPIC_MODEL: reviewer}` — the variables overlay the container environment for the review subprocess only; the tasks pass never sees them, the values never reach logs or dry-run output, and like a differing agent a non-empty `env` forces a two-pass run, so it cannot be combined with a worktree), bound the review diff to an explicit base (`base_ref: origin/main` — a branch name or commit hash that overrides ralphex's default-branch detection; `--base-ref` on the command line wins), and stop the external review after N unchanged rounds (`patience: 3`, or `--review-patience` — the setting moved from the top-level `build.review_patience` key, which is no longer parsed). Both review bounds apply to review-carrying passes only: the single full-cycle pass, or the review pass of a two-pass run. After a successful run the plan file itself moves to `completed/` inside its own topic directory (`.goga/history/<year>/<topic>/completed/`). A running build executes inside a Docker container, where its run-state and logs are written to a persistent host directory and survive across runs of the same project on the same branch — so an interrupted build can be resumed. Pass `--clean` (or `-c`) to wipe that state before launch for a fresh run. After the build, test the implementation manually. diff --git a/docs/workflow/build.md b/docs/workflow/build.md index 271bd142..753d7c5f 100644 --- a/docs/workflow/build.md +++ b/docs/workflow/build.md @@ -62,6 +62,7 @@ Inside the container, the ralph-loop executes the plan: one task per iteration, | `--wait` | string | config | Wait time before starting | | `--max-iterations` | int | config | Maximum number of build iterations | | `--review-patience` | int | config | Review patience count | +| `--base-ref` | string | config | Review diff base (branch name or commit hash); overrides `build.review_executor.base_ref` | | `-e`, `--env` | string | -- | Additional environment variable (`KEY=VALUE`, repeatable). Forwarded into the container env-file. | | `--proxy` | string | config | HTTP/HTTPS proxy URL; overrides `build.proxy` in `.goga/config.yml`. When set, adds `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` to the container env-file. | | `--add-host` | string (repeatable) | -- | Add a `docker run --add-host HOST:IP` entry. Merges on top of `build.hosts` from config; CLI wins on host-key conflict. | @@ -70,6 +71,8 @@ Inside the container, the ralph-loop executes the plan: one task per iteration, Timeout and iteration options fall back to `.goga/config.yml` when not provided on the command line. +`--review-patience` and `--base-ref` are review-scoped: they resolve with precedence CLI > `build.review_executor.*` in `.goga/config.yml` > omit, and they apply to review-carrying passes only — the single full-cycle pass, or the review pass of a two-pass run; a tasks-only run carries neither. The legacy `build.review_patience` key is not parsed (the setting moved to `build.review_executor.patience`). + ## Examples ```bash diff --git a/tests/build/test_build.py b/tests/build/test_build.py index 515f0772..debd9bc7 100644 --- a/tests/build/test_build.py +++ b/tests/build/test_build.py @@ -1233,6 +1233,43 @@ def test_two_pass_review_scoped_options_only_on_review_pass(self, tmp_path, monk assert second["review_patience"] == 3 assert second["review"] is True + def test_cli_scoped_options_override_config_on_review_pass(self, tmp_path, monkeypatch) -> None: + # The CLI source flows through the same composition: cli_options carry + # base_ref/review_patience, the config declares different values, and + # the CLI wins on the review-carrying (here: single full) pass. + config = _make_config( + review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/main", patience=3) + ) + with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + config=config, + cli_options={"skip_manifest_check": True, "base_ref": "origin/1.2.x", "review_patience": 7}, + ) + + assert result == 0 + assert mock_run.call_count == 1 + assert mock_run.call_args.args[1]["base_ref"] == "origin/1.2.x" + assert mock_run.call_args.args[1]["review_patience"] == 7 + + def test_cli_scoped_options_without_review_executor_section(self, tmp_path, monkeypatch) -> None: + # A minimal config with no review_executor section still honors + # CLI-sourced review bounds on the single full pass — the resolver + # must read the CLI source without gating it on the section. + with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + config=_make_config(), + cli_options={"skip_manifest_check": True, "base_ref": "origin/1.2.x", "review_patience": 4}, + ) + + assert result == 0 + assert mock_run.call_count == 1 + assert mock_run.call_args.args[1]["base_ref"] == "origin/1.2.x" + assert mock_run.call_args.args[1]["review_patience"] == 4 + def test_skip_run_omits_review_scoped_options(self, tmp_path, monkeypatch) -> None: # A skip run has no review phase of any kind: even with review bounds # declared, the single tasks-only pass carries universal options only. diff --git a/tests/config/test_loader.py b/tests/config/test_loader.py index 1f2469a4..473f147f 100644 --- a/tests/config/test_loader.py +++ b/tests/config/test_loader.py @@ -3415,7 +3415,6 @@ def test_review_executor_patience_int_parsed(self, goga_project): config = load_project_config() assert config.build.review_executor.patience == 3 assert isinstance(config.build.review_executor.patience, int) - assert not isinstance(config.build.review_executor.patience, bool) def test_review_executor_base_ref_non_string_raises(self, goga_project): """review_executor.base_ref: 12 → ValueError with the exact contract message.""" @@ -3508,14 +3507,14 @@ def test_review_executor_base_ref_unset_variants_resolve_none(self, goga_project assert config.build.review_executor.base_ref is None @pytest.mark.parametrize( - ("patience_snippet", "section_present"), - [("agent: claude\n", True), ("agent: claude\n patience: null\n", True), ("", False)], - ids=["absent", "yaml-null", "section-absent"], + "patience_snippet", + ["agent: claude\n", "agent: claude\n patience: null\n"], + ids=["absent", "yaml-null"], ) - def test_review_executor_patience_unset_variants_resolve_none( - self, goga_project, patience_snippet, section_present - ): - """Absent and YAML-null patience both resolve to None, as does an absent section.""" + def test_review_executor_patience_unset_variants_resolve_none(self, goga_project, patience_snippet): + """Absent and YAML-null patience both resolve to None. + + The absent-section variant is pinned by test_loader_review_executor_absent_and_null.""" _write_goga_yml( goga_project, f"""\ @@ -3527,11 +3526,8 @@ def test_review_executor_patience_unset_variants_resolve_none( {patience_snippet}""", ) config = load_project_config() - if section_present: - assert config.build.review_executor is not None - assert config.build.review_executor.patience is None - else: - assert config.build.review_executor is None + assert config.build.review_executor is not None + assert config.build.review_executor.patience is None @pytest.mark.parametrize( ("patience_literal", "patience_id"), diff --git a/tests/integration/test_base_ref_end_to_end.py b/tests/integration/test_base_ref_end_to_end.py index 2750c952..e65d41e9 100644 --- a/tests/integration/test_base_ref_end_to_end.py +++ b/tests/integration/test_base_ref_end_to_end.py @@ -224,15 +224,29 @@ def test_config_base_reach_ralphex_flag_on_review_pass(self, tmp_path: Path, mon # The review-carrying pass alone carries the review-scoped flags: the # options keys base_ref / review_patience map onto the ralphex value - # flags --base-ref / --review-patience. + # flags --base-ref / --review-patience. The full argv is pinned (not + # just flag membership) so a flag/value transposition or a stray token + # fails the test. second_cmd = _build_command("plan.md", mock_run.call_args_list[1].args[1]) - assert "--base-ref" in second_cmd - assert "origin/1.2.x" in second_cmd - assert "--review-patience" in second_cmd - assert "3" in second_cmd + assert second_cmd == [ + "ralphex", + "plan.md", + "--config-dir", + ".ralphex/", + "--review", + "--review-patience", + "3", + "--base-ref", + "origin/1.2.x", + ] # The tasks pass carries the universal options only — a diff base on the # task pass would scope the wrong phase of the run. first_cmd = _build_command("plan.md", mock_run.call_args_list[0].args[1]) - assert "--base-ref" not in first_cmd - assert "--review-patience" not in first_cmd + assert first_cmd == [ + "ralphex", + "plan.md", + "--config-dir", + ".ralphex/", + "--tasks-only", + ] diff --git a/tests/ralphex/test_run_ralphex.py b/tests/ralphex/test_run_ralphex.py index 78358080..d2a26e46 100644 --- a/tests/ralphex/test_run_ralphex.py +++ b/tests/ralphex/test_run_ralphex.py @@ -59,8 +59,12 @@ def test_scalar_option_emits_flag_with_value(self) -> None: assert "10" in cmd def test_scalar_option_zero_and_empty_omitted(self) -> None: - """Scalar values of None/""/0 are omitted (guards against 0==False regression).""" - assert _build_command("plan.md", {"max_iterations": 0, "session_timeout": ""}) == [ + """Scalar values of None/""/0 are omitted (guards against 0==False regression). + + review_patience 0 is the documented patience-unset case: the resolver + forwards a CLI/config 0 verbatim and the launcher drops it, so ralphex + runs its own default (0 = disabled).""" + assert _build_command("plan.md", {"max_iterations": 0, "session_timeout": "", "review_patience": 0}) == [ "ralphex", "plan.md", "--config-dir", From 8aa79a119b87bdf2de3a33920f98ef92f2799cd2 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 14:20:10 +0000 Subject: [PATCH 044/229] fix: align review-scoped docs and tests with implementation Acceptance findings from the contracts & coverage audit of the base_ref/patience functionality (08db50d..HEAD): - goga/build CODEMANIFEST: resolve_review_options algorithm step 6 now states the padded-value stripped-form rule the resolver implements (base_ref.strip() or None) and why the resolver, not the loader, owns it. - goga/ralphex run-ralphex usage: the shared options template carried base_ref, so the canonical two-pass example spread it onto the tasks-only pass - contradicting the file's own parameter note, the goga/build contract guarantee, and build.py, which keeps pass 1 universal-only. The template now holds universal options only and the two-pass block makes the split explicit. - tests: add a loader-level assertion that a padded config base_ref is stored stripped (the sibling test only covered unpadded input). No code changes - all three findings were documentation/test-side. --- goga/build/CODEMANIFEST | 5 ++++- goga/ralphex/.usages/run-ralphex.md | 12 +++++++----- tests/config/test_loader.py | 20 ++++++++++++++++++++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/goga/build/CODEMANIFEST b/goga/build/CODEMANIFEST index c7527afa..7723bd7c 100644 --- a/goga/build/CODEMANIFEST +++ b/goga/build/CODEMANIFEST @@ -219,7 +219,10 @@ Annotations: | two_pass true) 6. Resolve base_ref: take the cli_options base_ref when not None, otherwise the base_ref field of `ReviewExecutorConfig`, otherwise None; an empty - or whitespace-only value from either source resolves as unset (None) + or whitespace-only value from either source resolves as unset (None); + a padded value (non-empty with surrounding whitespace) resolves to its + stripped form — the CLI path and directly-constructed configs are not + loader-normalized 7. Resolve patience: take the cli_options review_patience when not None, otherwise the patience field of `ReviewExecutorConfig`, otherwise None diff --git a/goga/ralphex/.usages/run-ralphex.md b/goga/ralphex/.usages/run-ralphex.md index cbc4eea6..60cae8d6 100644 --- a/goga/ralphex/.usages/run-ralphex.md +++ b/goga/ralphex/.usages/run-ralphex.md @@ -16,11 +16,10 @@ the CLI/config precedence applied, generates the `.ralphex/config`, and only the from goga.ralphex import run_ralphex plan = "docs/plans/my-plan.md" # resolved by the caller (goga/build) -options = { # resolved ralphex options (CLI > ProjectConfig > omit applied) +options = { # universal ralphex options (CLI > ProjectConfig > omit applied) "worktree": True, "max_iterations": 50, "session_timeout": "30m", - "base_ref": "origin/1.2.x", # → --base-ref (review diff base) "tasks_only": False, # True → --tasks-only (skip all review phases) "review": False, # True → --review (review-only pass) } @@ -29,13 +28,16 @@ dry_run = False exit_code = run_ralphex(plan, options, dry_run) ``` -Two-pass composition when the task executor and the review executor differ: +Two-pass composition when the task executor and the review executor differ. The +shared `options` dict holds the universal options only — the review-scoped keys +(`base_ref`, `review_patience`) join the review pass alone, never the tasks pass: ```python -# Pass 1 — tasks only (task wrapper in .ralphex/config claude_command) +# Pass 1 — tasks only (task wrapper in .ralphex/config claude_command). +# Universal options only: a review diff base here would scope the wrong phase. exit_code = run_ralphex(plan, {**options, "tasks_only": True}, dry_run) # Pass 2 (only on pass-1 success) — review only (review wrapper rewritten -# into claude_command) +# into claude_command), carrying the review-scoped options if exit_code == 0: exit_code = run_ralphex(plan, {**options, "review": True}, dry_run) ``` diff --git a/tests/config/test_loader.py b/tests/config/test_loader.py index 473f147f..b426d29a 100644 --- a/tests/config/test_loader.py +++ b/tests/config/test_loader.py @@ -3399,6 +3399,26 @@ def test_review_executor_base_ref_parsed_verbatim(self, goga_project): assert config.build.review_executor.base_ref == "origin/1.2.x" assert isinstance(config.build.review_executor.base_ref, str) + def test_review_executor_base_ref_padded_stripped(self, goga_project): + """review_executor.base_ref with surrounding whitespace is stored stripped. + + Exact equality — an implementation that only nulls the whitespace-only + case without assigning the stripped value fails. + """ + _write_goga_yml( + goga_project, + """\ +language: python +build: + task_executor: + agent: claude + review_executor: + base_ref: " origin/1.2.x " +""", + ) + config = load_project_config() + assert config.build.review_executor.base_ref == "origin/1.2.x" + def test_review_executor_patience_int_parsed(self, goga_project): """review_executor.patience YAML int is stored verbatim as an int.""" _write_goga_yml( From 1ae445c6ba2d62d511cee91b333f3c1ad7f551e8 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 18:13:06 +0300 Subject: [PATCH 045/229] feat: build review config --- .goga/config.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.goga/config.yml b/.goga/config.yml index 9d17339b..5c019e76 100644 --- a/.goga/config.yml +++ b/.goga/config.yml @@ -8,9 +8,6 @@ dockerfile: Dockerfile ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic" build: - session_timeout: 1h - idle_timeout: 15m - max_iterations: 10 task_executor: agent: claude env: @@ -19,7 +16,7 @@ build: review_executor: agent: claude base_ref: release/1.3.0 - patience: 3 + patience: 2 env: <<: *claude-env ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3[1m]" From fda1859f891db5e816e28ac84af8c57a8c55833d Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 20:50:21 +0000 Subject: [PATCH 046/229] feat: add history cell architecture for topic paths and CLI Brainstorm output for the history-commands feature - contracts only, the implementation follows the compiled plan. - new cells: goga/history (single owner of the .goga/history/ tree: slug grammar, topic addressing, status model, traversal), goga/history/git (current-branch reader, re-exported on the history facade), and goga/commands/history (the `goga history` command group: list, status, path, ensure) - goga/commands/pipeline: normalize_topic_slug and resolve_current_branch_name move to goga/history and come back as imports; check_branch_occupancy drops the history_year parameter - the topic year is resolved inside the history oracle (topic_exists) - facades: goga/commands registers the history command (14 commands, history-command practice), the root app lists it in cli-commands - click practice: repeatable options (multiple=True) and NO_COLOR handling - pipeline-command usage points topic addressing at goga history path / goga history ensure --- .goga/usages/cooks/click.md | 19 + goga/CODEMANIFEST | 2 + goga/commands/.usages/cli-commands.md | 7 +- goga/commands/CODEMANIFEST | 10 + .../history/.usages/history-command.md | 77 ++++ goga/commands/history/CODEMANIFEST | 244 +++++++++++++ .../pipeline/.usages/pipeline-command.md | 3 +- goga/commands/pipeline/CODEMANIFEST | 92 ++--- goga/history/.usages/history-tree.md | 23 ++ goga/history/.usages/topic-paths.md | 96 +++++ goga/history/.usages/topic-statuses.md | 56 +++ goga/history/CODEMANIFEST | 335 ++++++++++++++++++ goga/history/git/CODEMANIFEST | 53 +++ 13 files changed, 951 insertions(+), 66 deletions(-) create mode 100644 goga/commands/history/.usages/history-command.md create mode 100644 goga/commands/history/CODEMANIFEST create mode 100644 goga/history/.usages/history-tree.md create mode 100644 goga/history/.usages/topic-paths.md create mode 100644 goga/history/.usages/topic-statuses.md create mode 100644 goga/history/CODEMANIFEST create mode 100644 goga/history/git/CODEMANIFEST diff --git a/.goga/usages/cooks/click.md b/.goga/usages/cooks/click.md index b6c80777..e3af65f6 100644 --- a/.goga/usages/cooks/click.md +++ b/.goga/usages/cooks/click.md @@ -84,6 +84,20 @@ app.add_command(command) - Use only for obvious positional data (paths, file names) - If the meaning of a parameter is not obvious — use `option` +### Option — repeatable parameters + +```python +@click.option('--status', '-s', multiple=True, help='Фильтр по статусу (повторяемый)') +def status(status: tuple[str, ...]) -> None: + for name in status: + ... +``` + +- An option with `multiple=True` may be passed several times; click collects the values into a tuple in passing order (`-s defined -s discovered` → `("defined", "discovered")`) +- The parameter value is a tuple; no passes yield an empty tuple `()`, not `None` +- Check "option not passed" by testing the tuple for emptiness, never against `None` +- A long and a short form on one option (`--status`/`-s`) behave like any other option + ## Passing State Between Commands To pass data from the root group to subcommands, use `ctx.obj`: @@ -162,6 +176,11 @@ sys.exit(1) - Output errors via `click.secho(..., err=True)` or `raise click.ClickException` - Do not use `print()` directly +## Colored Output and NO_COLOR + +- `click.echo` / `click.secho` strip ANSI codes themselves when the output stream is not a TTY (a pipe or redirect receives plain text) — no manual `isatty()` check is needed for that case +- click does NOT honor the `NO_COLOR` environment variable: check it explicitly and, when it is set to a non-empty value, do not pass a color to `secho` (the no-color.org convention: present and non-empty — color is disabled always, even in a TTY) + ## Testing CLI Click provides the `CliRunner` utility for testing: diff --git a/goga/CODEMANIFEST b/goga/CODEMANIFEST index a8baae1f..e8365e4c 100644 --- a/goga/CODEMANIFEST +++ b/goga/CODEMANIFEST @@ -13,6 +13,7 @@ Imports: - upgrade - install - uninstall + - history Usages: - cli-commands From: goga/commands @@ -80,6 +81,7 @@ app(): - `upgrade` - `install` - `uninstall` + - `history` --- diff --git a/goga/commands/.usages/cli-commands.md b/goga/commands/.usages/cli-commands.md index 1017028d..65be1dfd 100644 --- a/goga/commands/.usages/cli-commands.md +++ b/goga/commands/.usages/cli-commands.md @@ -1,6 +1,6 @@ # CLI Commands — goga/commands facade -The `goga.commands` package is a facade that re-exports 13 CLI commands. Each command is a `click.Command` registered in a click group. Each subcell is an independent Python package (`goga/commands/<name>/`) with implementation in `<name>.py` and re-export through `__init__.py`. +The `goga.commands` package is a facade that re-exports 14 CLI commands. Each command is a `click.Command` registered in a click group. Each subcell is an independent Python package (`goga/commands/<name>/`) with implementation in `<name>.py` and re-export through `__init__.py`. ## Import @@ -21,6 +21,7 @@ from goga.commands import ( upgrade, install, uninstall, + history, ) ``` @@ -40,6 +41,7 @@ from goga.commands.pipeline import pipeline from goga.commands.upgrade import upgrade from goga.commands.install import install from goga.commands.install import uninstall +from goga.commands.history import history ``` ## Registration in click group @@ -61,6 +63,7 @@ from goga.commands import ( upgrade, install, uninstall, + history, ) @@ -82,6 +85,7 @@ app.add_command(pipeline) app.add_command(upgrade) app.add_command(install) app.add_command(uninstall) +app.add_command(history) ``` ## Testing with CliRunner @@ -114,3 +118,4 @@ def test_example(): | `upgrade` | `goga/commands/upgrade/` | Upgrade goga and re-sync agents | | `install` | `goga/commands/install/` | Install a goga_tool_* package | | `uninstall` | `goga/commands/install/` | Remove a goga_tool_* package | +| `history` | `goga/commands/history/` | Work with the .goga/history/ tree | diff --git a/goga/commands/CODEMANIFEST b/goga/commands/CODEMANIFEST index 082b303f..21326692 100644 --- a/goga/commands/CODEMANIFEST +++ b/goga/commands/CODEMANIFEST @@ -41,6 +41,11 @@ Imports: - uninstall-usage - install AS install-usage From: goga/commands/install + - Types: + - history + Usages: + - history-command + From: goga/commands/history Usages: convention: .goga/usages/conventions.md @@ -63,6 +68,10 @@ Annotations: | Use the `install-usage` practice for consumer scenarios of the install command: the four modes, the post-install hooks, and the exit codes. + Use the `history-command` practice for consumer scenarios of the history + command group: the four subcommands, their options and filters, the path + and ensure behaviors, and the exit codes. + --- ->lint: {} @@ -78,6 +87,7 @@ Annotations: | ->upgrade: {} ->install: {} ->uninstall: {} +->history: {} --- diff --git a/goga/commands/history/.usages/history-command.md b/goga/commands/history/.usages/history-command.md new file mode 100644 index 00000000..c86ccd71 --- /dev/null +++ b/goga/commands/history/.usages/history-command.md @@ -0,0 +1,77 @@ +# history — goga history commands + +The `goga history` command group works with the `.goga/history/` tree from +the command line. For script authors (workflow scripts, skill prompts) and +operators. A topic value can always be given as a branch name +(`release/1.3.0`) or as a slug (`release-1-3-0`) — both address +`.goga/history/<year>/release-1-3-0/`. + +## goga history list + +Prints the inventory tree — every year with its topics. No statuses, no +artifacts. + + 2026/ + └── add-ref-for-review + └── history-commands + +- Read-only. An empty history prints nothing, exit 0. + +## goga history status [YEAR] [-t TOPIC] [-s STATUS]… + +Prints one `topic [status]` line per topic of the year — flat, no year, no +tree. + + release-1-3-0 [done] + history-commands [planned] + +- `YEAR` — optional positional, four digits; defaults to the current year. +- `-t/--topic` — substring filter; the value is normalized (a branch name + works as a filter too). A `--topic` value that normalizes to an empty slug + (fully non-ASCII) is an error, not a match-all. +- `-s/--status` — repeatable (`-s defined -s discovered`); combined with + `--topic` by AND. Valid names: `empty`, `defined`, `discovered`, + `backlog`, `designed`, `specified`, `planned`, `done`. An unknown name is + an error (non-zero exit). +- Topics come out alphabetically. An empty result prints nothing, exit 0. +- Statuses are colorized on a terminal; piped output is plain; `NO_COLOR` + disables color always. + +A topic's status is the deepest artifact present: `prd.md` → defined, +`adr.md` → discovered, `task.md` → backlog, `arch.md` → designed, +`design.md` → specified, `plan.md` → planned, `completed/plan.md` → done; +no artifacts → empty. + +## goga history path [TOPIC] [-f FILENAME] [-y YEAR] + +Prints one path — and nothing else — to stdout. Nothing is created. + + goga history path # topic dir of the current branch + goga history path -f plan.md # …/plan.md of the current branch + goga history path release/1.3.0 -f plan.md # explicit topic (branch name ok) + goga history path -y 2025 # another year + +- Without `TOPIC` the current git branch names the topic. No branch (not a + repository, detached HEAD, git missing) → clean error, non-zero exit. +- `-f/--file` — an artifact filename with an extension; without the flag the + topic directory is printed. A filename without an extension is an error. +- `-y/--year` — four digits; defaults to the current year. +- Scripting pattern: `plan=$(goga history path -f plan.md)`. + +## goga history ensure [NAME] + +Creates the topic directory of the current year — idempotently. + + goga history ensure # topic of the current branch + goga history ensure Feature/Foo_Bar # → .goga/history/<year>/feature-foo-bar + +- An existing topic directory is a success, not a conflict. Occupancy + checks belong to the caller. +- Prints nothing on stdout; the exit code carries the result. + +## Errors + +Every failure is a clean message on stderr with a non-zero exit and no +fallback values: git unavailable / not a repository / detached HEAD, a +topic that normalizes to an empty slug, a filename without an extension, an +unknown status name. diff --git a/goga/commands/history/CODEMANIFEST b/goga/commands/history/CODEMANIFEST new file mode 100644 index 00000000..a2df3c1e --- /dev/null +++ b/goga/commands/history/CODEMANIFEST @@ -0,0 +1,244 @@ +Imports: + - Types: + - HistoryYear + - TopicRecord + - TopicStatus + - collect_history_tree + - collect_topic_statuses + - ensure_topic_dir + - normalize_topic_slug + - resolve_current_branch_name + - resolve_topic_dir + - resolve_topic_file + Usages: + - topic-paths + - topic-statuses + - history-tree + From: goga/history + +Usages: + convention: .goga/usages/conventions.md + click: .goga/usages/cooks/click.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + Use the `click` practice to build the history command group: the group + decorator, the subcommand registration, the options and arguments of each + subcommand (the optional positional, the long/short option pairs, the + repeatable -s/--status option via multiple=True), echo, and exit-code + propagation. + + This cell is the CLI surface of the history domain: a thin wrapper that + resolves inputs, delegates every computation to the domain routines, and + renders the results. No path building, no slug grammar, no status + resolution, and no tree walking live here. Domain errors surface as clean + CLI errors (stderr, non-zero exit, no traceback) — no fallback topic names + and no silent skips. Use relative imports. + +--- + +"history()": + location: history.py + annotations: | + The goga history command group — a click.Group container for the history + subcommands. Exported via __all__ and registered in the root application + group. The group carries no options of its own — every subcommand owns + its arguments and options. + + Use the `click` practice for the group decorator and the subcommand + registration. + + Subcommand surfaces: + - list — no arguments, no options + - status — an optional YEAR positional, --topic/-t (substring filter), + -s/--status (repeatable status filter) + - path — an optional TOPIC positional, -f/--file FILENAME, --year/-y YYYY + - ensure — an optional NAME positional + + Apply the `convention` CLI command docstring rule for the --help text + (rendered verbatim by Click; omit Args/Returns/Raises). + methods: + "list() -> exit_code: int": | + Subcommand goga history list: print the tree of every history year with + its topics — the inventory view, no statuses and no artifacts. + + `exit_code`: 0 on success, 1 on error + + Apply the `history-tree` practice for the tree contract of the domain. + + Algorithm: + 1. Collect the full tree via `collect_history_tree` + 2. Render it via `render_history_tree` + 3. An empty tree renders nothing — exit 0 + + Requirements: + - Read-only — nothing is created or written + + Constraints: + - Do not print statuses or artifact names — the list view carries + years and topics only + + "status(year: str | None = None, topic: str | None = None, statuses: tuple[str, ...]) -> exit_code: int": | + Subcommand goga history status: print the flat list of topics of one + year, each line "topic [status]". + + `year`: optional YEAR positional — four digits; None means the current + year; the year is never printed + `topic`: --topic/-t value — a substring filter; the value is normalized + via `normalize_topic_slug` before matching + `statuses`: -s/--status values (repeatable, multiple=True) — status + names to keep; combined with `topic` by AND + `exit_code`: 0 on success (an empty result included), 1 on error + + Apply the `topic-statuses` practice for the record and status + contracts of the domain. + Apply the `click` practice for the repeatable -s/--status option and + the color rules. + + Algorithm: + 1. Validate every name in `statuses` against `TopicStatus` — an + unknown name is a clean error (stderr, non-zero exit) + 1.1. A `topic` value that normalizes to an empty slug is a clean error + (stderr, non-zero exit) — an empty filter would silently match + every topic and is rejected instead + 2. Collect the records via `collect_topic_statuses` with `year` + 3. Filter: when `topic` is given, keep the records whose topic contains + the normalized filter as a substring; when `statuses` is non-empty, + keep the records whose status is one of the resolved names; both + filters combine by AND + 4. Render the surviving records via `render_topic_statuses` + 5. An empty result renders nothing and exits 0 + + Requirements: + - Topics come out alphabetically — the domain sorts, the command does + not re-sort + - The year is never printed + - Color follows the `click` practice: ANSI only on a TTY, NO_COLOR + disables it always + - An empty normalized `topic` filter is an error, not a match-all — + consistent with the empty-slug policy of `resolve_topic_dir` + + Constraints: + - Do not print the year, a header, or a summary line — one record per + line only + - Do not treat an empty result as an error + + "path(topic: str | None = None, filename: str | None = None, year: str | None = None) -> exit_code: int": | + Subcommand goga history path: print one path of the history tree — and + nothing else. + + `topic`: optional TOPIC positional — a branch name or a slug; None + means the current git branch + `filename`: -f/--file value — an artifact filename with an extension; + without the flag the topic directory is printed + `year`: --year/-y value — four digits; None means the current year + `exit_code`: 0 on success, 1 on error + + Apply the `topic-paths` practice for the path contracts of the domain. + + Algorithm: + 1. Resolve `topic`: the positional when given, otherwise the current + branch via `resolve_current_branch_name`; an undetermined branch is + a clean error (stderr, non-zero exit) + 2. `filename` given -> resolve the file path via `resolve_topic_file`; + otherwise resolve the topic directory via `resolve_topic_dir` + 3. Echo the resolved path to stdout — exactly one line, nothing else + + Requirements: + - Prints the path and only the path — no decoration, no trailing text + - Nothing is created on disk + + Constraints: + - Do not create the topic directory or the file — this subcommand is + read-only + - Do not print anything besides the path on stdout + + "ensure(name: str | None = None) -> exit_code: int": | + Subcommand goga history ensure: create the topic directory of the + current year. + + `name`: optional NAME positional — a branch name or a slug; None means + the current git branch + `exit_code`: 0 on success, 1 on error + + Apply the `topic-paths` practice for the creation contract of the + domain. + + Algorithm: + 1. Resolve `name`: the positional when given, otherwise the current + branch via `resolve_current_branch_name`; an undetermined branch is + a clean error (stderr, non-zero exit) + 2. Create the directory via `ensure_topic_dir` — idempotently + 3. Exit 0 + + Requirements: + - An existing topic directory is a success, not a conflict + - Prints nothing on stdout — the exit code carries the result + + Constraints: + - Do not report occupancy — deciding whether a topic may be created + belongs to the caller + - Do not create artifact files inside the directory + +"render_history_tree(tree: list[HistoryYear])": + location: render.py + annotations: | + Render the history tree as the list-view output: one "YYYY/" header line + per year, each topic indented under its year with the tree marker. + + `tree`: the collected tree — years ascending, topics alphabetical + + Apply the `click` practice for echo. + + Algorithm: + 1. For each `HistoryYear` in `tree`, print the year followed by a slash + 2. Under it, print each topic on its own indented line prefixed with + the tree marker "└── " + 3. An empty `tree` prints nothing + + Requirements: + - The output shape is "YYYY/" then one indented topic line per topic — + no statuses, no artifacts + + Constraints: + - Read-only on `tree` — do not mutate it + - Do not compute anything — render what the collection already carries + +"render_topic_statuses(records: list[TopicRecord])": + location: render.py + annotations: | + Render the status view: one flat "topic [status]" line per record. + + `records`: the (already filtered) records to print + + Apply the `click` practice for echo, secho, and the color rules — ANSI + only on a TTY, NO_COLOR disables it always. + + Algorithm: + 1. For each `TopicRecord` in `records`, print the topic followed by the + bracketed status display name + 2. Colorize the status segment per the `click` practice; leave the topic + plain + 3. An empty `records` prints nothing + + Requirements: + - The status segment uses the display name of the record's status + - Empty input renders empty output — not an error + + Constraints: + - Read-only on `records` — do not mutate, do not re-sort, do not filter + - Do not print the year + +--- + +Author: Goga +CreatedAt: 28/08/26 +Description: | + The goga history command group with the list, status, path, and ensure + subcommands over the history domain. diff --git a/goga/commands/pipeline/.usages/pipeline-command.md b/goga/commands/pipeline/.usages/pipeline-command.md index cf63c720..3ec1a492 100644 --- a/goga/commands/pipeline/.usages/pipeline-command.md +++ b/goga/commands/pipeline/.usages/pipeline-command.md @@ -52,7 +52,8 @@ The entered name plays two roles: `.goga/history/<YYYY>/<slug>/`: lowercase, non-ASCII dropped, anything outside `[a-z0-9]` becomes `-`, repeat hyphens collapse, edge hyphens trim (`Feature/Foo_Bar` → `feature-foo-bar`, `release/1.3.0` → - `release-1-3-0`). + `release-1-3-0`). The same topic addressing is available directly via the + `goga history path` / `goga history ensure` commands. Occupancy = a local branch with the entered name, OR a remote-tracking branch with the entered name, OR an existing `.goga/history/<YYYY>/<slug>/` diff --git a/goga/commands/pipeline/CODEMANIFEST b/goga/commands/pipeline/CODEMANIFEST index 1d1e75fe..1bec44c7 100644 --- a/goga/commands/pipeline/CODEMANIFEST +++ b/goga/commands/pipeline/CODEMANIFEST @@ -1,4 +1,11 @@ Imports: + - Types: + - normalize_topic_slug + - resolve_current_branch_name + - topic_exists + Usages: + - topic-paths + From: goga/history - Types: - ProjectConfig - load_project_config @@ -111,12 +118,16 @@ Annotations: | The optional -b/--branch flag prepares a fresh branch and a fresh history topic before the run form launches: the entered name is normalized into the - topic slug, occupancy is checked against three oracles (a local branch, a - remote-tracking branch, the history topic folder), and a free name creates - the branch on the host and switches to it. The procedure runs after the - argument-form validation and before any docker activity. The listing and - info forms silently skip the whole branch procedure. - + topic slug via `normalize_topic_slug`, occupancy is checked against three + oracles (a local branch, a remote-tracking branch, the history topic folder + via `topic_exists`), and a free name creates the branch on the host and + switches to it. The procedure runs after the argument-form validation and + before any docker activity. The listing and info forms silently skip the + whole branch procedure. + + Use the `topic-paths` practice for the consumer patterns of the history + facade used by the branch procedure — the topic slug, the current branch, + and the topic-existence oracle. Use the `git` practice for every git invocation of the branch procedure — read-only inspection and the single create-and-switch mutation. Use the `click` practice for the -b/--branch option: a long form and a short @@ -276,68 +287,18 @@ Annotations: | - Do not pass the branch name into the container — the container sees the branch through the mounted project -"normalize_topic_slug(name: str) -> slug: str": - location: branch.py - annotations: | - Normalize a branch name into the history topic slug. - - `name`: branch name as entered by the user - `slug`: history topic slug - - Algorithm: - 1. Lowercase the name - 2. Drop every non-ASCII character (no transliteration) - 3. Replace each remaining character outside [a-z0-9] with a hyphen - 4. Collapse repeat hyphens into one - 5. Trim leading and trailing hyphens - - Requirements: - - The grammar matches the skill-side topic grammar: - "Feature/Foo_Bar" -> "feature-foo-bar"; "release/1.3.0" -> - "release-1-3-0"; "aБb" -> "ab" (non-ASCII dropped before hyphen - replacement); a fully non-ASCII name -> empty slug - - Deterministic — same input always produces the same output - - Pure string transformation — no git, no filesystem, no side effects - - Constraints: - - Do not transliterate Cyrillic or any other script - - Do not return a fallback for an empty result — an empty slug is a - valid output; the caller owns the empty-slug decision - -"resolve_current_branch_name() -> branch: str | None": - location: branch.py - annotations: | - Read the current git branch name exactly as git reports it. - - `branch`: raw current branch name, or None when it cannot be determined - - Apply the `git` practice for the invocation pattern. - - Algorithm: - 1. Ask git for the current branch name - 2. A non-empty answer -> return it stripped, unmodified - 3. Detached HEAD, missing git binary, or a non-repository -> None - - Requirements: - - Read-only — no branch switch, no writes, no caching - - No slugification and no fallback value — both belong to the caller - - Constraints: - - Do not tolerate unexpected OS-level failures silently — the None - result covers only the documented failure modes - -"check_branch_occupancy(branch_name: str, slug: str, history_year: str) -> conflict: str | None": +"check_branch_occupancy(branch_name: str, slug: str) -> conflict: str | None": location: branch.py annotations: | Decide whether the entered branch name and the topic slug are free. `branch_name`: branch name as entered (checked against git refs) `slug`: normalized topic slug (checked against the history folder) - `history_year`: current year as YYYY (the caller owns the clock) `conflict`: human-readable reason of the first occupied oracle, or None when everything is free Apply the `git` practice for the invocation pattern. + Apply the `topic-paths` practice for the topic-existence oracle contract. Apply the `convention` practice for docstring style and intra-package imports. @@ -345,8 +306,8 @@ Annotations: | 1. A local branch ref for `branch_name` exists -> return the reason 2. A remote-tracking ref for `branch_name` exists -> return the reason (local remote-tracking refs only — no network call) - 3. The history topic directory .goga/history/<history_year>/<slug> - exists as a directory -> return the reason (a stray file named + 3. `topic_exists` with `slug` returns True -> return the reason (the + topic directory of the current year is taken; a stray file named <slug> does not occupy a topic) 4. All three oracles are free -> None @@ -355,10 +316,10 @@ Annotations: | the slug — the two may deliberately differ - The first occupied oracle wins; remaining oracles are not probed - Read-only — no ref or folder is created + - No clock — the topic year is resolved inside the history oracle Constraints: - - Do not resolve remote state over the network — remote-tracking refs - only + - Do not resolve remote state over the network — remote-tracking refs only - Do not create the history folder or any ref here "ensure_pipeline_branch(branch_name: str) -> branch: str": @@ -373,6 +334,9 @@ Annotations: | Apply the `click` practice for click.prompt and exit-code propagation. Apply the `git` practice for every git invocation. + Apply the `topic-paths` practice for the consumer patterns of the history + facade — the topic slug, the current branch, and the topic-existence + oracle. Apply the `convention` practice for docstring style and intra-package imports. @@ -387,8 +351,8 @@ Annotations: | 3. The current branch is known and its slug equals the entered slug -> return the current branch name; no git action, no occupancy check (a branch does not conflict with itself) - 4. `check_branch_occupancy` with the entered name, the slug, and the - current year returns a reason -> conflict: + 4. `check_branch_occupancy` with the entered name and the slug returns + a reason -> conflict: - interactive terminal: print the reason, prompt for a new name, restart from step 1 with it - no terminal: print the reason and the hint to stderr, fail with a diff --git a/goga/history/.usages/history-tree.md b/goga/history/.usages/history-tree.md new file mode 100644 index 00000000..48c6a5f9 --- /dev/null +++ b/goga/history/.usages/history-tree.md @@ -0,0 +1,23 @@ +# history — year and topic inventory + +How to walk the whole `.goga/history/` tree with the `goga.history` facade. +For consumers that inventory history: CLI list output, audits, cleanups. + +## Collecting the full tree + +```python +from goga.history import collect_history_tree + +tree = collect_history_tree() +for year_record in tree: + print(year_record.year) # "2026" + for topic in year_record.topics: # sorted alphabetically + print(topic) +``` + +- One `HistoryYear` per year, sorted by year ascending; topics within a year + sorted alphabetically. +- A year directory is a directory named with exactly four digits; anything + else in the history root is ignored. Only directories count as topics. +- An absent history root yields an empty list — not an error. +- The tree carries names only: no statuses, no artifact lists. diff --git a/goga/history/.usages/topic-paths.md b/goga/history/.usages/topic-paths.md new file mode 100644 index 00000000..72afdaea --- /dev/null +++ b/goga/history/.usages/topic-paths.md @@ -0,0 +1,96 @@ +# history — topic paths and creation + +How to compute history topic paths and create topic directories with the +`goga.history` facade. For consumers that address the `.goga/history/` tree: +CLI commands, workflow scripts, and branch preparation. + +Every routine accepting a topic value normalizes it first: pass a branch name +(`release/1.3.0`) or an already-normalized slug (`release-1-3-0`) — both give +`.goga/history/<year>/release-1-3-0/`. An input that normalizes to an empty +slug raises a clean error; there is no fallback name. + +## Computing a topic directory + +```python +from goga.history import resolve_topic_dir + +topic_dir = resolve_topic_dir("Feature/Foo_Bar") # current year +# -> .goga/history/2026/feature-foo-bar + +topic_dir = resolve_topic_dir("release-1-3-0", year="2025") +# -> .goga/history/2025/release-1-3-0 +``` + +- Pure: nothing is created on disk. +- The year defaults to the current year (four digits, local time). + +## Computing an artifact file path + +```python +from goga.history import resolve_topic_file + +plan = resolve_topic_file("history-commands", "plan.md") +# -> .goga/history/2026/history-commands/plan.md +``` + +- The filename is arbitrary but must carry an extension (`plan.md`); + a name without one is a clean error. +- The file is neither created nor checked for existence — the artifact's + producer writes it. + +## Checking whether a topic exists + +```python +from goga.history import topic_exists + +if topic_exists("release-1-3-0"): + ... # topic already occupied this year +``` + +- True only when the topic path exists as a directory; a stray file with the + slug's name does not occupy a topic. +- Read-only. + +## Creating a topic directory + +```python +from goga.history import ensure_topic_dir + +topic_dir = ensure_topic_dir("Feature/Foo_Bar") +# -> .goga/history/2026/feature-foo-bar (now existing) +``` + +- Always for the current year. +- Idempotent: an existing topic directory is a success, not a conflict. + Decide occupancy *before* creating (via `topic_exists`) when the + distinction matters. + +## The slug grammar + +`normalize_topic_slug` applies: lowercase → drop non-ASCII → everything +outside `[a-z0-9]` becomes `-` → collapse repeats → trim edges. + +```python +from goga.history import normalize_topic_slug + +normalize_topic_slug("Feature/Foo_Bar") # "feature-foo-bar" +normalize_topic_slug("release/1.3.0") # "release-1-3-0" +normalize_topic_slug("aБb") # "ab" +``` + +- Pure and deterministic; no transliteration; a fully non-ASCII name yields + an empty string — the caller decides what an empty slug means. + +## Reading the current branch as a topic source + +```python +from goga.history import resolve_current_branch_name + +branch = resolve_current_branch_name() +if branch is None: + ... # not a git repository / detached HEAD / git missing +topic_dir = resolve_topic_dir(branch) +``` + +- Returns the raw branch name, or None when it cannot be determined; the + error policy belongs to the caller. diff --git a/goga/history/.usages/topic-statuses.md b/goga/history/.usages/topic-statuses.md new file mode 100644 index 00000000..25a2d2b1 --- /dev/null +++ b/goga/history/.usages/topic-statuses.md @@ -0,0 +1,56 @@ +# history — topic statuses + +How to read the status of history topics with the `goga.history` facade. For +consumers that report progress: CLI status output, reviews, dashboards. + +A topic's status is the process stage reached by its deepest present +artifact: + +| Status | Deepest artifact present | +|---|---| +| empty | none | +| defined | prd.md | +| discovered | adr.md | +| backlog | task.md | +| designed | arch.md | +| specified | design.md | +| planned | plan.md | +| done | completed/plan.md | + +## Listing a year with statuses + +```python +from goga.history import collect_topic_statuses + +records = collect_topic_statuses() # current year +records = collect_topic_statuses(year="2025") # explicit year +for record in records: + print(record.topic, record.status.value) +``` + +- One `TopicRecord` per topic, sorted alphabetically by topic. +- An absent year or a year without topics yields an empty list — not an error. +- Filtering (by status name or topic substring) belongs to the consumer: the + facade returns the full year. + +## Resolving one topic's status + +```python +from goga.history import resolve_topic_dir, resolve_topic_status + +status = resolve_topic_status(resolve_topic_dir("history-commands")) +``` + +- `completed/plan.md` wins over every flat artifact when present. +- Read-only. + +## Validating status names + +```python +from goga.history import TopicStatus + +TopicStatus("planned") # -> TopicStatus.planned; ValueError for unknown names +``` + +- Use this to validate user-supplied status filters before matching records: + the member set is fixed, and `record.status.value` carries the display name. diff --git a/goga/history/CODEMANIFEST b/goga/history/CODEMANIFEST new file mode 100644 index 00000000..6a58fe87 --- /dev/null +++ b/goga/history/CODEMANIFEST @@ -0,0 +1,335 @@ +Imports: + - Types: + - resolve_current_branch_name + From: goga/history/git + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell is the single owner of the .goga/history/ tree: topic identity (the + slug grammar and the current year), topic addressing (directory and artifact + file paths, existence, creation), the topic status model, and tree traversal. + Artifact files are written by their producers — this cell computes paths and + creates topic directories only; it never writes artifact content. Pure + filesystem and grammar logic: no git access (the branch reader lives in + goga/history/git and is re-exported on this facade), no CLI, no output + rendering. Every topic value received on the input is normalized — a branch + name and an already-normalized slug are both accepted, identically and + idempotently. Use relative imports. + +--- + +->resolve_current_branch_name: {} + +"normalize_topic_slug(name: str) -> slug: str": + location: naming.py + annotations: | + Normalize a topic input into the history topic slug. + + `name`: topic input as entered — a branch name or an already-normalized slug + `slug`: the history topic slug + + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. Lowercase the name + 2. Drop every non-ASCII character (no transliteration) + 3. Replace each remaining character outside [a-z0-9] with a hyphen + 4. Collapse repeat hyphens into one + 5. Trim leading and trailing hyphens + + Requirements: + - "Feature/Foo_Bar" -> "feature-foo-bar"; "release/1.3.0" -> + "release-1-3-0"; "aБb" -> "ab" (non-ASCII dropped before hyphen + replacement); a fully non-ASCII name -> empty slug + - Idempotent — an already-normalized slug stays itself + - Deterministic — same input always produces the same output + - Pure string transformation — no git, no filesystem, no side effects + + Constraints: + - Do not transliterate Cyrillic or any other script + - Do not return a fallback for an empty result — an empty slug is a valid + output; the caller owns the empty-slug decision + +"current_year() -> year: str": + location: naming.py + annotations: | + Compute the current calendar year for history addressing. + + `year`: the current year as four digits + + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. Read the naive local calendar year and return it as four digits + + Requirements: + - Naive local time, exactly four digits — the single time source for every + history consumer + - Pure — no filesystem access, no caching + + Constraints: + - Do not accept a timezone or an override value — callers needing a + different year pass it explicitly to the path routines + +"resolve_topic_dir(topic: str, year: str | None = None) -> topic_dir: Path": + location: paths.py + annotations: | + Compute the directory path of a history topic. + + `topic`: topic input — a branch name or an already-normalized slug + `year`: optional year as four digits; None means the current year + `topic_dir`: the topic directory path .goga/history/<year>/<slug>/ + + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. Normalize `topic` via `normalize_topic_slug` + 2. An empty slug -> raise a clean error (no topic name remains) + 3. Resolve the year — `year` when given, otherwise `current_year` + 4. Compose .goga/history/<year>/<slug> and return it + + Requirements: + - The path is rooted at the caller's working directory + - Pure with respect to the filesystem — the directory is not created + - Idempotent — the same input yields the same path + + Constraints: + - Do not create the directory — creation belongs to `ensure_topic_dir` + - Do not fall back to another name or year when the slug is empty — the + error is the result + +"resolve_topic_file(topic: str, filename: str, year: str | None = None) -> file_path: Path": + location: paths.py + annotations: | + Compute the path of an artifact file inside a history topic. + + `topic`: topic input — a branch name or an already-normalized slug + `filename`: artifact filename — arbitrary, must carry an extension + `year`: optional year as four digits; None means the current year + `file_path`: the artifact file path .goga/history/<year>/<slug>/<filename> + + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. Reject a `filename` without an extension with a clean error + 2. Compose the topic directory via `resolve_topic_dir` with `topic` and `year` + 3. Append `filename` and return the path + + Requirements: + - A filename carries an extension when a dot separates a non-empty stem + from a non-empty suffix — a leading dot alone (a dotfile name such as + ".md") is a hidden-file marker, not an extension separator + - Pure with respect to the filesystem — the file is neither created nor + checked for existence + - The filename is taken verbatim — no normalization, no case change + + Constraints: + - Do not resolve the path against the filesystem — no existence checks + +"topic_exists(topic: str, year: str | None = None) -> exists: bool": + location: paths.py + annotations: | + Decide whether a history topic already exists for the year. + + `topic`: topic input — a branch name or an already-normalized slug + `year`: optional year as four digits; None means the current year + `exists`: True when the topic directory exists + + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. Compose the topic directory via `resolve_topic_dir` with `topic` and `year` + 2. Return True when the path exists and is a directory, otherwise False + + Requirements: + - A stray file named like the slug does not occupy a topic — only a + directory counts + - Read-only — nothing is created + + Constraints: + - Do not raise on an absent history root — a missing tree is simply "no" + +"ensure_topic_dir(name: str) -> topic_dir: Path": + location: paths.py + annotations: | + Create the directory of a history topic for the current year. + + `name`: topic input — a branch name or an already-normalized slug + `topic_dir`: the topic directory path that now exists + + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. Compose the topic directory via `resolve_topic_dir` with `name` and the + current year + 2. Create the directory including missing parents + 3. Return the path + + Requirements: + - Idempotent — an existing topic directory is a success, not a conflict + - Creates directories only — no artifact files + + Constraints: + - Do not report occupancy — deciding whether a topic may be created + belongs to the caller + - Do not create or touch artifact files inside the directory + +"TopicStatus()": + location: status.py + annotations: | + Fixed value set of a history topic status. Each member names the process + stage reached by the topic's deepest present artifact. + + Apply the `convention` practice for the value-set implementation and + intra-package imports. + + Requirements: + - Members carry their display names verbatim — consumers filter and + render by them + + Constraints: + - Fixed value set (implement as enum.Enum); no derived or combined members + properties: + "empty -> str": | + "empty" — no artifact is present in the topic directory. + "defined -> str": | + "defined" — prd.md is the deepest present artifact. + "discovered -> str": | + "discovered" — adr.md is the deepest present artifact. + "backlog -> str": | + "backlog" — task.md is the deepest present artifact. + "designed -> str": | + "designed" — arch.md is the deepest present artifact. + "specified -> str": | + "specified" — design.md is the deepest present artifact. + "planned -> str": | + "planned" — plan.md is the deepest present artifact. + "done -> str": | + "done" — completed/plan.md is the deepest present artifact. + +"TopicRecord(topic: str, status: TopicStatus)": + location: status.py + annotations: | + One topic of a year paired with its resolved status — a single record of + the status listing. + + `topic`: the topic slug + `status`: the topic status + + Apply the `convention` practice for the data-model rules and intra-package + imports. + properties: + "topic -> str": | + The topic slug — the directory name of the topic. + "status -> TopicStatus": | + The status resolved for the topic. + +"resolve_topic_status(topic_dir: Path) -> status: TopicStatus": + location: status.py + annotations: | + Resolve the status of one topic from its directory content. + + `topic_dir`: the topic directory path + `status`: the status of the topic + + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. Probe the artifacts of the progression in deepening order: prd.md, + adr.md, task.md, arch.md, design.md, plan.md, completed/plan.md + 2. Return the member mapped to the deepest artifact present as a file + 3. No artifact present -> empty + + Requirements: + - completed/plan.md is the deepest artifact — its presence wins over every + flat artifact + - Read-only — the directory content is probed, never changed + + Constraints: + - Do not invent intermediate statuses — the member set is fixed + - Do not consider files outside the progression + +"collect_topic_statuses(year: str | None = None) -> records: list[TopicRecord]": + location: status.py + annotations: | + Collect every topic of one year with its resolved status. + + `year`: optional year as four digits; None means the current year + `records`: one `TopicRecord` per topic, sorted alphabetically by topic + + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. Resolve the year — `year` when given, otherwise `current_year` + 2. List the topic directories of that year; an absent year yields no records + 3. Resolve the status of each topic via `resolve_topic_status` + 4. Assemble the records sorted alphabetically by topic and return them + + Requirements: + - Only directories count as topics — stray files in the year directory are + ignored + - An absent year yields an empty list — not an error + + Constraints: + - Do not filter — filtering belongs to the consumer + - Do not render — output shaping belongs to the consumer + +"HistoryYear(year: str, topics: list[str])": + location: tree.py + annotations: | + One year of the history tree paired with its topic names — a single record + of the tree listing. + + `year`: the year as four digits + `topics`: the topic slugs of that year + + Apply the `convention` practice for the data-model rules and intra-package + imports. + properties: + "year -> str": | + The year as four digits — the directory name of the year. + "topics -> list[str]": | + The topic slugs found under the year, sorted alphabetically. + +"collect_history_tree() -> tree: list[HistoryYear]": + location: tree.py + annotations: | + Collect the full history tree — every year with its topics. + + `tree`: one `HistoryYear` per year, sorted by year ascending + + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. List the year directories of the history root + 2. For each year, list its topic directories + 3. Assemble one `HistoryYear` per year — topics sorted alphabetically, + years sorted ascending + 4. Return the assembled tree + + Requirements: + - A year directory is a directory named with exactly four digits — + anything else is ignored + - Only directories count as topics — stray files are ignored + - An absent history root yields an empty list — not an error + + Constraints: + - Do not compute statuses — the tree carries topic names only + - Do not render — output shaping belongs to the consumer + +--- + +Author: Goga +CreatedAt: 28/08/26 +Description: | + Owner of the .goga/history/ tree — topic identity, addressing, statuses, and + traversal. Re-exports the git branch reader on its facade. diff --git a/goga/history/git/CODEMANIFEST b/goga/history/git/CODEMANIFEST new file mode 100644 index 00000000..a82208b4 --- /dev/null +++ b/goga/history/git/CODEMANIFEST @@ -0,0 +1,53 @@ +Usages: + convention: .goga/usages/conventions.md + git: | + External git binary invoked via subprocess.run (check=True, capture_output=True). + Set GIT_TERMINAL_PROMPT=0 in the env to suppress interactive prompts. Mock the + subprocess call in tests per `convention`. + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns git-environment introspection for the history domain: reading the + current branch name. It is environment-probing, NOT history-path or topic logic — + that lives in goga/history. All git access flows through the `git` practice; mock + the subprocess call in tests per `convention`. Use relative imports. Re-exported + on the goga/history facade via embedding. + +--- + +"resolve_current_branch_name() -> branch: str | None": + location: branch.py + annotations: | + Read the current git branch name exactly as git reports it. + + `branch`: raw current branch name, or None when it cannot be determined + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. Ask git for the current branch name + 2. A non-empty answer -> return it stripped, unmodified + 3. Detached HEAD, missing git binary, or a non-repository -> None + + Requirements: + - Read-only — no branch switch, no writes, no caching + - No slugification and no fallback value — both belong to the caller + + Constraints: + - Do not tolerate unexpected OS-level failures silently — the None result + covers only the documented failure modes + +--- + +Author: Goga +CreatedAt: 28/08/26 +Description: | + Git-environment introspection for the history domain — reads the current branch + name. Re-exported on the goga/history facade. From 11c9d5fa2ea1f24a49dafa3a8060c7fc97e74815 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 20:52:47 +0000 Subject: [PATCH 047/229] feat: add goga/history/git leaf cell with branch reader and tests --- goga/history/__init__.py | 8 ++ goga/history/git/__init__.py | 5 + goga/history/git/branch.py | 48 ++++++++++ tests/history/__init__.py | 0 tests/history/git/__init__.py | 0 tests/history/git/test_branch.py | 151 +++++++++++++++++++++++++++++++ 6 files changed, 212 insertions(+) create mode 100644 goga/history/__init__.py create mode 100644 goga/history/git/__init__.py create mode 100644 goga/history/git/branch.py create mode 100644 tests/history/__init__.py create mode 100644 tests/history/git/__init__.py create mode 100644 tests/history/git/test_branch.py diff --git a/goga/history/__init__.py b/goga/history/__init__.py new file mode 100644 index 00000000..5066449a --- /dev/null +++ b/goga/history/__init__.py @@ -0,0 +1,8 @@ +"""History domain cell — the owner of the ``.goga/history/`` tree. + +Placeholder package module: the git leaf cell (``goga.history.git``) is +physically nested inside this directory, so the package must exist before the +leaf is importable. The domain facade — the 13 contract names of the naming, +paths, status, and tree modules plus the embedded git routine — is assembled +by the dedicated facade task. +""" diff --git a/goga/history/git/__init__.py b/goga/history/git/__init__.py new file mode 100644 index 00000000..c424a589 --- /dev/null +++ b/goga/history/git/__init__.py @@ -0,0 +1,5 @@ +"""Git-environment introspection cell for the history domain — the branch reader.""" + +from .branch import resolve_current_branch_name + +__all__: list[str] = ["resolve_current_branch_name"] diff --git a/goga/history/git/branch.py b/goga/history/git/branch.py new file mode 100644 index 00000000..fa59db8f --- /dev/null +++ b/goga/history/git/branch.py @@ -0,0 +1,48 @@ +"""Git-environment introspection for the history domain — the branch reader. + +The single routine declared in the cell CODEMANIFEST with ``location: +branch.py``: the raw current-branch reader. Every git invocation follows the +``git`` practice — ``subprocess.run`` with ``check=True``, captured output, and +``GIT_TERMINAL_PROMPT=0`` in the environment. The reader is read-only; no +branch is switched and nothing is written. +""" + +from __future__ import annotations + +import os +import subprocess + + +def resolve_current_branch_name() -> str | None: + """Read the current git branch name exactly as git reports it. + + Asks git via ``git branch --show-current`` (per the ``git`` practice) and + returns the stripped answer unmodified — no slugification, no fallback + value; both belong to the caller. ``None`` covers only the three documented + failure modes: detached HEAD (an empty git answer), a missing git binary + (``FileNotFoundError``), and a non-repository (a non-zero git exit). + Read-only; the result is not cached — each call asks git anew. + + Returns: + The raw current branch name (stripped, unmodified), or ``None`` when it + cannot be determined. + + Raises: + OSError: unexpected OS-level failures of the git invocation (e.g. a + ``PermissionError``); the ``None`` result covers only the + documented failure modes. + """ + try: + result = subprocess.run( + ["git", "branch", "--show-current"], + check=True, + capture_output=True, + text=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + return None + value = result.stdout.strip() + if value == "": + return None + return value diff --git a/tests/history/__init__.py b/tests/history/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/history/git/__init__.py b/tests/history/git/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/history/git/test_branch.py b/tests/history/git/test_branch.py new file mode 100644 index 00000000..5bc077e0 --- /dev/null +++ b/tests/history/git/test_branch.py @@ -0,0 +1,151 @@ +"""Contract and logic tests for the routine declared in +``goga/history/git/CODEMANIFEST`` with ``location: branch.py``: + +- ``resolve_current_branch_name() -> str | None`` — the raw git branch reader + with the three documented None modes (detached HEAD, missing git binary, + non-repository) + +Git is mocked at the subprocess boundary per the ``git`` practice — +``mock.patch.object(branch_module.subprocess, "run")`` — never as a git double. +""" + +from __future__ import annotations + +import inspect +import subprocess +import typing +from unittest import mock + +import pytest +from goga.history.git import branch as branch_module +from goga.history.git import resolve_current_branch_name + +# --- Git subprocess mocking helpers (the process boundary only) --- + + +class _GitResult: + """Minimal stand-in for a ``subprocess.CompletedProcess``.""" + + def __init__(self, returncode: int = 0, stdout: str = "", stderr: str = "") -> None: + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def _git_run_dispatch( + show_current: object = _GitResult(stdout="main\n"), + show_ref: object = subprocess.CalledProcessError(1, "git"), + for_each_ref: object = _GitResult(stdout=""), + switch: object = _GitResult(returncode=0), +) -> mock.Mock: + """Build a ``subprocess.run`` mock dispatching per git subcommand. + + ``show_current`` / ``show_ref`` / ``for_each_ref`` / ``switch`` are either a + ``_GitResult`` (returned) or an exception instance/class (raised). Any of + them may instead be a LIST of such outcomes consumed in call order + (exhausted → AssertionError) — for re-ask sequences where the same command + must answer differently per iteration. + """ + outcomes = { + ("branch", "--show-current"): show_current, + ("show-ref",): show_ref, + ("for-each-ref",): for_each_ref, + ("switch",): switch, + } + queues = {key: (list(value) if isinstance(value, list) else None) for key, value in outcomes.items()} + + def _run(argv: list[str], **_kwargs: object) -> _GitResult: + for key, default_outcome in outcomes.items(): + if tuple(argv[1 : 1 + len(key)]) == key: + outcome = default_outcome + if queues[key] is not None: + if not queues[key]: + raise AssertionError(f"unexpected repeat of git argv in test: {argv!r}") + outcome = queues[key].pop(0) + if isinstance(outcome, BaseException) or ( + isinstance(outcome, type) and issubclass(outcome, BaseException) + ): + raise outcome + return outcome + raise AssertionError(f"unexpected git argv in test: {argv!r}") + + return mock.Mock(side_effect=_run) + + +# --- Contract tests --- + + +class TestGitBranchContract: + def test_routine_is_importable_from_facade_and_callable(self) -> None: + """The routine is importable from ``goga.history.git`` and callable.""" + assert callable(resolve_current_branch_name) + assert branch_module.resolve_current_branch_name is resolve_current_branch_name + + def test_facade_all_lists_the_routine(self) -> None: + """The cell facade exports exactly the one declared name.""" + import goga.history.git + + assert goga.history.git.__all__ == ["resolve_current_branch_name"] + + def test_resolve_current_branch_name_signature(self) -> None: + """``resolve_current_branch_name() -> str | None`` — no parameters.""" + signature = inspect.signature(resolve_current_branch_name) + assert list(signature.parameters) == [] + hints = typing.get_type_hints(resolve_current_branch_name) + assert hints == {"return": str | None} + + +# --- Logic tests --- + + +class TestResolveCurrentBranchName: + def test_returns_raw_branch_name_stripped(self) -> None: + """A non-empty git answer is returned stripped and unmodified.""" + run_mock = _git_run_dispatch(show_current=_GitResult(stdout=" release/1.3.0\n")) + + with mock.patch.object(branch_module.subprocess, "run", run_mock): + assert resolve_current_branch_name() == "release/1.3.0" + + def test_asks_git_show_current_without_terminal_prompts(self) -> None: + """The invocation follows the ``git`` practice: argv and prompt-free env.""" + run_mock = _git_run_dispatch() + + with mock.patch.object(branch_module.subprocess, "run", run_mock): + resolve_current_branch_name() + + call = run_mock.call_args + assert call.args[0] == ["git", "branch", "--show-current"] + assert call.kwargs["check"] is True + assert call.kwargs["capture_output"] is True + assert call.kwargs["text"] is True + assert call.kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0" + + def test_detached_head_returns_none(self) -> None: + """An empty git answer (detached HEAD) is a documented None mode.""" + run_mock = _git_run_dispatch(show_current=_GitResult(stdout="")) + + with mock.patch.object(branch_module.subprocess, "run", run_mock): + assert resolve_current_branch_name() is None + + def test_non_repository_returns_none(self) -> None: + """A non-zero git exit (a non-repository) is a documented None mode.""" + run_mock = _git_run_dispatch( + show_current=subprocess.CalledProcessError(128, "git", stderr="fatal: not a git repository"), + ) + + with mock.patch.object(branch_module.subprocess, "run", run_mock): + assert resolve_current_branch_name() is None + + def test_missing_git_binary_returns_none(self) -> None: + """A missing git binary is a documented None mode.""" + run_mock = mock.Mock(side_effect=FileNotFoundError("git")) + + with mock.patch.object(branch_module.subprocess, "run", run_mock): + assert resolve_current_branch_name() is None + + def test_unexpected_os_failure_propagates(self) -> None: + """An unexpected ``PermissionError`` is not swallowed by the None modes.""" + run_mock = mock.Mock(side_effect=PermissionError("git")) + + with mock.patch.object(branch_module.subprocess, "run", run_mock), pytest.raises(PermissionError): + resolve_current_branch_name() From 57578155f4129d88ce03e63db7c0a94f1eba4406 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 20:55:40 +0000 Subject: [PATCH 048/229] feat: add goga/history/naming.py with normalize_topic_slug and current_year --- goga/history/__init__.py | 14 ++-- goga/history/naming.py | 52 +++++++++++++++ tests/history/test_naming.py | 124 +++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 goga/history/naming.py create mode 100644 tests/history/test_naming.py diff --git a/goga/history/__init__.py b/goga/history/__init__.py index 5066449a..4aeb9925 100644 --- a/goga/history/__init__.py +++ b/goga/history/__init__.py @@ -1,8 +1,12 @@ """History domain cell — the owner of the ``.goga/history/`` tree. -Placeholder package module: the git leaf cell (``goga.history.git``) is -physically nested inside this directory, so the package must exist before the -leaf is importable. The domain facade — the 13 contract names of the naming, -paths, status, and tree modules plus the embedded git routine — is assembled -by the dedicated facade task. +The package facade is assembled incrementally per module (the naming module +here; the git leaf cell ``goga.history.git`` is physically nested inside this +directory and re-exported later). The full 13-name contract surface of the +naming, paths, status, and tree modules plus the embedded git routine is +finalized by the dedicated facade task. """ + +from .naming import current_year, normalize_topic_slug + +__all__: list[str] = ["current_year", "normalize_topic_slug"] diff --git a/goga/history/naming.py b/goga/history/naming.py new file mode 100644 index 00000000..b779722c --- /dev/null +++ b/goga/history/naming.py @@ -0,0 +1,52 @@ +"""Naming and time primitives for the history domain. + +The two routines declared in the cell CODEMANIFEST with ``location: +naming.py``: the pure slug transformer (moved verbatim from the pipeline +cell, whose own copy stays in place until the dedicated migration task) and +the single current-year point shared by every history consumer. Both are +pure — no git, no filesystem, no caching. +""" + +from __future__ import annotations + +import re +from datetime import datetime + + +def normalize_topic_slug(name: str) -> str: + """Normalize a branch name into the history topic slug. + + Deterministic pure string transformation: lowercase the name, drop every + non-ASCII character (no transliteration), replace each remaining character + outside ``[a-z0-9]`` with a hyphen, collapse repeat hyphens into one, and + trim leading and trailing hyphens. Lowercasing happens BEFORE the ASCII + filter, so a name like ``"aБb"`` yields ``"ab"`` and the Turkish dotted + capital ``"İ"`` lowercases to ``"i"`` plus a combining dot that the filter + drops. + + A fully non-ASCII or all-separator name yields the empty string — a valid + output. No fallback is returned for an empty result; the caller owns the + empty-slug decision. + + Args: + name: Branch name as entered by the user. + + Returns: + The history topic slug (possibly empty). No git, no filesystem, no + side effects. + """ + lowered = name.lower() + ascii_only = "".join(character for character in lowered if character.isascii()) + hyphened = re.sub(r"[^a-z0-9]", "-", ascii_only) + collapsed = re.sub(r"-{2,}", "-", hyphened) + return collapsed.strip("-") + + +def current_year() -> str: + """Return the current local calendar year as a 4-digit string. + + The single time point for every history consumer: naive local time — the + history tree is organized by the host's calendar year — with no timezone + and no override. Pure and uncached: evaluated anew on each call. + """ + return f"{datetime.now().year:04d}" # noqa: DTZ005 — bare now() is the mandated test mock target diff --git a/tests/history/test_naming.py b/tests/history/test_naming.py new file mode 100644 index 00000000..312a6107 --- /dev/null +++ b/tests/history/test_naming.py @@ -0,0 +1,124 @@ +"""Contract and logic tests for the routines declared in +``goga/history/CODEMANIFEST`` with ``location: naming.py``: + +- ``normalize_topic_slug(name: str) -> str`` — the pure slug transformer +- ``current_year() -> str`` — the single current-year point of the domain + +Both routines are pure; the only mock target is ``naming.datetime`` (the +mandated bare-``now()`` point), patched at the import site. +""" + +from __future__ import annotations + +import inspect +import typing +from datetime import datetime +from unittest import mock + +import pytest +from goga.history import naming +from goga.history.naming import current_year, normalize_topic_slug + + +class _FixedClock: + """Stand-in for ``datetime`` answering a fixed naive date.""" + + @staticmethod + def now() -> datetime: + return datetime(2031, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock + + +# --- Contract tests --- + + +class TestNamingContract: + def test_routines_are_importable_from_module_and_callable(self) -> None: + """Both routines are importable from ``goga.history.naming`` and callable.""" + assert callable(normalize_topic_slug) + assert callable(current_year) + assert naming.normalize_topic_slug is normalize_topic_slug + assert naming.current_year is current_year + + def test_facade_reexports_the_naming_names(self) -> None: + """The naming routines are importable from the domain facade.""" + import goga.history + + assert goga.history.normalize_topic_slug is normalize_topic_slug + assert goga.history.current_year is current_year + assert "current_year" in goga.history.__all__ + assert "normalize_topic_slug" in goga.history.__all__ + + def test_normalize_topic_slug_signature(self) -> None: + """``normalize_topic_slug(name: str) -> str`` — one positional-or-keyword parameter.""" + signature = inspect.signature(normalize_topic_slug) + assert list(signature.parameters) == ["name"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + hints = typing.get_type_hints(normalize_topic_slug) + assert hints == {"name": str, "return": str} + + def test_current_year_signature(self) -> None: + """``current_year() -> str`` — no parameters.""" + signature = inspect.signature(current_year) + assert list(signature.parameters) == [] + hints = typing.get_type_hints(current_year) + assert hints == {"return": str} + + +# --- Logic tests --- + + +class TestNormalizeTopicSlug: + @pytest.mark.parametrize( + ("name", "slug"), + [ + ("Feature/Foo_Bar", "feature-foo-bar"), + ("release/1.3.0", "release-1-3-0"), + ("Релиз/Один", ""), + ("aБb", "ab"), + ("-a--b-", "a-b"), + ("My Tool", "my-tool"), + ("feat///x", "feat-x"), + ("UPPER", "upper"), + ("123", "123"), + ("release-1-3-0", "release-1-3-0"), + ], + ) + def test_normalize_topic_slug_parametrized(self, name: str, slug: str) -> None: + """The slug grammar: lowercase → ASCII filter → hyphenate → collapse → trim. + + The last pair is idempotence — an already-normalized slug maps to + itself. + """ + assert normalize_topic_slug(name) == slug + + def test_normalize_topic_slug_fully_non_ascii_empty(self) -> None: + """A fully non-ASCII name yields the empty slug — deterministically.""" + assert normalize_topic_slug("Релиз/Один") == "" + assert normalize_topic_slug("Релиз/Один") == normalize_topic_slug("Релиз/Один") + + +class TestCurrentYear: + def test_current_year_returns_four_digits(self) -> None: + """The pinned clock answers the zero-padded 4-digit year, no parameters.""" + with mock.patch.object(naming, "datetime", _FixedClock): + year = current_year() + assert year == "2031" + assert len(year) == 4 + assert list(inspect.signature(current_year).parameters) == [] + + def test_current_year_has_no_override_and_is_uncached(self) -> None: + """Each call asks the clock anew — two pinned calls, two answers.""" + class _SteppingClock: + calls = 0 + + @classmethod + def now(cls) -> datetime: + cls.calls += 1 + return datetime(2031 + cls.calls, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock + + with mock.patch.object(naming, "datetime", _SteppingClock): + assert current_year() == "2032" + assert current_year() == "2033" From 339803233cce4c090df09cfae7b15df3a0ae264e Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 20:59:21 +0000 Subject: [PATCH 049/229] feat: add goga/history/paths leaf module with path routines and tests --- goga/history/__init__.py | 20 +++- goga/history/paths.py | 114 ++++++++++++++++++ tests/history/test_paths.py | 231 ++++++++++++++++++++++++++++++++++++ 3 files changed, 359 insertions(+), 6 deletions(-) create mode 100644 goga/history/paths.py create mode 100644 tests/history/test_paths.py diff --git a/goga/history/__init__.py b/goga/history/__init__.py index 4aeb9925..342666e3 100644 --- a/goga/history/__init__.py +++ b/goga/history/__init__.py @@ -1,12 +1,20 @@ """History domain cell — the owner of the ``.goga/history/`` tree. -The package facade is assembled incrementally per module (the naming module -here; the git leaf cell ``goga.history.git`` is physically nested inside this -directory and re-exported later). The full 13-name contract surface of the -naming, paths, status, and tree modules plus the embedded git routine is -finalized by the dedicated facade task. +The package facade is assembled incrementally per module (the naming and +paths modules here; the git leaf cell ``goga.history.git`` is physically +nested inside this directory and re-exported later). The full 13-name +contract surface of the naming, paths, status, and tree modules plus the +embedded git routine is finalized by the dedicated facade task. """ from .naming import current_year, normalize_topic_slug +from .paths import ensure_topic_dir, resolve_topic_dir, resolve_topic_file, topic_exists -__all__: list[str] = ["current_year", "normalize_topic_slug"] +__all__: list[str] = [ + "current_year", + "ensure_topic_dir", + "normalize_topic_slug", + "resolve_topic_dir", + "resolve_topic_file", + "topic_exists", +] diff --git a/goga/history/paths.py b/goga/history/paths.py new file mode 100644 index 00000000..06eea056 --- /dev/null +++ b/goga/history/paths.py @@ -0,0 +1,114 @@ +"""Topic addressing for the history domain. + +The routines declared in the cell CODEMANIFEST with ``location: paths.py``: +the private history-root helper shared by the cell's modules, the two pure +path composers (topic directory and artifact file), the read-only occupancy +oracle, and the idempotent directory creator. Composers never touch the +filesystem — creation belongs to ``ensure_topic_dir`` alone. +""" + +from __future__ import annotations + +from pathlib import Path, PurePath + +from .naming import current_year, normalize_topic_slug + + +def _history_root() -> Path: + """Return the history tree root relative to the caller's working directory.""" + return Path(".goga") / "history" + + +def resolve_topic_dir(topic: str, year: str | None = None) -> Path: + """Compute the directory path of a history topic. + + The topic input is normalized via the slug grammar — a branch name and an + already-normalized slug compose identically. A falsy year (``None`` or the + empty string an empty CLI value produces) means "not set" and falls back + to the current year; without that rule an empty string would degrade the + composed path to the year's parent directory. + + Args: + topic: Topic input — a branch name or an already-normalized slug. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The topic directory path ``.goga/history/<year>/<slug>/`` — relative + to the caller's working directory, not created. + + Raises: + ValueError: The topic input normalizes to an empty slug — no fallback + name or year is returned; the error is the result. + """ + slug = normalize_topic_slug(topic) + if slug == "": + raise ValueError(f"topic input {topic!r} normalizes to an empty topic slug") + resolved_year = year or current_year() + return _history_root() / resolved_year / slug + + +def resolve_topic_file(topic: str, filename: str, year: str | None = None) -> Path: + """Compute the path of an artifact file inside a history topic. + + The filename is taken verbatim — no normalization, no case change — and + must carry an extension: a dot separating a non-empty stem from a + non-empty suffix. A leading dot alone (a dotfile name such as ``.md``) is + a hidden-file marker, not an extension separator, which is exactly the + standard-library ``PurePath.suffix`` semantics this check relies on. + + Args: + topic: Topic input — a branch name or an already-normalized slug. + filename: Artifact filename — arbitrary, must carry an extension. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The artifact file path ``.goga/history/<year>/<slug>/<filename>`` — + neither created nor checked for existence. + + Raises: + ValueError: The filename carries no extension, or the topic input + normalizes to an empty slug (the directory composer's error). + """ + if PurePath(filename).suffix == "": + raise ValueError(f"filename {filename!r} must carry an extension") + return resolve_topic_dir(topic, year) / filename + + +def topic_exists(topic: str, year: str | None = None) -> bool: + """Decide whether a history topic already exists for the year. + + True only when the composed topic path exists as a directory — a stray + file named like the slug does not occupy a topic, and a missing history + root is simply "no", not an error. + + Args: + topic: Topic input — a branch name or an already-normalized slug. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + True when the topic directory exists, otherwise False. + """ + return resolve_topic_dir(topic, year).is_dir() + + +def ensure_topic_dir(name: str) -> Path: + """Create the directory of a history topic for the current year. + + Idempotent: an existing topic directory is a success, not a conflict — + deciding whether a topic may be created belongs to the caller. + Directories only: no artifact file inside the tree is created or touched. + + Args: + name: Topic input — a branch name or an already-normalized slug. + + Returns: + The topic directory path that now exists. + + Raises: + ValueError: The name normalizes to an empty slug. + OSError: Propagated from ``mkdir`` — unexpected OS failures are not + swallowed. + """ + topic_dir = resolve_topic_dir(name) + topic_dir.mkdir(parents=True, exist_ok=True) + return topic_dir diff --git a/tests/history/test_paths.py b/tests/history/test_paths.py new file mode 100644 index 00000000..6ffd51e7 --- /dev/null +++ b/tests/history/test_paths.py @@ -0,0 +1,231 @@ +"""Contract and logic tests for the routines declared in +``goga/history/CODEMANIFEST`` with ``location: paths.py``: + +- ``resolve_topic_dir(topic: str, year: str | None = None) -> Path`` +- ``resolve_topic_file(topic: str, filename: str, year: str | None = None) -> Path`` +- ``topic_exists(topic: str, year: str | None = None) -> bool`` +- ``ensure_topic_dir(name: str) -> Path`` + +The path composers are pure with respect to the filesystem; ``ensure_topic_dir`` +is the only mutating routine. The single mock target is ``naming.datetime`` +(the mandated bare-``now()`` point), patched at the import site; filesystem +fixtures use ``tmp_path`` + ``monkeypatch.chdir``. +""" + +from __future__ import annotations + +import inspect +import typing +from datetime import datetime +from pathlib import Path +from unittest import mock + +import pytest +from goga.history import naming, paths +from goga.history.paths import ( + ensure_topic_dir, + resolve_topic_dir, + resolve_topic_file, + topic_exists, +) + + +class _FixedClock: + """Stand-in for ``datetime`` answering a fixed naive date.""" + + @staticmethod + def now() -> datetime: + return datetime(2031, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock + + +# --- Contract tests --- + + +class TestPathsContract: + def test_routines_are_importable_from_module_and_callable(self) -> None: + """All four routines are importable from ``goga.history.paths`` and callable.""" + assert callable(resolve_topic_dir) + assert callable(resolve_topic_file) + assert callable(topic_exists) + assert callable(ensure_topic_dir) + assert paths.resolve_topic_dir is resolve_topic_dir + assert paths.resolve_topic_file is resolve_topic_file + assert paths.topic_exists is topic_exists + assert paths.ensure_topic_dir is ensure_topic_dir + + def test_facade_reexports_the_paths_names(self) -> None: + """The paths routines are importable from the domain facade.""" + import goga.history + + assert goga.history.resolve_topic_dir is resolve_topic_dir + assert goga.history.resolve_topic_file is resolve_topic_file + assert goga.history.topic_exists is topic_exists + assert goga.history.ensure_topic_dir is ensure_topic_dir + for name in ("resolve_topic_dir", "resolve_topic_file", "topic_exists", "ensure_topic_dir"): + assert name in goga.history.__all__ + + def test_resolve_topic_dir_signature(self) -> None: + """``resolve_topic_dir(topic: str, year: str | None = None) -> Path``.""" + signature = inspect.signature(resolve_topic_dir) + assert list(signature.parameters) == ["topic", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(resolve_topic_dir) + assert hints == {"topic": str, "year": str | None, "return": Path} + + def test_resolve_topic_file_signature(self) -> None: + """``resolve_topic_file(topic: str, filename: str, year: str | None = None) -> Path``.""" + signature = inspect.signature(resolve_topic_file) + assert list(signature.parameters) == ["topic", "filename", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(resolve_topic_file) + assert hints == {"topic": str, "filename": str, "year": str | None, "return": Path} + + def test_topic_exists_signature(self) -> None: + """``topic_exists(topic: str, year: str | None = None) -> bool``.""" + signature = inspect.signature(topic_exists) + assert list(signature.parameters) == ["topic", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(topic_exists) + assert hints == {"topic": str, "year": str | None, "return": bool} + + def test_ensure_topic_dir_signature(self) -> None: + """``ensure_topic_dir(name: str) -> Path`` — one positional-or-keyword parameter.""" + signature = inspect.signature(ensure_topic_dir) + assert list(signature.parameters) == ["name"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + hints = typing.get_type_hints(ensure_topic_dir) + assert hints == {"name": str, "return": Path} + + def test_history_root_helper_points_at_the_tree(self) -> None: + """The private helper answers the relative history root.""" + assert paths._history_root() == Path(".goga") / "history" + + +# --- Logic tests --- + + +class TestResolveTopicDir: + def test_resolve_topic_dir_composes_and_normalizes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Branch input normalizes; no year means the current year; nothing is created.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object(naming, "datetime", _FixedClock): + assert resolve_topic_dir("Feature/Foo_Bar") == Path(".goga/history/2031/feature-foo-bar") + assert resolve_topic_dir("release-1-3-0", year="2025") == Path(".goga/history/2025/release-1-3-0") + assert not (tmp_path / ".goga").exists() + + def test_resolve_topic_dir_is_idempotent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The same input yields the same path on every call.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object(naming, "datetime", _FixedClock): + first = resolve_topic_dir("My Tool") + second = resolve_topic_dir("My Tool") + assert first == second == Path(".goga/history/2031/my-tool") + + def test_resolve_topic_dir_empty_slug_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A fully non-ASCII topic raises the clean empty-slug error, no fallback.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="normalizes to an empty topic slug"): + resolve_topic_dir("Релиз/Один") + + def test_resolve_topic_dir_empty_year_string_means_current( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A falsy year (``""`` from an empty CLI value) means "not set", not path degradation.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object(naming, "datetime", _FixedClock): + assert resolve_topic_dir("feat-x", year="") == Path(".goga/history/2031/feat-x") + + +class TestResolveTopicFile: + def test_resolve_topic_file_appends_filename( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The filename is appended verbatim; the file is not created.""" + monkeypatch.chdir(tmp_path) + path = resolve_topic_file("history-commands", "plan.md", year="2026") + assert path == Path(".goga/history/2026/history-commands/plan.md") + assert not path.exists() + + @pytest.mark.parametrize("filename", ["noext", ".md", "plan."]) + def test_resolve_topic_file_rejects_extensionless( + self, filename: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An extensionless filename — including dotfiles and trailing dots — is a clean error.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="must carry an extension"): + resolve_topic_file("history-commands", filename, year="2026") + + def test_resolve_topic_file_empty_slug_raises_via_dir_composer( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The file composer reuses the single directory composer — its empty-slug error stands.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="normalizes to an empty topic slug"): + resolve_topic_file("Релиз/Один", "plan.md", year="2026") + + +class TestTopicExists: + def test_topic_exists_true_for_directory(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Only a directory occupies a topic — and only for its own year.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".goga" / "history" / "2026" / "feat-x").mkdir(parents=True) + (tmp_path / ".goga" / "history" / "2026" / "stray").write_text("not a topic", encoding="utf-8") + assert topic_exists("feat/x", year="2026") is True + assert topic_exists("feat/x", year="2025") is False + assert topic_exists("stray", year="2026") is False + + def test_topic_exists_absent_root_is_false(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A missing history root is "no", not an error — and creates nothing.""" + monkeypatch.chdir(tmp_path) + assert topic_exists("feat-x") is False + assert not (tmp_path / ".goga").exists() + + +class TestEnsureTopicDir: + def test_ensure_topic_dir_creates_idempotently( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Creation normalizes, defaults to the current year, and is idempotent.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object(naming, "datetime", _FixedClock): + first = ensure_topic_dir("Feature/X") + second = ensure_topic_dir("feature-x") + expected = Path(".goga/history/2031/feature-x") + assert first == expected + assert second == expected + assert first.is_dir() + assert list(first.iterdir()) == [] + + def test_ensure_topic_dir_creates_parents( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Missing parents (.goga/history/<year>) are created on the way.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object(naming, "datetime", _FixedClock): + created = ensure_topic_dir("feat-y") + assert created == Path(".goga/history/2031/feat-y") + assert (tmp_path / ".goga" / "history" / "2031").is_dir() + + def test_ensure_topic_dir_empty_slug_raises( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An empty slug is the directory composer's clean error — nothing is created.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="normalizes to an empty topic slug"): + ensure_topic_dir("Релиз/Один") + assert not (tmp_path / ".goga").exists() From 1da8781266886afa14fd9de4383e347856767d02 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 21:13:41 +0000 Subject: [PATCH 050/229] feat: add goga/history/status module with TopicStatus, TopicRecord, and status routines --- goga/history/__init__.py | 9 +- goga/history/status.py | 103 +++++++++++++++++ tests/history/test_status.py | 218 +++++++++++++++++++++++++++++++++++ 3 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 goga/history/status.py create mode 100644 tests/history/test_status.py diff --git a/goga/history/__init__.py b/goga/history/__init__.py index 342666e3..b15e1d90 100644 --- a/goga/history/__init__.py +++ b/goga/history/__init__.py @@ -1,7 +1,7 @@ """History domain cell — the owner of the ``.goga/history/`` tree. -The package facade is assembled incrementally per module (the naming and -paths modules here; the git leaf cell ``goga.history.git`` is physically +The package facade is assembled incrementally per module (the naming, paths, +and status modules here; the git leaf cell ``goga.history.git`` is physically nested inside this directory and re-exported later). The full 13-name contract surface of the naming, paths, status, and tree modules plus the embedded git routine is finalized by the dedicated facade task. @@ -9,12 +9,17 @@ from .naming import current_year, normalize_topic_slug from .paths import ensure_topic_dir, resolve_topic_dir, resolve_topic_file, topic_exists +from .status import TopicRecord, TopicStatus, collect_topic_statuses, resolve_topic_status __all__: list[str] = [ + "TopicRecord", + "TopicStatus", + "collect_topic_statuses", "current_year", "ensure_topic_dir", "normalize_topic_slug", "resolve_topic_dir", "resolve_topic_file", + "resolve_topic_status", "topic_exists", ] diff --git a/goga/history/status.py b/goga/history/status.py new file mode 100644 index 00000000..ca9fcb14 --- /dev/null +++ b/goga/history/status.py @@ -0,0 +1,103 @@ +"""Topic status model for the history domain. + +The entities declared in the cell CODEMANIFEST with ``location: status.py``: +the fixed eight-member status value set, the per-topic record of the status +listing, the read-only resolver that walks the artifact progression, and the +year collector. Both filesystem routines only probe — nothing is created or +changed; filtering and rendering belong to the consumer. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +from .naming import current_year +from .paths import _history_root + + +class TopicStatus(Enum): + """Fixed value set of a history topic status. + + Each member names the process stage reached by the topic's deepest + present artifact; ``value`` is the display string consumers filter and + render by. + """ + + empty = "empty" + defined = "defined" + discovered = "discovered" + backlog = "backlog" + designed = "designed" + specified = "specified" + planned = "planned" + done = "done" + + +@dataclass(frozen=True, kw_only=True) +class TopicRecord: + """One topic of a year paired with its resolved status. + + Attributes: + topic: The topic slug — the directory name of the topic. + status: The status resolved for the topic. + """ + + topic: str + status: TopicStatus + + +# Defined after TopicStatus — each row pairs a progression artifact with the +# status its presence reports; the deepening order is the contract. +_ARTIFACT_PROGRESSION: list[tuple[str, TopicStatus]] = [ + ("prd.md", TopicStatus.defined), + ("adr.md", TopicStatus.discovered), + ("task.md", TopicStatus.backlog), + ("arch.md", TopicStatus.designed), + ("design.md", TopicStatus.specified), + ("plan.md", TopicStatus.planned), + ("completed/plan.md", TopicStatus.done), +] + + +def resolve_topic_status(topic_dir: Path) -> TopicStatus: + """Resolve the status of one topic from its directory content. + + The artifacts of the progression are probed in deepening order and the + deepest present one wins — ``completed/plan.md`` is last in the list, so + its presence outranks every flat artifact. Files outside the progression + are ignored; an empty or missing directory resolves to ``empty``. + + Args: + topic_dir: The topic directory path. + + Returns: + The status of the topic. Read-only — the directory content is probed, + never changed. + """ + resolved = TopicStatus.empty + for artifact, artifact_status in _ARTIFACT_PROGRESSION: + if (topic_dir / artifact).is_file(): + resolved = artifact_status + return resolved + + +def collect_topic_statuses(year: str | None = None) -> list[TopicRecord]: + """Collect every topic of one year with its resolved status. + + Args: + year: Optional year as four digits; ``None`` (or the empty string an + empty CLI value produces) means the current year. + + Returns: + One ``TopicRecord`` per topic, sorted alphabetically by topic — the + full year, unfiltered. An absent year yields an empty list, not an + error; stray files in the year directory are not topics. + """ + resolved_year = year or current_year() + year_dir = _history_root() / resolved_year + if not year_dir.is_dir(): + return [] + topics = sorted(path.name for path in year_dir.iterdir() if path.is_dir()) + return [TopicRecord(topic=topic, status=resolve_topic_status(year_dir / topic)) for topic in topics] diff --git a/tests/history/test_status.py b/tests/history/test_status.py new file mode 100644 index 00000000..da70d069 --- /dev/null +++ b/tests/history/test_status.py @@ -0,0 +1,218 @@ +"""Contract and logic tests for the entities declared in +``goga/history/CODEMANIFEST`` with ``location: status.py``: + +- ``TopicStatus()`` — the fixed eight-member status value set +- ``TopicRecord(topic: str, status: TopicStatus)`` +- ``resolve_topic_status(topic_dir: Path) -> status: TopicStatus`` +- ``collect_topic_statuses(year: str | None = None) -> records: list[TopicRecord]`` + +The resolver and the collector are read-only with respect to the filesystem. +The single mock target is ``naming.datetime`` (the mandated bare-``now()`` +point), patched at the import site; filesystem fixtures use ``tmp_path`` + +``monkeypatch.chdir``. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import typing +from datetime import datetime +from pathlib import Path +from unittest import mock + +import pytest +from goga.history import naming, status +from goga.history.status import ( + TopicRecord, + TopicStatus, + collect_topic_statuses, + resolve_topic_status, +) + + +class _FixedClock: + """Stand-in for ``datetime`` answering a fixed naive date.""" + + @staticmethod + def now() -> datetime: + return datetime(2031, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock + + +# --- Contract tests --- + + +class TestStatusContract: + def test_entities_are_importable_from_module_and_callable(self) -> None: + """All four entities are importable from ``goga.history.status``.""" + assert status.TopicStatus is TopicStatus + assert status.TopicRecord is TopicRecord + assert callable(resolve_topic_status) + assert callable(collect_topic_statuses) + assert status.resolve_topic_status is resolve_topic_status + assert status.collect_topic_statuses is collect_topic_statuses + + def test_facade_reexports_the_status_names(self) -> None: + """The status entities are importable from the domain facade.""" + import goga.history + + assert goga.history.TopicStatus is TopicStatus + assert goga.history.TopicRecord is TopicRecord + assert goga.history.resolve_topic_status is resolve_topic_status + assert goga.history.collect_topic_statuses is collect_topic_statuses + for name in ("TopicStatus", "TopicRecord", "resolve_topic_status", "collect_topic_statuses"): + assert name in goga.history.__all__ + + def test_topic_status_fixed_value_set(self) -> None: + """Eight members; each value is the display name; lookup by value works.""" + assert [member.value for member in TopicStatus] == [ + "empty", + "defined", + "discovered", + "backlog", + "designed", + "specified", + "planned", + "done", + ] + assert TopicStatus("planned") is TopicStatus.planned + with pytest.raises(ValueError, match="is not a valid TopicStatus"): + TopicStatus("bogus") + + def test_topic_record_is_frozen_kw_only_dataclass(self) -> None: + """``@dataclass(frozen=True, kw_only=True)`` with the fields ``topic`` and ``status``.""" + assert dataclasses.is_dataclass(TopicRecord) + assert TopicRecord.__dataclass_params__.frozen is True + assert TopicRecord.__dataclass_params__.kw_only is True + assert typing.get_type_hints(TopicRecord) == {"topic": str, "status": TopicStatus} + record = TopicRecord(topic="t", status=TopicStatus.planned) + assert record.topic == "t" + assert record.status is TopicStatus.planned + with pytest.raises(dataclasses.FrozenInstanceError): + record.topic = "other" # type: ignore[misc] + with pytest.raises(TypeError): + TopicRecord("t", TopicStatus.planned) # type: ignore[misc] + + def test_resolve_topic_status_signature(self) -> None: + """``resolve_topic_status(topic_dir: Path) -> TopicStatus`` — one positional-or-keyword parameter.""" + signature = inspect.signature(resolve_topic_status) + assert list(signature.parameters) == ["topic_dir"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + hints = typing.get_type_hints(resolve_topic_status) + assert hints == {"topic_dir": Path, "return": TopicStatus} + + def test_collect_topic_statuses_signature(self) -> None: + """``collect_topic_statuses(year: str | None = None) -> list[TopicRecord]``.""" + signature = inspect.signature(collect_topic_statuses) + assert list(signature.parameters) == ["year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(collect_topic_statuses) + assert hints == {"year": str | None, "return": list[TopicRecord]} + + +# --- Logic tests --- + + +class TestResolveTopicStatus: + @pytest.mark.parametrize( + ("artifact", "expected"), + [ + ("prd.md", TopicStatus.defined), + ("adr.md", TopicStatus.discovered), + ("task.md", TopicStatus.backlog), + ("arch.md", TopicStatus.designed), + ("design.md", TopicStatus.specified), + ("plan.md", TopicStatus.planned), + ("completed/plan.md", TopicStatus.done), + ], + ) + def test_resolve_topic_status_progression( + self, + artifact: str, + expected: TopicStatus, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Each progression artifact alone resolves to its mapped status.""" + monkeypatch.chdir(tmp_path) + topic_dir = tmp_path / ".goga" / "history" / "2026" / "t" + artifact_path = topic_dir / artifact + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_text("artifact", encoding="utf-8") + assert resolve_topic_status(topic_dir) is expected + + def test_resolve_topic_status_completed_wins_over_flat( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``completed/plan.md`` outranks every flat artifact present alongside it.""" + monkeypatch.chdir(tmp_path) + topic_dir = tmp_path / ".goga" / "history" / "2026" / "t" + topic_dir.mkdir(parents=True) + (topic_dir / "prd.md").write_text("flat artifact", encoding="utf-8") + (topic_dir / "plan.md").write_text("flat artifact", encoding="utf-8") + (topic_dir / "completed").mkdir() + (topic_dir / "completed" / "plan.md").write_text("nested artifact", encoding="utf-8") + assert resolve_topic_status(topic_dir) is TopicStatus.done + + def test_resolve_topic_status_empty_when_no_artifact_present( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An empty or absent directory, and files outside the progression, resolve to empty.""" + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + empty_dir = year_dir / "empty-topic" + empty_dir.mkdir(parents=True) + assert resolve_topic_status(empty_dir) is TopicStatus.empty + assert resolve_topic_status(year_dir / "absent-topic") is TopicStatus.empty + stray_dir = year_dir / "stray-topic" + stray_dir.mkdir(parents=True) + (stray_dir / "notes.md").write_text("outside the progression", encoding="utf-8") + assert resolve_topic_status(stray_dir) is TopicStatus.empty + + +class TestCollectTopicStatuses: + def test_collect_topic_statuses_sorted_records( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Only directories count as topics; records are sorted with resolved statuses.""" + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "zeta").mkdir(parents=True) + (year_dir / "alpha").mkdir(parents=True) + (year_dir / "alpha" / "plan.md").write_text("plan", encoding="utf-8") + (year_dir / "mid").mkdir(parents=True) + (year_dir / "mid" / "prd.md").write_text("prd", encoding="utf-8") + (year_dir / "stray.txt").write_text("not a topic", encoding="utf-8") + records = collect_topic_statuses(year="2026") + assert [record.topic for record in records] == ["alpha", "mid", "zeta"] + assert records[0].status is TopicStatus.planned + assert records[1].status is TopicStatus.defined + assert records[2].status is TopicStatus.empty + + def test_collect_topic_statuses_absent_year_empty( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An absent year yields an empty list — not an error, and nothing is created.""" + monkeypatch.chdir(tmp_path) + assert collect_topic_statuses(year="1999") == [] + assert not (tmp_path / ".goga").exists() + + def test_collect_topic_statuses_empty_year_string_means_current( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A falsy year means the current year — not the history root's year children.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".goga" / "history" / "2025" / "old-topic").mkdir(parents=True) + (tmp_path / ".goga" / "history" / "2031" / "t").mkdir(parents=True) + with mock.patch.object(naming, "datetime", _FixedClock): + records = collect_topic_statuses(year="") + assert [record.topic for record in records] == ["t"] + assert "2025" not in [record.topic for record in records] + assert "2031" not in [record.topic for record in records] From ec976eae79a7b50cc50e6bbe7a366f3579b8ecab Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 21:23:19 +0000 Subject: [PATCH 051/229] feat: add goga/history/tree module with HistoryYear and collect_history_tree --- goga/history/__init__.py | 11 +++-- goga/history/tree.py | 59 +++++++++++++++++++++++++ tests/history/test_tree.py | 89 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 goga/history/tree.py create mode 100644 tests/history/test_tree.py diff --git a/goga/history/__init__.py b/goga/history/__init__.py index b15e1d90..d7b112da 100644 --- a/goga/history/__init__.py +++ b/goga/history/__init__.py @@ -1,19 +1,22 @@ """History domain cell — the owner of the ``.goga/history/`` tree. The package facade is assembled incrementally per module (the naming, paths, -and status modules here; the git leaf cell ``goga.history.git`` is physically -nested inside this directory and re-exported later). The full 13-name -contract surface of the naming, paths, status, and tree modules plus the -embedded git routine is finalized by the dedicated facade task. +status, and tree modules here; the git leaf cell ``goga.history.git`` is +physically nested inside this directory and re-exported later). The full +13-name contract surface of these modules plus the embedded git routine is +finalized by the dedicated facade task. """ from .naming import current_year, normalize_topic_slug from .paths import ensure_topic_dir, resolve_topic_dir, resolve_topic_file, topic_exists from .status import TopicRecord, TopicStatus, collect_topic_statuses, resolve_topic_status +from .tree import HistoryYear, collect_history_tree __all__: list[str] = [ + "HistoryYear", "TopicRecord", "TopicStatus", + "collect_history_tree", "collect_topic_statuses", "current_year", "ensure_topic_dir", diff --git a/goga/history/tree.py b/goga/history/tree.py new file mode 100644 index 00000000..4ae0e0f1 --- /dev/null +++ b/goga/history/tree.py @@ -0,0 +1,59 @@ +"""History tree inventory for the history domain. + +The entities declared in the cell CODEMANIFEST with ``location: tree.py``: +the per-year record of the tree listing and the full-tree collector. The +collector is read-only and carries names only — statuses belong to the +status module, filtering and rendering to the consumer. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .paths import _history_root + +_YEAR_NAME_LENGTH = 4 + + +@dataclass(frozen=True, kw_only=True) +class HistoryYear: + """One year of the history tree paired with its topic names. + + Attributes: + year: The year as four digits — the directory name of the year. + topics: The topic slugs found under the year, sorted alphabetically. + """ + + year: str + topics: list[str] + + +def collect_history_tree() -> list[HistoryYear]: + """Collect the full history tree — every year with its topics. + + A year directory is a directory named with exactly four ASCII digits — + anything else in the history root is ignored (the ASCII filter matters: + some non-ASCII digit strings still satisfy ``str.isdigit()``). Only + directories count as topics; stray files are ignored on both levels. + + Returns: + One ``HistoryYear`` per year — years sorted ascending, topics within + a year sorted alphabetically. An absent history root yields an empty + list, not an error. Read-only — nothing is created, and no status is + computed: the tree carries topic names only. + """ + root = _history_root() + if not root.is_dir(): + return [] + years = sorted( + path.name + for path in root.iterdir() + if path.is_dir() and len(path.name) == _YEAR_NAME_LENGTH and path.name.isascii() and path.name.isdigit() + ) + return [ + HistoryYear( + year=year, + topics=sorted(entry.name for entry in (root / year).iterdir() if entry.is_dir()), + ) + for year in years + ] diff --git a/tests/history/test_tree.py b/tests/history/test_tree.py new file mode 100644 index 00000000..e23b771b --- /dev/null +++ b/tests/history/test_tree.py @@ -0,0 +1,89 @@ +"""Contract and logic tests for the entities declared in +``goga/history/CODEMANIFEST`` with ``location: tree.py``: + +- ``HistoryYear(year: str, topics: list[str])`` +- ``collect_history_tree() -> tree: list[HistoryYear]`` + +The collector is read-only with respect to the filesystem and carries names +only — no statuses are resolved and the clock is never read. Filesystem +fixtures use ``tmp_path`` + ``monkeypatch.chdir``; no mocks are needed. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import typing +from pathlib import Path + +import pytest +from goga.history import tree +from goga.history.tree import HistoryYear, collect_history_tree + +# --- Contract tests --- + + +class TestTreeContract: + def test_entities_are_importable_from_module_and_callable(self) -> None: + """Both entities are importable from ``goga.history.tree``.""" + assert tree.HistoryYear is HistoryYear + assert callable(collect_history_tree) + assert tree.collect_history_tree is collect_history_tree + + def test_facade_reexports_the_tree_names(self) -> None: + """The tree entities are importable from the domain facade.""" + import goga.history + + assert goga.history.HistoryYear is HistoryYear + assert goga.history.collect_history_tree is collect_history_tree + for name in ("HistoryYear", "collect_history_tree"): + assert name in goga.history.__all__ + + def test_history_year_is_frozen_kw_only_dataclass(self) -> None: + """``@dataclass(frozen=True, kw_only=True)`` with the fields ``year`` and ``topics``.""" + assert dataclasses.is_dataclass(HistoryYear) + assert HistoryYear.__dataclass_params__.frozen is True + assert HistoryYear.__dataclass_params__.kw_only is True + assert typing.get_type_hints(HistoryYear) == {"year": str, "topics": list[str]} + record = HistoryYear(year="2026", topics=["history-commands"]) + assert record.year == "2026" + assert record.topics == ["history-commands"] + with pytest.raises(dataclasses.FrozenInstanceError): + record.year = "2025" # type: ignore[misc] + with pytest.raises(TypeError): + HistoryYear("2026", []) # type: ignore[misc] + + def test_collect_history_tree_signature(self) -> None: + """``collect_history_tree() -> list[HistoryYear]`` — no parameters.""" + signature = inspect.signature(collect_history_tree) + assert list(signature.parameters) == [] + hints = typing.get_type_hints(collect_history_tree) + assert hints == {"return": list[HistoryYear]} + + +# --- Logic tests --- + + +class TestCollectHistoryTree: + def test_collect_history_tree_full_shape(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Four-ASCII-digit year directories ascending; topics alphabetical; everything else ignored.""" + monkeypatch.chdir(tmp_path) + root = tmp_path / ".goga" / "history" + (root / "2025" / "b-topic").mkdir(parents=True) + (root / "2025" / "a-topic").mkdir() + (root / "2025" / "notes.md").write_text("stray file in a year directory", encoding="utf-8") + (root / "2026" / "history-commands").mkdir(parents=True) + (root / "backups").mkdir() + (root / "20a6").mkdir() + (root / "²⁰²⁶").mkdir() # isdigit() is True, isascii() is not — not a year + (root / "notes.md").write_text("not a year", encoding="utf-8") + collected = collect_history_tree() + assert [year_record.year for year_record in collected] == ["2025", "2026"] + assert collected[0].topics == ["a-topic", "b-topic"] + assert collected[1].topics == ["history-commands"] + + def test_collect_history_tree_absent_root_empty(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An absent history root yields an empty list — not an error, and nothing is created.""" + monkeypatch.chdir(tmp_path) + assert collect_history_tree() == [] + assert not (tmp_path / ".goga").exists() From 57b72dcbb12e6b3f8a58a9aa087622b7f926543a Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 21:25:52 +0000 Subject: [PATCH 052/229] feat: finalize goga/history facade with git embedding and 13-name __all__ --- goga/history/__init__.py | 14 ++++++------ tests/history/test_facade.py | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 tests/history/test_facade.py diff --git a/goga/history/__init__.py b/goga/history/__init__.py index d7b112da..3ca1e921 100644 --- a/goga/history/__init__.py +++ b/goga/history/__init__.py @@ -1,12 +1,13 @@ -"""History domain cell — the owner of the ``.goga/history/`` tree. +"""History domain cell — the single owner of the ``.goga/history/`` tree. -The package facade is assembled incrementally per module (the naming, paths, -status, and tree modules here; the git leaf cell ``goga.history.git`` is -physically nested inside this directory and re-exported later). The full -13-name contract surface of these modules plus the embedded git routine is -finalized by the dedicated facade task. +Topic identity (the slug grammar and the current year), topic addressing +(directory and artifact file paths, existence, creation), the topic status +model, and tree traversal. The git branch reader lives in the nested leaf cell +``goga.history.git`` and is re-exported on this facade — the embedding +declared in ``goga/history/CODEMANIFEST``. """ +from .git import resolve_current_branch_name from .naming import current_year, normalize_topic_slug from .paths import ensure_topic_dir, resolve_topic_dir, resolve_topic_file, topic_exists from .status import TopicRecord, TopicStatus, collect_topic_statuses, resolve_topic_status @@ -21,6 +22,7 @@ "current_year", "ensure_topic_dir", "normalize_topic_slug", + "resolve_current_branch_name", "resolve_topic_dir", "resolve_topic_file", "resolve_topic_status", diff --git a/tests/history/test_facade.py b/tests/history/test_facade.py new file mode 100644 index 00000000..275e452f --- /dev/null +++ b/tests/history/test_facade.py @@ -0,0 +1,42 @@ +"""Facade contract test for the ``goga/history`` domain cell. + +The cell CODEMANIFEST declares thirteen facade names: the twelve domain types +and routines of the ``naming``/``paths``/``status``/``tree`` modules plus the +git branch reader embedded from the nested ``goga.history.git`` leaf cell (the +``->resolve_current_branch_name: {}`` re-export). +""" + +from __future__ import annotations + +import goga.history + +_HISTORY_FACADE_ALL = [ + "HistoryYear", + "TopicRecord", + "TopicStatus", + "collect_history_tree", + "collect_topic_statuses", + "current_year", + "ensure_topic_dir", + "normalize_topic_slug", + "resolve_current_branch_name", + "resolve_topic_dir", + "resolve_topic_file", + "resolve_topic_status", + "topic_exists", +] + + +class TestHistoryFacade: + def test_history_facade_exports_thirteen_names(self) -> None: + """The facade ``__all__`` is exactly the thirteen contract names, alphabetical.""" + assert goga.history.__all__ == _HISTORY_FACADE_ALL + for name in _HISTORY_FACADE_ALL: + assert hasattr(goga.history, name), f"{name} is not defined on goga.history" + + def test_history_facade_embeds_the_git_branch_reader(self) -> None: + """The embedded routine is the git leaf cell's object, not a copy.""" + assert ( + goga.history.resolve_current_branch_name + is goga.history.git.resolve_current_branch_name + ) From ce107741d2eebbdaa7fc2a8f30be606ffb5e1c68 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 21:31:24 +0000 Subject: [PATCH 053/229] feat: migrate pipeline cell atomically to the goga.history domain (two-param occupancy, 7-name facade, dual-point git mock) --- goga/commands/pipeline/__init__.py | 9 +- goga/commands/pipeline/branch.py | 111 ++---- tests/commands/pipeline/test_branch.py | 355 ++++++++---------- .../pipeline/test_pipeline_command.py | 20 +- 4 files changed, 187 insertions(+), 308 deletions(-) diff --git a/goga/commands/pipeline/__init__.py b/goga/commands/pipeline/__init__.py index 03575008..405eb759 100644 --- a/goga/commands/pipeline/__init__.py +++ b/goga/commands/pipeline/__init__.py @@ -1,11 +1,6 @@ """Pipeline command cell — host-side launcher for the single goga pipeline command.""" -from .branch import ( - check_branch_occupancy, - ensure_pipeline_branch, - normalize_topic_slug, - resolve_current_branch_name, -) +from .branch import check_branch_occupancy, ensure_pipeline_branch from .pipeline import pipeline from .run_pipeline_container import ( clean_pipeline_runtime_dir, @@ -18,9 +13,7 @@ "check_branch_occupancy", "clean_pipeline_runtime_dir", "ensure_pipeline_branch", - "normalize_topic_slug", "pipeline", - "resolve_current_branch_name", "resolve_pipeline_runtime_dir", "run_pipeline_container", "run_pipeline_info_container", diff --git a/goga/commands/pipeline/branch.py b/goga/commands/pipeline/branch.py index f30c2091..5bf71dfa 100644 --- a/goga/commands/pipeline/branch.py +++ b/goga/commands/pipeline/branch.py @@ -1,10 +1,11 @@ """Host-side branch routines for the ``-b/--branch`` procedure of ``goga pipeline``. -The four branch routines declared in the cell CODEMANIFEST with ``location: -branch.py``: the pure slug transformer, the git current-branch reader, the -three-oracle occupancy check, and the orchestrator of the whole branch -procedure. Every git invocation follows the ``git`` practice — -``subprocess.run`` with ``check=True``, captured output, and +The two branch routines declared in the cell CODEMANIFEST with ``location: +branch.py``: the three-oracle occupancy check and the orchestrator of the +whole branch procedure. The slug transformer and the git current-branch +reader come from the history domain (``goga.history``) via the cell Imports — +this module holds no local copies. Every git invocation follows the ``git`` +practice — ``subprocess.run`` with ``check=True``, captured output, and ``GIT_TERMINAL_PROMPT=0`` in the environment. The oracles are read-only; the single host-side mutation (create-and-switch) is owned by ``ensure_pipeline_branch``. @@ -13,14 +14,13 @@ from __future__ import annotations import os -import re import subprocess import sys -from datetime import datetime -from pathlib import Path import click +from ...history import normalize_topic_slug, resolve_current_branch_name, topic_exists + _GIT_REQUIRED_MESSAGE = "git is required for -b/--branch: git binary not found" _REASK_HINT = "Pass another branch name via -b." @@ -45,71 +45,7 @@ def _reask_branch_name(reason: str) -> str: return click.prompt("New branch name") -def normalize_topic_slug(name: str) -> str: - """Normalize a branch name into the history topic slug. - - Deterministic pure string transformation: lowercase the name, drop every - non-ASCII character (no transliteration), replace each remaining character - outside ``[a-z0-9]`` with a hyphen, collapse repeat hyphens into one, and - trim leading and trailing hyphens. Lowercasing happens BEFORE the ASCII - filter, so a name like ``"aБb"`` yields ``"ab"`` and the Turkish dotted - capital ``"İ"`` lowercases to ``"i"`` plus a combining dot that the filter - drops. - - A fully non-ASCII or all-separator name yields the empty string — a valid - output. No fallback is returned for an empty result; the caller owns the - empty-slug decision. - - Args: - name: Branch name as entered by the user. - - Returns: - The history topic slug (possibly empty). No git, no filesystem, no - side effects. - """ - lowered = name.lower() - ascii_only = "".join(character for character in lowered if character.isascii()) - hyphened = re.sub(r"[^a-z0-9]", "-", ascii_only) - collapsed = re.sub(r"-{2,}", "-", hyphened) - return collapsed.strip("-") - - -def resolve_current_branch_name() -> str | None: - """Read the current git branch name exactly as git reports it. - - Asks git via ``git branch --show-current`` (per the ``git`` practice) and - returns the stripped answer unmodified — no slugification, no fallback - value; both belong to the caller. ``None`` covers only the three documented - failure modes: detached HEAD (an empty git answer), a missing git binary - (``FileNotFoundError``), and a non-repository (a non-zero git exit). - Read-only; the result is not cached — each call asks git anew. - - Returns: - The raw current branch name (stripped, unmodified), or ``None`` when it - cannot be determined. - - Raises: - OSError: unexpected OS-level failures of the git invocation (e.g. a - ``PermissionError``); the ``None`` result covers only the - documented failure modes. - """ - try: - result = subprocess.run( - ["git", "branch", "--show-current"], - check=True, - capture_output=True, - text=True, - env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, - ) - except (FileNotFoundError, subprocess.CalledProcessError): - return None - value = result.stdout.strip() - if value == "": - return None - return value - - -def check_branch_occupancy(branch_name: str, slug: str, history_year: str) -> str | None: +def check_branch_occupancy(branch_name: str, slug: str) -> str | None: """Decide whether the entered branch name and the topic slug are free. Probes three oracles in order and returns the human-readable reason of the @@ -120,8 +56,10 @@ def check_branch_occupancy(branch_name: str, slug: str, history_year: str) -> st ``/``); 2. a remote-tracking ref for ``branch_name`` (local ``git for-each-ref refs/remotes`` output only — no network call); - 3. the history topic folder ``.goga/history/<history_year>/<slug>`` — only - a DIRECTORY occupies a topic (a stray file named ``<slug>`` does not). + 3. the history topic for ``slug`` via the domain oracle ``topic_exists`` + (the current year is resolved inside the domain — this routine owns no + clock; only a DIRECTORY occupies a topic, a stray file named + ``<slug>`` does not). The git oracles check the name as entered; the history oracle checks the slug — the two may deliberately differ (``release/1.3.0`` vs @@ -132,7 +70,6 @@ def check_branch_occupancy(branch_name: str, slug: str, history_year: str) -> st Args: branch_name: Branch name as entered (checked against git refs). slug: Normalized topic slug (checked against the history folder). - history_year: Current year as ``YYYY`` (the caller owns the clock). Returns: The human-readable reason of the first occupied oracle, or ``None`` @@ -177,19 +114,19 @@ def check_branch_occupancy(branch_name: str, slug: str, history_year: str) -> st if separator and branch == branch_name: return f"remote-tracking branch '{branch_name}' already exists" - # Oracle 3 — history topic folder. Only a directory occupies a topic. - topic_dir = Path.cwd() / ".goga" / "history" / history_year / slug - if topic_dir.is_dir(): - return f"history topic '.goga/history/{history_year}/{slug}' already exists" + # Oracle 3 — history topic, via the domain oracle (the year is resolved + # inside the domain). Only a directory occupies a topic. + if topic_exists(slug): + return f"history topic '{slug}' already exists for the current year" return None def ensure_pipeline_branch(branch_name: str) -> str: """Bring the project onto a fresh branch with a fresh history topic. - Composes the three primitives above: normalize the entered name into the - topic slug, read the current branch, and check occupancy against the three - oracles. A free name is created and switched to on the host exactly as + Composes the domain primitives (the slug transformer and the git + current-branch reader from ``goga.history``) with the occupancy check + above. A free name is created and switched to on the host exactly as entered (``git switch -c`` — the single mutation; git owns name validity). An unusable name (an empty slug or an occupancy conflict) re-asks on a terminal — the cycle restarts from the top and validates the NEW name @@ -226,13 +163,7 @@ def ensure_pipeline_branch(branch_name: str) -> str: return current try: - # Local-timezone year — the history tree is organized by the host's - # calendar year; the bare now() shape is the mandated test mock target. - conflict = check_branch_occupancy( - branch_name, - slug, - f"{datetime.now().year:04d}", # noqa: DTZ005 - ) + conflict = check_branch_occupancy(branch_name, slug) except FileNotFoundError as exc: raise click.ClickException(_GIT_REQUIRED_MESSAGE) from exc except subprocess.CalledProcessError as exc: diff --git a/tests/commands/pipeline/test_branch.py b/tests/commands/pipeline/test_branch.py index 6601a756..fd332a11 100644 --- a/tests/commands/pipeline/test_branch.py +++ b/tests/commands/pipeline/test_branch.py @@ -1,23 +1,31 @@ """Contract and logic tests for the branch routines declared in ``goga/commands/pipeline/CODEMANIFEST`` with ``location: branch.py``: -- ``normalize_topic_slug(name: str) -> str`` — pure slug transformer -- ``resolve_current_branch_name() -> str | None`` — git reader with the three - documented None modes (detached HEAD, missing git binary, non-repository) -- ``check_branch_occupancy(branch_name, slug, history_year) -> str | None`` — - three-oracle occupancy check (local ref, remote-tracking ref, history topic) +- ``check_branch_occupancy(branch_name, slug) -> str | None`` — three-oracle + occupancy check (local ref, remote-tracking ref, history topic via the + domain oracle ``topic_exists``) - ``ensure_pipeline_branch(branch_name: str) -> str`` — the branch-procedure orchestrator (re-ask cycle, non-terminal abort, no-git-host conversion, the single create-and-switch mutation) -Git is mocked at the subprocess boundary per the ``git`` practice — -``mock.patch.object(branch_module.subprocess, "run")`` — never as a git double. +The slug transformer and the git current-branch reader are NOT local anymore: +they are Imported from the history domain (``goga.history``) and only their +identity with the domain facade is asserted here — their behavior suites live +in ``tests/history/``. + +Git is mocked at the subprocess boundary per the ``git`` practice — one +``run`` dispatcher laid over BOTH invocation points at once +(``goga.history.git.branch`` and this cell's ``branch`` module) via +``contextlib.ExitStack``; mocking only one of them would release real git into +the test. """ from __future__ import annotations +import contextlib import subprocess import typing +from collections.abc import Iterator from datetime import datetime from pathlib import Path from unittest import mock @@ -26,6 +34,8 @@ import pytest from click.testing import CliRunner from goga.commands.pipeline import branch as branch_module +from goga.history import naming as history_naming +from goga.history.git import branch as history_git_branch_module # --- Git subprocess mocking helpers (the process boundary only) --- @@ -79,62 +89,53 @@ def _run(argv: list[str], **_kwargs: object) -> _GitResult: return mock.Mock(side_effect=_run) +@contextlib.contextmanager +def _git_on_both_points(run_mock: mock.Mock) -> Iterator[mock.Mock]: + """Lay one ``run`` dispatcher over BOTH git invocation points at once. + + ``ensure_pipeline_branch`` spans two modules after the domain migration: + ``--show-current`` runs in ``goga.history.git.branch`` while ``show-ref``, + ``for-each-ref``, and ``switch`` run in this cell's ``branch`` module. A + mock on only one of the two points would let the other run real git. + """ + with contextlib.ExitStack() as stack: + stack.enter_context(mock.patch.object(history_git_branch_module.subprocess, "run", run_mock)) + stack.enter_context(mock.patch.object(branch_module.subprocess, "run", run_mock)) + yield run_mock + + +class _FixedClock: + """Stand-in for ``datetime`` answering a fixed naive date.""" + + @staticmethod + def now() -> datetime: + return datetime(2031, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock + + +def _switch_argv_calls(run_mock: mock.Mock) -> list[list[str]]: + """The recorded ``git switch`` argvs (usually asserted to be empty).""" + return [call.args[0] for call in run_mock.call_args_list if call.args[0][1] == "switch"] + + # --- Contract tests --- class TestBranchContract: - def test_three_primitives_exist_and_are_callable(self) -> None: - """The three routines are defined on the branch module and callable.""" - assert callable(branch_module.normalize_topic_slug) - assert callable(branch_module.resolve_current_branch_name) + def test_branch_routines_exist_and_are_callable(self) -> None: + """The two routines are defined on the branch module and callable.""" assert callable(branch_module.check_branch_occupancy) - - def test_normalize_topic_slug_signature(self) -> None: - """``normalize_topic_slug(name: str) -> str``.""" - hints = typing.get_type_hints(branch_module.normalize_topic_slug) - assert hints == {"name": str, "return": str} - - def test_resolve_current_branch_name_signature(self) -> None: - """``resolve_current_branch_name() -> str | None``.""" - hints = typing.get_type_hints(branch_module.resolve_current_branch_name) - assert hints["return"] == str | None + assert callable(branch_module.ensure_pipeline_branch) def test_check_branch_occupancy_signature(self) -> None: - """``check_branch_occupancy(branch_name: str, slug: str, history_year: str) -> str | None``.""" + """``check_branch_occupancy(branch_name: str, slug: str) -> str | None``.""" import inspect signature = inspect.signature(branch_module.check_branch_occupancy) - assert list(signature.parameters) == ["branch_name", "slug", "history_year"] + assert list(signature.parameters) == ["branch_name", "slug"] for parameter in signature.parameters.values(): assert parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD hints = typing.get_type_hints(branch_module.check_branch_occupancy) - assert hints == { - "branch_name": str, - "slug": str, - "history_year": str, - "return": str | None, - } - - def test_resolve_current_branch_name_signature_parameters(self) -> None: - """``resolve_current_branch_name`` takes no parameters.""" - import inspect - - signature = inspect.signature(branch_module.resolve_current_branch_name) - assert list(signature.parameters) == [] - hints = typing.get_type_hints(branch_module.resolve_current_branch_name) - assert hints == {"return": str | None} - - def test_normalize_topic_slug_parameters(self) -> None: - """``normalize_topic_slug`` takes one positional-or-keyword ``name``.""" - import inspect - - signature = inspect.signature(branch_module.normalize_topic_slug) - assert list(signature.parameters) == ["name"] - assert signature.parameters["name"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - - def test_ensure_pipeline_branch_exists_and_is_callable(self) -> None: - """The orchestrator is defined on the branch module and callable.""" - assert callable(branch_module.ensure_pipeline_branch) + assert hints == {"branch_name": str, "slug": str, "return": str | None} def test_ensure_pipeline_branch_signature(self) -> None: """``ensure_pipeline_branch(branch_name: str) -> str``.""" @@ -146,85 +147,26 @@ def test_ensure_pipeline_branch_signature(self) -> None: hints = typing.get_type_hints(branch_module.ensure_pipeline_branch) assert hints == {"branch_name": str, "return": str} - def test_ensure_pipeline_branch_free_name_returns_entered_name( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """A free name creates-and-switches and returns the entered name (str → str).""" - monkeypatch.chdir(tmp_path) - run_mock = _git_run_dispatch( - show_current=_GitResult(stdout="main\n"), - show_ref=subprocess.CalledProcessError(1, "git"), - for_each_ref=_GitResult(stdout=""), - switch=_GitResult(returncode=0), - ) - with mock.patch.object(branch_module.subprocess, "run", run_mock): - assert branch_module.ensure_pipeline_branch("feat/x") == "feat/x" - - -# --- Logic tests: normalize_topic_slug (pure transformer) --- + def test_moved_routines_are_bound_to_the_domain_not_local_copies(self) -> None: + """The moved names resolve to the DOMAIN objects — a local ``def`` copy would differ.""" + import goga.history + assert branch_module.normalize_topic_slug is goga.history.normalize_topic_slug + assert branch_module.resolve_current_branch_name is goga.history.resolve_current_branch_name -class TestNormalizeTopicSlug: - @pytest.mark.parametrize( - ("name", "expected"), - [ - ("Feature/Foo_Bar", "feature-foo-bar"), - ("release/1.3.0", "release-1-3-0"), - ("Релиз/Один", ""), - ("aБb", "ab"), - ("-a--b-", "a-b"), - ("My Tool", "my-tool"), - ("feat///x", "feat-x"), - ("UPPER", "upper"), - ("123", "123"), - ], - ) - def test_normalize_topic_slug_parametrized(self, name: str, expected: str) -> None: - """The grammar rows from the contract — deterministic pure transform.""" - assert branch_module.normalize_topic_slug(name) == expected + def test_pipeline_facade_all_without_moved_names(self) -> None: + """The package facade exports exactly the seven names — the moved routines are gone.""" + from goga.commands.pipeline import __all__ as facade_all - def test_normalize_topic_slug_empty_result_is_valid_output(self) -> None: - """A fully non-ASCII name yields "" — no fallback, no raise.""" - assert branch_module.normalize_topic_slug("Релиз/Один") == "" - - -# --- Logic tests: resolve_current_branch_name (git reader) --- - - -class TestResolveCurrentBranchName: - def test_resolve_current_branch_name_returns_stripped_raw_name(self) -> None: - """The raw branch name is returned stripped and unmodified (no slugification).""" - result = _GitResult(returncode=0, stdout=" release/1.3.0\n") - with mock.patch.object(branch_module.subprocess, "run", return_value=result) as run_mock: - branch = branch_module.resolve_current_branch_name() - assert branch == "release/1.3.0" - assert run_mock.call_args.args[0] == ["git", "branch", "--show-current"] - assert run_mock.call_args.kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0" - - def test_resolve_current_branch_name_detached_head_returns_none(self) -> None: - """Detached HEAD — an empty git answer — yields None.""" - result = _GitResult(returncode=0, stdout="") - with mock.patch.object(branch_module.subprocess, "run", return_value=result): - assert branch_module.resolve_current_branch_name() is None - - def test_resolve_current_branch_name_not_a_repository_returns_none(self) -> None: - """A non-repository (non-zero git exit) yields None.""" - error = subprocess.CalledProcessError(128, "git") - with mock.patch.object(branch_module.subprocess, "run", side_effect=error): - assert branch_module.resolve_current_branch_name() is None - - def test_resolve_current_branch_name_missing_git_binary_returns_none(self) -> None: - """A missing git binary yields None.""" - with mock.patch.object(branch_module.subprocess, "run", side_effect=FileNotFoundError("git")): - assert branch_module.resolve_current_branch_name() is None - - def test_resolve_current_branch_name_unexpected_os_error_propagates(self) -> None: - """Unexpected OS-level failures are NOT swallowed — PermissionError propagates.""" - with ( - mock.patch.object(branch_module.subprocess, "run", side_effect=PermissionError("denied")), - pytest.raises(PermissionError), - ): - branch_module.resolve_current_branch_name() + assert facade_all == [ + "check_branch_occupancy", + "clean_pipeline_runtime_dir", + "ensure_pipeline_branch", + "pipeline", + "resolve_pipeline_runtime_dir", + "run_pipeline_container", + "run_pipeline_info_container", + ] # --- Logic tests: check_branch_occupancy (three oracles) --- @@ -238,8 +180,8 @@ def test_check_branch_occupancy_local_ref_reports_reason(self) -> None: show_ref=_GitResult(returncode=0), for_each_ref=_GitResult(stdout="refs/remotes/origin/feat/x\n"), ) - with mock.patch.object(branch_module.subprocess, "run", run_mock): - conflict = branch_module.check_branch_occupancy("feat/x", "feat-x", "2026") + with _git_on_both_points(run_mock): + conflict = branch_module.check_branch_occupancy("feat/x", "feat-x") assert conflict == "branch 'feat/x' already exists" probed = [call.args[0] for call in run_mock.call_args_list] assert all(argv[1] != "for-each-ref" for argv in probed) @@ -251,8 +193,8 @@ def test_check_branch_occupancy_remote_tracking_ref_reports_reason(self) -> None show_ref=subprocess.CalledProcessError(1, "git"), for_each_ref=_GitResult(stdout="refs/remotes/origin/feat/x\nrefs/remotes/origin/main\n"), ) - with mock.patch.object(branch_module.subprocess, "run", run_mock): - conflict = branch_module.check_branch_occupancy("feat/x", "feat-x", "2026") + with _git_on_both_points(run_mock): + conflict = branch_module.check_branch_occupancy("feat/x", "feat-x") assert conflict == "remote-tracking branch 'feat/x' already exists" def test_check_branch_occupancy_remote_ref_no_prefix_match( @@ -265,41 +207,54 @@ def test_check_branch_occupancy_remote_ref_no_prefix_match( show_ref=subprocess.CalledProcessError(1, "git"), for_each_ref=_GitResult(stdout="refs/remotes/origin/feat/xy\nrefs/remotes/origin/main\n"), ) - with mock.patch.object(branch_module.subprocess, "run", run_mock): - conflict = branch_module.check_branch_occupancy("feat/x", "feat-x", "2026") + with _git_on_both_points(run_mock): + conflict = branch_module.check_branch_occupancy("feat/x", "feat-x") assert conflict is None - def test_check_branch_occupancy_history_topic_folder_reports_reason( + def test_check_branch_occupancy_two_param_topic_oracle( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Oracle 3: an existing history topic DIRECTORY (checked by slug) reports the reason.""" - (tmp_path / ".goga" / "history" / "2026" / "feat-x").mkdir(parents=True) + """Oracle 3: the DOMAIN resolves the year — two parameters, no clock here. + + The year 2031 comes from the fixed clock patched at the domain's + ``naming.datetime`` (the mandated bare-``now()`` point); this cell + computes no year of its own. The reason names the slug for the current + year — no hand-built path in the message. + """ + (tmp_path / ".goga" / "history" / "2031" / "feat-x").mkdir(parents=True) monkeypatch.chdir(tmp_path) run_mock = _git_run_dispatch( show_current=_GitResult(stdout="main\n"), show_ref=subprocess.CalledProcessError(1, "git"), for_each_ref=_GitResult(stdout=""), ) - with mock.patch.object(branch_module.subprocess, "run", run_mock): - conflict = branch_module.check_branch_occupancy("feat/x", "feat-x", "2026") - assert conflict == "history topic '.goga/history/2026/feat-x' already exists" + with ( + mock.patch.object(history_naming, "datetime", _FixedClock), + _git_on_both_points(run_mock), + ): + conflict = branch_module.check_branch_occupancy("feat/x", "feat-x") + assert conflict == "history topic 'feat-x' already exists for the current year" + assert _switch_argv_calls(run_mock) == [] - def test_check_branch_occupancy_stray_file_is_not_a_topic( + def test_check_branch_occupancy_stray_file_is_not_topic( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A stray FILE named <slug> does not occupy a topic — only a directory does.""" - (tmp_path / ".goga" / "history" / "2026" / "feat-x").parent.mkdir(parents=True) - (tmp_path / ".goga" / "history" / "2026" / "feat-x").write_text("stray") + (tmp_path / ".goga" / "history" / "2031" / "feat-x").parent.mkdir(parents=True) + (tmp_path / ".goga" / "history" / "2031" / "feat-x").write_text("stray") monkeypatch.chdir(tmp_path) run_mock = _git_run_dispatch( show_current=_GitResult(stdout="main\n"), show_ref=subprocess.CalledProcessError(1, "git"), for_each_ref=_GitResult(stdout=""), ) - with mock.patch.object(branch_module.subprocess, "run", run_mock): - assert branch_module.check_branch_occupancy("feat/x", "feat-x", "2026") is None + with ( + mock.patch.object(history_naming, "datetime", _FixedClock), + _git_on_both_points(run_mock), + ): + assert branch_module.check_branch_occupancy("feat/x", "feat-x") is None - def test_check_branch_occupancy_ref_listing_failure_propagates(self) -> None: + def test_check_branch_occupancy_oracle_listing_failure_propagates(self) -> None: """A git infrastructure failure of oracle 2 itself propagates (not an occupancy answer).""" run_mock = _git_run_dispatch( show_current=_GitResult(stdout="main\n"), @@ -307,21 +262,31 @@ def test_check_branch_occupancy_ref_listing_failure_propagates(self) -> None: for_each_ref=subprocess.CalledProcessError(128, "git", stderr="fatal: not a git repository"), ) with ( - mock.patch.object(branch_module.subprocess, "run", run_mock), + _git_on_both_points(run_mock), pytest.raises(subprocess.CalledProcessError), ): - branch_module.check_branch_occupancy("feat/x", "feat-x", "2026") + branch_module.check_branch_occupancy("feat/x", "feat-x") # --- Logic tests: ensure_pipeline_branch (the branch-procedure orchestrator) --- -def _switch_argv_calls(run_mock: mock.Mock) -> list[list[str]]: - """The recorded ``git switch`` argvs (usually asserted to be empty).""" - return [call.args[0] for call in run_mock.call_args_list if call.args[0][1] == "switch"] - - class TestEnsurePipelineBranch: + def test_ensure_pipeline_branch_end_to_end_free_name( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """One dispatcher over both points: a free name returns the entered name and switches.""" + monkeypatch.chdir(tmp_path) + run_mock = _git_run_dispatch( + show_current=_GitResult(stdout="main\n"), + show_ref=subprocess.CalledProcessError(1, "git"), + for_each_ref=_GitResult(stdout=""), + switch=_GitResult(returncode=0), + ) + with _git_on_both_points(run_mock): + assert branch_module.ensure_pipeline_branch("feat/x") == "feat/x" + assert run_mock.call_args.args[0] == ["git", "switch", "-c", "feat/x"] + def test_ensure_pipeline_branch_creates_and_switches_as_entered( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -333,14 +298,14 @@ def test_ensure_pipeline_branch_creates_and_switches_as_entered( for_each_ref=_GitResult(stdout=""), switch=_GitResult(returncode=0), ) - with mock.patch.object(branch_module.subprocess, "run", run_mock): + with _git_on_both_points(run_mock): assert branch_module.ensure_pipeline_branch("Feature/X") == "Feature/X" assert run_mock.call_args.args[0] == ["git", "switch", "-c", "Feature/X"] def test_ensure_pipeline_branch_already_on_branch_returns_current_name(self) -> None: """Slug equality with the current branch → the CURRENT name, one probe, no mutation.""" run_mock = _git_run_dispatch(show_current=_GitResult(stdout="release/1.3.0\n")) - with mock.patch.object(branch_module.subprocess, "run", run_mock): + with _git_on_both_points(run_mock): assert branch_module.ensure_pipeline_branch("release-1.3.0") == "release/1.3.0" assert run_mock.call_count == 1 assert run_mock.call_args.args[0] == ["git", "branch", "--show-current"] @@ -349,7 +314,7 @@ def test_ensure_pipeline_branch_empty_slug_no_tty_fails_with_hint(self) -> None: """Empty slug without a terminal → ClickException with the reason and the -b hint.""" run_mock = _git_run_dispatch() with ( - mock.patch.object(branch_module.subprocess, "run", run_mock), + _git_on_both_points(run_mock), mock.patch.object(branch_module.sys.stdin, "isatty", return_value=False), pytest.raises(click.ClickException) as excinfo, ): @@ -366,7 +331,7 @@ def test_ensure_pipeline_branch_empty_slug_cli_semantics_stderr_exit_1(self) -> def _probe() -> None: branch_module.ensure_pipeline_branch("Релиз") - with mock.patch.object(branch_module.subprocess, "run", _git_run_dispatch()): + with _git_on_both_points(_git_run_dispatch()): result = CliRunner().invoke(_probe, []) assert result.exit_code == 1 assert "normalizes to an empty topic slug" in result.stderr @@ -376,7 +341,7 @@ def test_ensure_pipeline_branch_conflict_no_tty_fails_with_reason(self) -> None: """A conflict without a terminal → ClickException with the oracle reason and hint.""" run_mock = _git_run_dispatch(show_ref=_GitResult(returncode=0)) with ( - mock.patch.object(branch_module.subprocess, "run", run_mock), + _git_on_both_points(run_mock), mock.patch.object(branch_module.sys.stdin, "isatty", return_value=False), pytest.raises(click.ClickException) as excinfo, ): @@ -386,6 +351,34 @@ def test_ensure_pipeline_branch_conflict_no_tty_fails_with_reason(self) -> None: assert "Pass another branch name via -b." in message assert _switch_argv_calls(run_mock) == [] + def test_ensure_pipeline_branch_history_topic_conflict_no_tty_fails( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Oracle 3 through the orchestrator: the domain topic oracle wins for the current year. + + The year is pinned at the domain's ``naming.datetime`` — the only + clock left in the procedure. The reason names the slug and the current + year, not a hand-composed path. + """ + (tmp_path / ".goga" / "history" / "2031" / "feat-x").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + run_mock = _git_run_dispatch( + show_current=_GitResult(stdout="main\n"), + show_ref=subprocess.CalledProcessError(1, "git"), + for_each_ref=_GitResult(stdout=""), + ) + with ( + mock.patch.object(history_naming, "datetime", _FixedClock), + _git_on_both_points(run_mock), + mock.patch.object(branch_module.sys.stdin, "isatty", return_value=False), + pytest.raises(click.ClickException) as excinfo, + ): + branch_module.ensure_pipeline_branch("feat/x") + message = str(excinfo.value) + assert "history topic 'feat-x' already exists for the current year" in message + assert "Pass another branch name via -b." in message + assert _switch_argv_calls(run_mock) == [] + def test_ensure_pipeline_branch_tty_reask_until_free(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """On a terminal an occupied name re-asks; the NEW name runs the FULL procedure.""" monkeypatch.chdir(tmp_path) @@ -396,7 +389,7 @@ def test_ensure_pipeline_branch_tty_reask_until_free(self, tmp_path: Path, monke switch=_GitResult(returncode=0), ) with ( - mock.patch.object(branch_module.subprocess, "run", run_mock), + _git_on_both_points(run_mock), mock.patch.object(branch_module.sys.stdin, "isatty", return_value=True), mock.patch.object(branch_module.click, "prompt", return_value="feat/two") as prompt_mock, ): @@ -408,7 +401,7 @@ def test_ensure_pipeline_branch_abort_leaves_repository_untouched(self) -> None: """Ctrl-C at the re-ask prompt propagates as click.Abort — no switch ever ran.""" run_mock = _git_run_dispatch(show_ref=_GitResult(returncode=0)) with ( - mock.patch.object(branch_module.subprocess, "run", run_mock), + _git_on_both_points(run_mock), mock.patch.object(branch_module.sys.stdin, "isatty", return_value=True), mock.patch.object(branch_module.click, "prompt", side_effect=click.Abort()), pytest.raises(click.Abort), @@ -428,7 +421,7 @@ def test_ensure_pipeline_branch_git_rejects_invalid_name_surfaces_stderr( switch=subprocess.CalledProcessError(128, "git", stderr="fatal: 'a b' is not a valid branch name"), ) with ( - mock.patch.object(branch_module.subprocess, "run", run_mock), + _git_on_both_points(run_mock), pytest.raises(click.ClickException) as excinfo, ): branch_module.ensure_pipeline_branch("a b") @@ -437,50 +430,16 @@ def test_ensure_pipeline_branch_git_rejects_invalid_name_surfaces_stderr( assert "fatal: 'a b' is not a valid branch name" in message def test_ensure_pipeline_branch_missing_git_binary_fails_cleanly(self) -> None: - """A no-git host is a clean ClickException — never a traceback.""" + """A no-git host is a clean ClickException — never a traceback (both points).""" run_mock = mock.Mock(side_effect=FileNotFoundError("git")) with ( - mock.patch.object(branch_module.subprocess, "run", run_mock), + _git_on_both_points(run_mock), pytest.raises(click.ClickException) as excinfo, ): branch_module.ensure_pipeline_branch("feat/x") assert str(excinfo.value) == "git is required for -b/--branch: git binary not found" assert _switch_argv_calls(run_mock) == [] - def test_ensure_pipeline_branch_history_topic_conflict_no_tty_fails( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Oracle 3 through the orchestrator: the topic dir is checked by SLUG for the current year. - - The year is pinned via the mandated ``branch_module.datetime`` mock - target, so the composed ``f"{datetime.now().year:04d}"`` argument is - asserted against a directory created for that exact year. - """ - - class _FixedClock: - @staticmethod - def now() -> datetime: - return datetime(2031, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock - - (tmp_path / ".goga" / "history" / "2031" / "feat-x").mkdir(parents=True) - monkeypatch.chdir(tmp_path) - run_mock = _git_run_dispatch( - show_current=_GitResult(stdout="main\n"), - show_ref=subprocess.CalledProcessError(1, "git"), - for_each_ref=_GitResult(stdout=""), - ) - with ( - mock.patch.object(branch_module.subprocess, "run", run_mock), - mock.patch.object(branch_module, "datetime", _FixedClock), - mock.patch.object(branch_module.sys.stdin, "isatty", return_value=False), - pytest.raises(click.ClickException) as excinfo, - ): - branch_module.ensure_pipeline_branch("feat/x") - message = str(excinfo.value) - assert "history topic '.goga/history/2031/feat-x' already exists" in message - assert "Pass another branch name via -b." in message - assert _switch_argv_calls(run_mock) == [] - def test_ensure_pipeline_branch_ref_listing_failure_fails_cleanly( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -497,7 +456,7 @@ def test_ensure_pipeline_branch_ref_listing_failure_fails_cleanly( for_each_ref=subprocess.CalledProcessError(128, "git", stderr="fatal: not a git repository"), ) with ( - mock.patch.object(branch_module.subprocess, "run", run_mock), + _git_on_both_points(run_mock), pytest.raises(click.ClickException) as excinfo, ): branch_module.ensure_pipeline_branch("feat/x") @@ -513,7 +472,7 @@ def test_ensure_pipeline_branch_reask_validates_new_name_fully(self) -> None: show_ref=[_GitResult(returncode=0)], ) with ( - mock.patch.object(branch_module.subprocess, "run", run_mock), + _git_on_both_points(run_mock), mock.patch.object(branch_module.sys.stdin, "isatty", return_value=True), mock.patch.object(branch_module.click, "prompt", return_value="main"), ): @@ -539,7 +498,7 @@ def test_ensure_pipeline_branch_empty_slug_tty_reasks_new_name( switch=_GitResult(returncode=0), ) with ( - mock.patch.object(branch_module.subprocess, "run", run_mock), + _git_on_both_points(run_mock), mock.patch.object(branch_module.sys.stdin, "isatty", return_value=True), mock.patch.object(branch_module.click, "prompt", return_value="feat/two") as prompt_mock, ): diff --git a/tests/commands/pipeline/test_pipeline_command.py b/tests/commands/pipeline/test_pipeline_command.py index d971e6c6..5a8a2f53 100644 --- a/tests/commands/pipeline/test_pipeline_command.py +++ b/tests/commands/pipeline/test_pipeline_command.py @@ -593,16 +593,16 @@ def test_pipeline_info_forms_ignore_run_flags(self, tmp_path: Path, monkeypatch: # --- Facade contract: goga/commands/pipeline exports the full contract API --- -# The nine names declared in the cell CODEMANIFEST — the pipeline command, the -# two container launchers, the four branch routines, and the two runtime-dir -# helpers (declared since the cell existed, exported since release 1.3.0). +# The seven names declared in the cell CODEMANIFEST — the pipeline command, the +# two container launchers, the two branch routines, and the two runtime-dir +# helpers (declared since the cell existed, exported since release 1.3.0; the +# slug transformer and the current-branch reader moved to goga.history with no +# re-export from their old location). _PIPELINE_FACADE_ALL = [ "check_branch_occupancy", "clean_pipeline_runtime_dir", "ensure_pipeline_branch", - "normalize_topic_slug", "pipeline", - "resolve_current_branch_name", "resolve_pipeline_runtime_dir", "run_pipeline_container", "run_pipeline_info_container", @@ -611,7 +611,7 @@ def test_pipeline_info_forms_ignore_run_flags(self, tmp_path: Path, monkeypatch: class TestCommandsFacadeExportsInfoLauncher: def test_commands_facade_exports_info_launcher(self) -> None: - """The package facade defines all nine public names and lists them in ``__all__``. + """The package facade defines all seven public names and lists them in ``__all__``. ``goga.commands.pipeline`` is shadowed on the ``goga.commands`` package by the pipeline Click command (see the module-level note above), so the @@ -625,7 +625,7 @@ def test_commands_facade_exports_info_launcher(self) -> None: assert name in commands_facade.__all__, f"{name} is missing from goga.commands.pipeline.__all__" def test_commands_facade_all_is_alphabetical_and_complete(self) -> None: - """``__all__`` holds exactly the nine names in alphabetical order.""" + """``__all__`` holds exactly the seven names in alphabetical order.""" commands_facade = sys.modules["goga.commands.pipeline"] assert commands_facade.__all__ == _PIPELINE_FACADE_ALL @@ -633,15 +633,13 @@ def test_cell_facades_export_full_contract_api(self) -> None: """Every declared contract name is importable from the cell facade root. The Python facade rule obliges ``goga.commands.pipeline`` to expose the - full contract API: the command, both launchers, the four ``branch.py`` + full contract API: the command, both launchers, the two ``branch.py`` routines, and the two runtime-dir helpers. """ from goga.commands.pipeline import ( check_branch_occupancy, clean_pipeline_runtime_dir, ensure_pipeline_branch, - normalize_topic_slug, - resolve_current_branch_name, resolve_pipeline_runtime_dir, run_pipeline_container, run_pipeline_info_container, @@ -655,8 +653,6 @@ def test_cell_facades_export_full_contract_api(self) -> None: assert run_pipeline_info_container is not None assert resolve_pipeline_runtime_dir is not None assert clean_pipeline_runtime_dir is not None - assert normalize_topic_slug is not None - assert resolve_current_branch_name is not None assert check_branch_occupancy is not None assert ensure_pipeline_branch is not None assert sys.modules["goga.commands.pipeline"].__all__ == _PIPELINE_FACADE_ALL From 9b6f2d3480927f55242b14a59374d4e266e91fe5 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 21:35:01 +0000 Subject: [PATCH 054/229] feat: add goga/commands/history render module with the tree and status renderers --- goga/commands/history/__init__.py | 1 + goga/commands/history/render.py | 50 +++++++++++ tests/commands/history/__init__.py | 0 tests/commands/history/test_render.py | 115 ++++++++++++++++++++++++++ 4 files changed, 166 insertions(+) create mode 100644 goga/commands/history/__init__.py create mode 100644 goga/commands/history/render.py create mode 100644 tests/commands/history/__init__.py create mode 100644 tests/commands/history/test_render.py diff --git a/goga/commands/history/__init__.py b/goga/commands/history/__init__.py new file mode 100644 index 00000000..97cd798e --- /dev/null +++ b/goga/commands/history/__init__.py @@ -0,0 +1 @@ +"""History command cell — the CLI surface of the history domain.""" diff --git a/goga/commands/history/render.py b/goga/commands/history/render.py new file mode 100644 index 00000000..078e0471 --- /dev/null +++ b/goga/commands/history/render.py @@ -0,0 +1,50 @@ +"""Console rendering for the history command group. + +The entities declared in the cell CODEMANIFEST with ``location: render.py``: +the list-view tree renderer and the flat status-view renderer. Both are pure +output — what the input carries is printed as given, never sorted, filtered, +or recomputed; the caller owns the collection and the filtering. +""" + +from __future__ import annotations + +import os + +import click + +from ...history import HistoryYear, TopicRecord + + +def render_history_tree(tree: list[HistoryYear]) -> None: + """Render the history tree as the list-view output. + + One ``YYYY/`` line per year, each topic of the year on its own indented + line under the tree marker. An empty tree renders nothing. + + Args: + tree: The collected tree — years ascending, topics alphabetical. + """ + for year_record in tree: + click.echo(f"{year_record.year}/") + for topic in year_record.topics: + click.echo(f" └── {topic}") + + +def render_topic_statuses(records: list[TopicRecord]) -> None: + """Render the status view — one flat ``topic [status]`` line per record. + + The topic prints plain with a trailing space and no newline; the bracketed + status display name follows as the one colored segment (``cyan``). A + non-empty ``NO_COLOR`` keeps the segment plain — click does not honor the + variable, so it is checked explicitly. An empty input renders nothing. + + Args: + records: The records to print — already filtered by the caller. + """ + for record in records: + click.echo(f"{record.topic} ", nl=False) + status_segment = f"[{record.status.value}]" + if os.environ.get("NO_COLOR"): + click.echo(status_segment) + else: + click.secho(status_segment, fg="cyan") diff --git a/tests/commands/history/__init__.py b/tests/commands/history/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/commands/history/test_render.py b/tests/commands/history/test_render.py new file mode 100644 index 00000000..b2040950 --- /dev/null +++ b/tests/commands/history/test_render.py @@ -0,0 +1,115 @@ +"""Contract and logic tests for the entities declared in +``goga/commands/history/CODEMANIFEST`` with ``location: render.py``: + +- ``render_history_tree(tree: list[HistoryYear])`` +- ``render_topic_statuses(records: list[TopicRecord])`` + +The renderers are pure output: what the input carries is printed as given — +no sorting, no filtering, no computation, and the year of a status record is +never printed. Output is captured with ``capsys``; the color test mocks +``click.secho`` at the import point of the render module. +""" + +from __future__ import annotations + +import inspect +import typing +from unittest import mock + +import pytest +from goga.commands.history import render +from goga.commands.history.render import render_history_tree, render_topic_statuses +from goga.history import HistoryYear, TopicRecord, TopicStatus + +# --- Contract tests --- + + +class TestRenderContract: + def test_entities_are_importable_from_module_and_callable(self) -> None: + """Both renderers are importable from ``goga.commands.history.render``.""" + assert render.render_history_tree is render_history_tree + assert render.render_topic_statuses is render_topic_statuses + assert callable(render_history_tree) + assert callable(render_topic_statuses) + + def test_render_history_tree_signature(self) -> None: + """``render_history_tree(tree: list[HistoryYear]) -> None``.""" + signature = inspect.signature(render_history_tree) + assert list(signature.parameters) == ["tree"] + assert signature.parameters["tree"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + hints = typing.get_type_hints(render_history_tree) + assert hints == {"tree": list[HistoryYear], "return": type(None)} + + def test_render_topic_statuses_signature(self) -> None: + """``render_topic_statuses(records: list[TopicRecord]) -> None``.""" + signature = inspect.signature(render_topic_statuses) + assert list(signature.parameters) == ["records"] + assert signature.parameters["records"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + hints = typing.get_type_hints(render_topic_statuses) + assert hints == {"records": list[TopicRecord], "return": type(None)} + + +# --- Logic tests --- + + +class TestRenderHistoryTree: + def test_render_history_tree_lines(self, capsys: pytest.CaptureFixture[str]) -> None: + """Each year prints ``YYYY/`` and each topic an indented marker line under it.""" + tree = [ + HistoryYear(year="2025", topics=["a-topic", "b-topic"]), + HistoryYear(year="2026", topics=["history-commands"]), + ] + render_history_tree(tree) + assert capsys.readouterr().out == "2025/\n └── a-topic\n └── b-topic\n2026/\n └── history-commands\n" + + def test_render_history_tree_empty_input_prints_nothing(self, capsys: pytest.CaptureFixture[str]) -> None: + """An empty tree renders not a single line.""" + render_history_tree([]) + assert capsys.readouterr().out == "" + + def test_render_history_tree_does_not_mutate_input(self, capsys: pytest.CaptureFixture[str]) -> None: + """Topics print in the given order — the renderer does not re-sort or mutate.""" + tree = [HistoryYear(year="2025", topics=["z-topic", "a-topic"])] + render_history_tree(tree) + capsys.readouterr() + assert tree[0].topics == ["z-topic", "a-topic"] + + +class TestRenderTopicStatuses: + def test_render_topic_statuses_no_color_plain( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """A non-empty NO_COLOR keeps every segment plain — no ANSI escapes.""" + monkeypatch.setenv("NO_COLOR", "1") + render_topic_statuses([TopicRecord(topic="t", status=TopicStatus.planned)]) + captured = capsys.readouterr() + assert captured.out.strip() == "t [planned]" + assert "\x1b" not in captured.out + + def test_render_topic_statuses_colors_status_segment( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """One color on the status segment; the topic stays plain with no newline.""" + monkeypatch.delenv("NO_COLOR", raising=False) + with mock.patch.object(render.click, "secho") as secho_mock: + render_topic_statuses([TopicRecord(topic="t", status=TopicStatus.planned)]) + assert secho_mock.call_args == mock.call("[planned]", fg="cyan") + assert capsys.readouterr().out == "t " + + def test_render_topic_statuses_empty_input_prints_nothing(self, capsys: pytest.CaptureFixture[str]) -> None: + """Empty records render not a single line — an empty result is not an error.""" + render_topic_statuses([]) + assert capsys.readouterr().out == "" + + def test_render_topic_statuses_keeps_input_order( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """Records print in the given order — the renderer neither sorts nor filters.""" + monkeypatch.setenv("NO_COLOR", "1") + records = [ + TopicRecord(topic="zeta", status=TopicStatus.empty), + TopicRecord(topic="alpha", status=TopicStatus.done), + ] + render_topic_statuses(records) + assert capsys.readouterr().out == "zeta [empty]\nalpha [done]\n" + assert [record.topic for record in records] == ["zeta", "alpha"] From ed7633322c586f10140113858f0b4639dc48ffcd Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 21:39:42 +0000 Subject: [PATCH 055/229] feat: add goga history click group with list/status/path/ensure subcommands and finalize the cell facade --- goga/commands/history/__init__.py | 5 + goga/commands/history/history.py | 168 +++++++++++++++++++++ tests/commands/history/test_history.py | 193 +++++++++++++++++++++++++ 3 files changed, 366 insertions(+) create mode 100644 goga/commands/history/history.py create mode 100644 tests/commands/history/test_history.py diff --git a/goga/commands/history/__init__.py b/goga/commands/history/__init__.py index 97cd798e..a0f19cce 100644 --- a/goga/commands/history/__init__.py +++ b/goga/commands/history/__init__.py @@ -1 +1,6 @@ """History command cell — the CLI surface of the history domain.""" + +from .history import history +from .render import render_history_tree, render_topic_statuses + +__all__: list[str] = ["history", "render_history_tree", "render_topic_statuses"] diff --git a/goga/commands/history/history.py b/goga/commands/history/history.py new file mode 100644 index 00000000..54f5ce40 --- /dev/null +++ b/goga/commands/history/history.py @@ -0,0 +1,168 @@ +"""The ``goga history`` command group — the CLI surface of the history domain. + +The click group declared in the cell CODEMANIFEST with ``location: +history.py``: the ``list``/``status``/``path``/``ensure`` subcommands over the +``.goga/history/`` tree. The group is a thin wrapper — it resolves the inputs, +delegates every computation to the domain routines of ``goga.history``, and +renders the results through the ``render`` module. No path building, no slug +grammar, and no status resolution live here. Domain errors surface as clean +CLI errors: a ``ValueError`` from the domain and an undetermined git branch +become ``click.ClickException`` (stderr, exit 1, no traceback) — no fallback +topic names, no silent skips. +""" + +from __future__ import annotations + +import click + +from ...history import ( + TopicStatus, + collect_history_tree, + collect_topic_statuses, + ensure_topic_dir, + normalize_topic_slug, + resolve_current_branch_name, + resolve_topic_dir, + resolve_topic_file, +) +from .render import render_history_tree, render_topic_statuses + + +def _resolve_topic_input(topic: str | None) -> str: + """Resolve a topic input: the positional when given, the branch otherwise. + + Args: + topic: The positional value — ``None`` when the user passed none. + + Returns: + The topic input to hand to the domain (verbatim, unnormalized). + + Raises: + click.ClickException: when the positional is absent and the current + git branch cannot be determined (the three documented ``None`` + modes of the domain branch reader). + """ + if topic is not None: + return topic + branch = resolve_current_branch_name() + if branch is None: + raise click.ClickException("cannot determine the current git branch — pass a topic explicitly") + return branch + + +@click.group() +def history() -> None: + """Work with the .goga/history/ tree.""" + + +@history.command("list") +@click.pass_context +def list_topics(ctx: click.Context) -> None: + """Print the tree of every history year with its topics. + + The inventory view: one YYYY/ line per year, each topic indented under + its year. An empty tree prints nothing. Read-only — nothing is created + or written; statuses and artifact names never appear. + """ + render_history_tree(collect_history_tree()) + ctx.exit(0) + + +@history.command("status") +@click.argument("year", required=False) +@click.option("-t", "--topic", default=None, help="Substring filter on the normalized topic slug.") +@click.option("-s", "--status", "statuses", multiple=True, help="Status filter, repeatable (e.g. -s planned).") +@click.pass_context +def status( + ctx: click.Context, + year: str | None = None, + topic: str | None = None, + statuses: tuple[str, ...] = (), +) -> None: + """Print the topics of one year, one 'topic [status]' line each. + + YEAR defaults to the current year and is never printed. -t/--topic keeps + the topics whose slug contains the normalized filter as a substring; + -s/--status keeps the given statuses; both filters combine by AND. An + empty result prints nothing and exits 0 — it is not an error. The topics + come out alphabetically; the domain sorts, this command does not re-sort. + """ + resolved: list[TopicStatus] = [] + for name in statuses: + try: + resolved.append(TopicStatus(name)) + except ValueError as exc: + raise click.ClickException(f"unknown status name: {name!r}") from exc + + filter_slug: str | None = None + if topic is not None: + filter_slug = normalize_topic_slug(topic) + if filter_slug == "": + raise click.ClickException(f"topic filter {topic!r} normalizes to an empty topic slug") + + records = collect_topic_statuses(year) + if topic is not None: + records = [record for record in records if filter_slug in record.topic] + if statuses: + allowed = set(resolved) + records = [record for record in records if record.status in allowed] + render_topic_statuses(records) + ctx.exit(0) + + +@history.command("path") +@click.argument("topic", required=False) +@click.option( + "-f", + "--file", + "filename", + default=None, + help="Print the artifact file path instead of the topic directory.", +) +@click.option("-y", "--year", default=None, help="Four-digit year (default: the current year).") +@click.pass_context +def path( + ctx: click.Context, + topic: str | None = None, + filename: str | None = None, + year: str | None = None, +) -> None: + """Print one path of the history tree — and nothing else. + + TOPIC defaults to the current git branch (taken raw, as a branch name or + a slug). With -f/--file the artifact file path is printed, otherwise the + topic directory; the year defaults to the current one. The path and only + the path — exactly one stdout line, for scripting: + plan=$(goga history path -f plan.md). Nothing is created on disk. + """ + resolved_topic = _resolve_topic_input(topic) + try: + if filename is not None: + resolved_path = resolve_topic_file(resolved_topic, filename, year) + else: + resolved_path = resolve_topic_dir(resolved_topic, year) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(resolved_path) + ctx.exit(0) + + +@history.command("ensure") +@click.argument("name", required=False) +@click.pass_context +def ensure(ctx: click.Context, name: str | None = None) -> None: + """Create the topic directory of the current year, idempotently. + + NAME defaults to the current git branch (taken raw, as a branch name or + a slug); parent directories are created as needed and an existing topic + directory is a success, not a conflict. Prints nothing on stdout — the + exit code carries the result. Only directories: no artifact file is + created, and occupancy is not reported (deciding whether a topic may be + created belongs to the caller). + """ + resolved_name = _resolve_topic_input(name) + try: + ensure_topic_dir(resolved_name) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + ctx.exit(0) diff --git a/tests/commands/history/test_history.py b/tests/commands/history/test_history.py new file mode 100644 index 00000000..ee47cf7b --- /dev/null +++ b/tests/commands/history/test_history.py @@ -0,0 +1,193 @@ +"""Contract and logic tests for the entity declared in +``goga/commands/history/CODEMANIFEST`` with ``location: history.py``: +the ``history`` click group with the ``list``/``status``/``path``/``ensure`` +subcommands. + +The group is a thin wrapper: inputs are resolved here, every computation is +delegated to the ``goga.history`` domain, and output goes through the +``render`` module. The logic tests cover the negative paths — domain errors +(``ValueError``), an undetermined git branch, and validation failures must +surface as ``click.ClickException`` (stderr, exit 1, no traceback). The +positive cross-entity scenarios live in ``test_history_command.py``. +""" + +from __future__ import annotations + +import inspect +import sys +import typing +from unittest import mock + +import click +import pytest +from click.testing import CliRunner +from goga.commands.history import history, render_history_tree, render_topic_statuses + +# goga.commands.history.history is shadowed in the package __init__ by the +# history click group, so attribute access through the package gives the +# group. Resolve the real module via sys.modules (precedent: test_pipeline). +_history_module = sys.modules["goga.commands.history.history"] +# The facade __all__ lives on the cell package itself. +_history_facade = sys.modules["goga.commands.history"] + +# --- Contract tests --- + + +class TestHistoryGroupContract: + def test_history_importable_from_facade(self) -> None: + """history is importable from the goga.commands.history facade.""" + assert _history_module.history is history + + def test_facade_exports_three_names(self) -> None: + """The cell facade carries the three declared names, alphabetically.""" + assert _history_facade.__all__ == ["history", "render_history_tree", "render_topic_statuses"] + assert callable(history) + assert callable(render_history_tree) + assert callable(render_topic_statuses) + + def test_history_is_a_click_group(self) -> None: + """history is a click.Group container for the subcommands.""" + assert isinstance(history, click.Group) + + def test_history_registers_four_subcommands(self) -> None: + """The group carries exactly the four declared subcommands.""" + assert sorted(history.commands) == ["ensure", "list", "path", "status"] + + def test_history_group_carries_no_options(self) -> None: + """Every subcommand owns its arguments — the group has none.""" + assert history.params == [] + + def test_list_topics_does_not_shadow_builtin_list(self) -> None: + """The list subcommand callback is named list_topics, not list.""" + assert callable(_history_module.list_topics) + assert not hasattr(_history_module, "list") + + def test_list_callback_signature(self) -> None: + """``list_topics(ctx)`` — no arguments beyond the click context.""" + callback = history.commands["list"].callback + assert list(inspect.signature(callback).parameters) == ["ctx"] + + def test_status_callback_signature(self) -> None: + """``status(ctx, year, topic, statuses)`` with the tuple default ``()``.""" + callback = history.commands["status"].callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["ctx", "year", "topic", "statuses"] + assert signature.parameters["statuses"].default == () + hints = typing.get_type_hints(callback) + assert hints == { + "ctx": click.Context, + "year": str | None, + "topic": str | None, + "statuses": tuple[str, ...], + "return": type(None), + } + + def test_path_callback_signature(self) -> None: + """``path(ctx, topic, filename, year)``.""" + callback = history.commands["path"].callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["ctx", "topic", "filename", "year"] + hints = typing.get_type_hints(callback) + assert hints == { + "ctx": click.Context, + "topic": str | None, + "filename": str | None, + "year": str | None, + "return": type(None), + } + + def test_ensure_callback_signature(self) -> None: + """``ensure(ctx, name)``.""" + callback = history.commands["ensure"].callback + assert list(inspect.signature(callback).parameters) == ["ctx", "name"] + + def test_status_options(self) -> None: + """status: optional YEAR positional, -t/--topic, repeatable -s/--status.""" + command = history.commands["status"] + year_argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "year") + assert year_argument.required is False + topic_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "topic") + assert "-t" in topic_option.opts + assert "--topic" in topic_option.opts + status_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "statuses") + assert "-s" in status_option.opts + assert "--status" in status_option.opts + assert status_option.multiple is True + + def test_path_options(self) -> None: + """path: optional TOPIC positional, -f/--file, -y/--year.""" + command = history.commands["path"] + topic_argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "topic") + assert topic_argument.required is False + file_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "filename") + assert "-f" in file_option.opts + assert "--file" in file_option.opts + year_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "year") + assert "-y" in year_option.opts + assert "--year" in year_option.opts + + def test_ensure_argument(self) -> None: + """ensure: optional NAME positional.""" + command = history.commands["ensure"] + name_argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "name") + assert name_argument.required is False + + +# --- Logic tests (negative paths, via CliRunner) --- + + +class TestHistoryNegativePaths: + def test_history_status_unknown_status_name(self) -> None: + """An unknown -s name is a clean error — no traceback, exit 1.""" + result = CliRunner().invoke(history, ["status", "-s", "bogus"]) + assert result.exit_code == 1 + assert "unknown status name" in result.stderr + assert "Traceback" not in result.stderr + + def test_history_status_empty_topic_filter_is_error(self) -> None: + """A -t value normalizing to an empty slug is an error, not match-all.""" + result = CliRunner().invoke(history, ["status", "-t", "Релиз"]) + assert result.exit_code == 1 + assert "empty topic slug" in result.stderr + assert result.stdout == "" + + def test_history_path_no_branch_fails_cleanly(self) -> None: + """path without a positional and without a determinable branch fails clean.""" + runner = CliRunner() + with mock.patch.object(_history_module, "resolve_current_branch_name", return_value=None): + result = runner.invoke(history, ["path"]) + assert result.exit_code == 1 + assert "cannot determine the current git branch" in result.stderr + assert result.stdout == "" + assert "Traceback" not in result.stderr + + def test_history_path_extensionless_file_fails(self) -> None: + """An extensionless -f value surfaces the domain error as a clean error.""" + result = CliRunner().invoke(history, ["path", "-f", "noext"]) + assert result.exit_code == 1 + assert "must carry an extension" in result.stderr + + def test_history_ensure_no_branch_fails_cleanly(self) -> None: + """ensure without a positional and without a determinable branch fails clean.""" + runner = CliRunner() + with mock.patch.object(_history_module, "resolve_current_branch_name", return_value=None): + result = runner.invoke(history, ["ensure"]) + assert result.exit_code == 1 + assert "cannot determine the current git branch" in result.stderr + assert result.stdout == "" + + +@pytest.mark.parametrize( + ("argv", "stderr_fragment"), + [ + (["status", "2026", "-t", "Релиз"], "empty topic slug"), + (["path", "Релиз/Один", "-f", "plan.md"], "empty topic slug"), + (["ensure", "Релиз/Один"], "empty topic slug"), + ], +) +def test_history_empty_slug_inputs_are_clean_errors(argv: list[str], stderr_fragment: str) -> None: + """Every subcommand converts the domain empty-slug error to exit 1.""" + result = CliRunner().invoke(history, argv) + assert result.exit_code == 1 + assert stderr_fragment in result.stderr + assert "Traceback" not in result.stderr From 8af0c23c06c9ba4fb6db04ef9cd5449d697dfe39 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 21:42:05 +0000 Subject: [PATCH 056/229] feat: add goga history CLI integration tests for list/status/path/ensure scenarios --- .../commands/history/test_history_command.py | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 tests/commands/history/test_history_command.py diff --git a/tests/commands/history/test_history_command.py b/tests/commands/history/test_history_command.py new file mode 100644 index 00000000..987a4d0e --- /dev/null +++ b/tests/commands/history/test_history_command.py @@ -0,0 +1,160 @@ +"""Integration tests for the ``goga history`` command group. + +Cross-entity scenarios — group → subcommand → domain → render → +stdout/filesystem: the command layer resolves the inputs, the +``goga.history`` domain computes, the ``render`` module prints. The negative +paths live in ``test_history.py``; this file drives the happy paths and the +empty-result edges through the real command objects. + +Setup follows the cell conventions: ``tmp_path`` + ``monkeypatch.chdir`` for +the filesystem, ``CliRunner`` for the CLI surface (captured output is not a +TTY, so ANSI is stripped), the pinned clock ``naming.datetime`` wherever the +year must be deterministic, and the branch reader mocked at its import site +in the command module. +""" + +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path +from unittest import mock + +import pytest +from click.testing import CliRunner +from goga.commands.history import history +from goga.history import naming + +# goga.commands.history.history is shadowed in the package __init__ by the +# history click group, so attribute access through the package gives the +# group. Resolve the real module via sys.modules (precedent: test_history.py). +_history_module = sys.modules["goga.commands.history.history"] + + +class _FixedClock: + """Stand-in for ``datetime`` answering a fixed naive date.""" + + @staticmethod + def now() -> datetime: + return datetime(2031, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock + + +# --- Cross-entity interactions --- + + +class TestHistoryList: + def test_history_list_renders_tree(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """list prints the inventory: years with topics, no statuses, no non-years.""" + root = tmp_path / ".goga" / "history" + for relative in ("2025/b-topic", "2025/a-topic", "2026/history-commands", "backups", "20a6"): + (root / relative).mkdir(parents=True) + (root / "notes.md").write_text("not a year\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["list"]) + + assert result.exit_code == 0 + assert "2025/" in result.output + assert " └── a-topic" in result.output + assert "2026/" in result.output + assert " └── history-commands" in result.output + assert result.output.splitlines() == [ + "2025/", + " └── a-topic", + " └── b-topic", + "2026/", + " └── history-commands", + ] + assert "[planned]" not in result.output + + +class TestHistoryStatus: + def test_history_status_filters_and(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """-t and -s combine by AND; the year is resolved, never printed.""" + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "release-1-3-0" / "completed").mkdir(parents=True) + (year_dir / "release-1-3-0" / "completed" / "plan.md").write_text("done\n", encoding="utf-8") + (year_dir / "history-commands").mkdir() + (year_dir / "history-commands" / "plan.md").write_text("plan\n", encoding="utf-8") + (year_dir / "other").mkdir() + (year_dir / "other" / "prd.md").write_text("prd\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["status", "2026", "-t", "Release/1.3.0", "-s", "done"]) + + assert result.exit_code == 0 + assert result.output.strip() == "release-1-3-0 [done]" + assert "history-commands" not in result.output + assert "other" not in result.output + assert "2026" not in result.output + + +class TestHistoryPath: + def test_history_path_prints_file_path_only( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """path answers the branch-defaulted artifact path — one line, nothing created.""" + monkeypatch.chdir(tmp_path) + runner = CliRunner() + with ( + mock.patch.object(naming, "datetime", _FixedClock), + mock.patch.object(_history_module, "resolve_current_branch_name", return_value="history-commands"), + ): + result = runner.invoke(history, ["path", "-f", "plan.md"]) + + expected = str(Path(".goga/history") / "2031" / "history-commands" / "plan.md") + assert result.exit_code == 0 + assert result.output.splitlines() == [expected] + assert result.output.endswith("\n") + assert not (tmp_path / ".goga").exists() + + +class TestHistoryEnsure: + def test_history_ensure_creates_dir_silently( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """ensure normalizes the branch name and is idempotent — stdout stays empty.""" + monkeypatch.chdir(tmp_path) + runner = CliRunner() + with ( + mock.patch.object(naming, "datetime", _FixedClock), + mock.patch.object(_history_module, "resolve_current_branch_name", return_value="Feature/Foo_Bar"), + ): + first = runner.invoke(history, ["ensure"]) + second = runner.invoke(history, ["ensure"]) + + assert first.exit_code == 0 + assert second.exit_code == 0 + assert first.output == "" + assert second.output == "" + assert (tmp_path / ".goga" / "history" / "2031" / "feature-foo-bar").is_dir() + + +# --- Edge cases --- + + +class TestHistoryEmptyResults: + def test_history_status_empty_result_exit_zero( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A filter matching nothing prints nothing and exits 0 — not an error.""" + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "history-commands").mkdir(parents=True) + (year_dir / "history-commands" / "plan.md").write_text("plan\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["status", "2026", "-t", "nomatch"]) + + assert result.exit_code == 0 + assert result.output == "" + + def test_history_list_absent_history_empty_output( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An empty workspace has an empty history — list prints nothing, exit 0.""" + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["list"]) + + assert result.exit_code == 0 + assert result.output == "" From ec0aa1d695c0027beac84d0df0b0c9de7fc9ae85 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 21:44:46 +0000 Subject: [PATCH 057/229] feat: register history group in goga/commands facade and root CLI app --- goga/cli.py | 2 ++ goga/commands/__init__.py | 2 ++ tests/test_cli.py | 27 ++++++++++++++++++++++++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/goga/cli.py b/goga/cli.py index 2a30db5e..fa89ba7e 100644 --- a/goga/cli.py +++ b/goga/cli.py @@ -9,6 +9,7 @@ config, connect, contract, + history, init, install, lint, @@ -74,3 +75,4 @@ def app() -> None: app.add_command(usages) app.add_command(tool) app.add_command(upgrade) +app.add_command(history) diff --git a/goga/commands/__init__.py b/goga/commands/__init__.py index 44309afb..8185235d 100644 --- a/goga/commands/__init__.py +++ b/goga/commands/__init__.py @@ -2,6 +2,7 @@ from .config import config from .connect import connect from .contract import contract +from .history import history from .init import init from .install import install, uninstall from .lint import lint @@ -16,6 +17,7 @@ "config", "connect", "contract", + "history", "init", "install", "lint", diff --git a/tests/test_cli.py b/tests/test_cli.py index 74fa88c0..f1abf123 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,7 +9,7 @@ import click import pytest from click.testing import CliRunner -from goga import app +from goga import app, commands from goga.cli import app as cli_app from tests.conftest import cwd as _cwd @@ -279,3 +279,28 @@ def test_cli_schema_lint_coexist(self) -> None: lint_result = runner.invoke(app, ["lint", "."]) assert lint_result.exit_code in (0, 1) + + +def test_cli_registers_history_group() -> None: + """The history group is registered on app and re-exported by the facade. + + Regression guard for the full registration chain: the group must be added + to the root ``app`` (help surface), expose all four subcommands, and be + re-exported through ``goga.commands.__all__`` — otherwise + ``from goga.commands import history`` breaks on some consumer paths even + though ``cli.py`` registered it. + """ + runner = CliRunner() + + root_help = runner.invoke(app, ["--help"]) + assert root_help.exit_code == 0 + assert "history" in root_help.output + + history_help = runner.invoke(app, ["history", "--help"]) + assert history_help.exit_code == 0 + for subcommand in ("list", "status", "path", "ensure"): + assert subcommand in history_help.output + + assert "history" in commands.__all__ + assert len(commands.__all__) == 14 + assert hasattr(commands, "history") From 4bdd983177d9b9845d3a38cae448f3d35b75da55 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 21:46:06 +0000 Subject: [PATCH 058/229] feat: migrate development workflow build script to goga history path --- .goga/workflows/development.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.goga/workflows/development.yml b/.goga/workflows/development.yml index efdc9efd..7e27117b 100644 --- a/.goga/workflows/development.yml +++ b/.goga/workflows/development.yml @@ -39,8 +39,6 @@ extend: - commit-changes timeout: "8h" script: | - branch=$(git branch --show-current) - topic=$(python3 -c "from goga.commands.pipeline.branch import normalize_topic_slug; import sys; print(normalize_topic_slug(sys.argv[1]))" "$branch") - test -n "$topic" || { echo "branch name '$branch' normalizes to an empty topic slug" >&2; exit 1; } - python3 -m goga.build ".goga/history/$(date +%Y)/$topic/plan.md" + plan=$(python3 -m goga history path -f plan.md) || exit 1 + python3 -m goga.build "$plan" after_script: rm -rf .ralphex From 8e1fac179f4ee4790264eab5572eb6cd88e19b22 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 21:54:10 +0000 Subject: [PATCH 059/229] feat: migrate 24 asset files to goga history path artifact addressing --- goga/assets/pipelines/development.yml | 37 +++++++------------ goga/assets/pipelines/refinement.yml | 25 +++++-------- .../skills/goga-brainstorm-intake/SKILL.md | 2 +- .../goga-brainstorm-plan-assembly/SKILL.md | 8 ++-- .../SKILL.md | 2 +- .../goga-brainstorm-primary-analysis/SKILL.md | 2 +- goga/assets/skills/goga-brainstorm/SKILL.md | 6 +-- goga/assets/skills/goga-define-prd/SKILL.md | 10 ++--- goga/assets/skills/goga-define/SKILL.md | 20 ++-------- .../skills/goga-design-by-changes/SKILL.md | 4 +- .../design-doc-template.md | 2 +- goga/assets/skills/goga-discover/SKILL.md | 2 +- .../skills/goga-plan-by-design/SKILL.md | 7 ++-- .../goga-plan-by-design/output-template.md | 2 +- goga/assets/skills/goga-propose/SKILL.md | 2 +- goga/assets/skills/goga-review-arch/SKILL.md | 6 +-- .../assets/skills/goga-review-design/SKILL.md | 2 +- goga/assets/skills/goga-review-plan/SKILL.md | 8 ++-- goga/assets/skills/goga-review-task/SKILL.md | 6 +-- .../skills/goga-task-by-proposing/SKILL.md | 7 ++-- 20 files changed, 65 insertions(+), 95 deletions(-) diff --git a/goga/assets/pipelines/development.yml b/goga/assets/pipelines/development.yml index a858cda7..e7454bcc 100644 --- a/goga/assets/pipelines/development.yml +++ b/goga/assets/pipelines/development.yml @@ -6,10 +6,9 @@ description: "Development process" title: "Task-based architecture development" communication: true prompt: | - Use the task `.goga/history/<year>/<topic>/task.md`, if it exists - Save the architecture plan as `.goga/history/<year>/<topic>/arch.md` - (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the - current git branch name (`release/1.3.0` → `release-1-3-0`); create the directory lazily) + Use the task file at the path printed by `goga history path -f task.md`, if it exists + Save the architecture plan at the path printed by `goga history path -f arch.md` + (run `goga history ensure` first if the topic directory does not exist) **CODEMANIFEST files** must be described at a functional and business-logic level, remaining strictly implementation-agnostic: - Focus on defining "what" the system should achieve (expected behavior, business rules, inputs, and outputs) rather than "how" to code it. @@ -36,18 +35,14 @@ description: "Development process" title: "Review of the created architectural plan" communication: true prompt: | - Review the architecture plan `.goga/history/<year>/<topic>/arch.md` - (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the - current git branch name) + Review the architecture plan at the path printed by `goga history path -f arch.md` skills: - goga-review-arch - name: apply-architecture title: "Apply the created architectural plan" prompt: | - Apply the architecture plan `.goga/history/<year>/<topic>/arch.md` - (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the - current git branch name) + Apply the architecture plan at the path printed by `goga history path -f arch.md` skills: - goga-apply @@ -55,9 +50,8 @@ description: "Development process" title: "Designing architecture into code" communication: true prompt: | - Save the design document as `.goga/history/<year>/<topic>/design.md` - (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the - current git branch name; create the directory lazily) + Save the design document at the path printed by `goga history path -f design.md` + (run `goga history ensure` first if the topic directory does not exist) skills: - goga-design @@ -65,9 +59,7 @@ description: "Development process" title: "Review of the created design plan" communication: true prompt: | - Review the design document `.goga/history/<year>/<topic>/design.md` - (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the - current git branch name) + Review the design document at the path printed by `goga history path -f design.md` skills: - goga-review-design @@ -75,10 +67,9 @@ description: "Development process" title: "Create the coding plan" communication: true prompt: | - Use the design document `.goga/history/<year>/<topic>/design.md` - Save the plan as `.goga/history/<year>/<topic>/plan.md` - (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the - current git branch name; create the directory lazily) + Use the design document at the path printed by `goga history path -f design.md` + Save the plan at the path printed by `goga history path -f plan.md` + (run `goga history ensure` first if the topic directory does not exist) skills: - goga-plan @@ -86,9 +77,7 @@ description: "Development process" title: "Review of the created coding plan" communication: true prompt: | - Review the plan `.goga/history/<year>/<topic>/plan.md` - (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the - current git branch name) + Review the plan at the path printed by `goga history path -f plan.md` skills: - goga-review-plan @@ -99,7 +88,7 @@ description: "Development process" Commit all added and modified files. Constraints: - - Except changes in `.goga/history/`. + - Except changes in the `.goga/history/` tree owned by `goga history`. - name: accept-result title: "Contracts & coverage audit" diff --git a/goga/assets/pipelines/refinement.yml b/goga/assets/pipelines/refinement.yml index 3750db67..0ef15305 100644 --- a/goga/assets/pipelines/refinement.yml +++ b/goga/assets/pipelines/refinement.yml @@ -6,9 +6,8 @@ description: "Task refinement process" title: "Product definition & create PRD" communication: true prompt: | - Save the PRD file as `.goga/history/<year>/<topic>/prd.md` - (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the - current git branch name (`release/1.3.0` → `release-1-3-0`); create the directory lazily) + Save the PRD file at the path printed by `goga history path -f prd.md` + (run `goga history ensure` first if the topic directory does not exist) Constrains: - Don't research a project until you receive the task @@ -19,12 +18,11 @@ description: "Task refinement process" title: "Technical discovery & create ADR" communication: true prompt: | - Use `.goga/history/<year>/<topic>/prd.md` as the PRD file, if it exists (4-digit year). + Use the PRD file at the path printed by `goga history path -f prd.md`, if it exists. If PRD file does not exist — ask user about task. - Save the ADR file as `.goga/history/<year>/<topic>/adr.md` - (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the - current git branch name (`release/1.3.0` → `release-1-3-0`); create the directory lazily) + Save the ADR file at the path printed by `goga history path -f adr.md` + (run `goga history ensure` first if the topic directory does not exist) Communication: - You **MUST** follow the `File-Based Dialog Protocol` for every round of questions. @@ -35,13 +33,12 @@ description: "Task refinement process" title: "Task decomposition & create Task(s)" communication: true prompt: | - Use the ADR `.goga/history/<year>/<topic>/adr.md` as the input for task formulation, if it exists (4-digit year). - If ADR does not exist — try `.goga/history/<year>/<topic>/prd.md` (4-digit year) as the PRD file. + Use the ADR at the path printed by `goga history path -f adr.md` as the input for task formulation, if it exists. + If ADR does not exist — try the PRD file at the path printed by `goga history path -f prd.md`. If PRD file does not exists — ask user about task. - Save the task file as `.goga/history/<year>/<topic>/task.md` - (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the - current git branch name (`release/1.3.0` → `release-1-3-0`); create the directory lazily) + Save the task file at the path printed by `goga history path -f task.md` + (run `goga history ensure` first if the topic directory does not exist) skills: - goga-propose @@ -49,8 +46,6 @@ description: "Task refinement process" title: "Review of the created task" communication: true prompt: | - Review the task `.goga/history/<year>/<topic>/task.md` - (`<year>` = current year, `YYYY`; `<topic>` = lowercase kebab-case slug of the - current git branch name) + Review the task file at the path printed by `goga history path -f task.md` skills: - goga-review-task diff --git a/goga/assets/skills/goga-brainstorm-intake/SKILL.md b/goga/assets/skills/goga-brainstorm-intake/SKILL.md index e53553d8..2f215d95 100644 --- a/goga/assets/skills/goga-brainstorm-intake/SKILL.md +++ b/goga/assets/skills/goga-brainstorm-intake/SKILL.md @@ -21,7 +21,7 @@ Classify the description into one of: - **Brief** — one sentence or a feature name - **Detailed** — a complete specification with requirements, constraints, examples -- **Task file** — path to `.goga/history/<year>/<topic>/task.md` +- **Task file** — a path to a task file (e.g., the path printed by `goga history path -f task.md`) ### Phase 3. Read the task file if provided diff --git a/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md b/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md index edb18bcd..984397c8 100644 --- a/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md +++ b/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md @@ -24,7 +24,8 @@ Use these reports for its specific purpose: ### Phase 1. Determine the topic -Determine `<topic>` — a lowercase kebab-case slug from the **Topic** section of the `[PRIMARY_ANALYSIS_REPORT]`. +Resolve the topic directory — the path printed by `goga history path` (the topic comes from the current git branch). +Keep the **Topic** section of the `[PRIMARY_ANALYSIS_REPORT]` as the plan's short name. ### Phase 2. Assemble the plan structure @@ -62,7 +63,8 @@ What to check after implementing each artifact. ### Phase 4. Save the plan -Save the plan to `.goga/history/<year>/<topic>/arch.md`. +Save the plan to the path printed by `goga history path -f arch.md` +(run `goga history ensure` first if the topic directory does not exist). ## WAIT @@ -76,7 +78,7 @@ Fill every section. No empty sections. # [ARCHITECTURE_PLAN] ## Topic -[Short name and the .goga/history/<year>/<topic>/arch.md path] +[Short name and the path printed by `goga history path -f arch.md`] ## Implementation Order [Ordered list of cells, leaves to root, with rationale per cell] diff --git a/goga/assets/skills/goga-brainstorm-plan-verification/SKILL.md b/goga/assets/skills/goga-brainstorm-plan-verification/SKILL.md index 17dc8b80..70325300 100644 --- a/goga/assets/skills/goga-brainstorm-plan-verification/SKILL.md +++ b/goga/assets/skills/goga-brainstorm-plan-verification/SKILL.md @@ -19,7 +19,7 @@ Use these skills for verification: Use this artifact for its specific purpose: -- **`[ARCHITECTURE_PLAN]`** (at `.goga/history/<year>/<topic>/arch.md`) — use it as the **object of verification**: its implementation +- **`[ARCHITECTURE_PLAN]`** (at the path printed by `goga history path -f arch.md`) — use it as the **object of verification**: its implementation order, per-cell CODEMANIFESTs and `.usages/` files, dependency map, and verification checklist, against which the DSL checks are run, failures are fixed in place, and the report is produced. diff --git a/goga/assets/skills/goga-brainstorm-primary-analysis/SKILL.md b/goga/assets/skills/goga-brainstorm-primary-analysis/SKILL.md index 121020f6..edbf7733 100644 --- a/goga/assets/skills/goga-brainstorm-primary-analysis/SKILL.md +++ b/goga/assets/skills/goga-brainstorm-primary-analysis/SKILL.md @@ -34,7 +34,7 @@ Task file) sets the expected depth — Brief input yields more dark zones; Detai constraints and acceptance criteria. - **Topic** — a short topic name derived from the `[INTAKE_REPORT]` task summary (used by plan-assembly for - `.goga/history/<year>/<topic>/arch.md`) + the `arch.md` path printed by `goga history path -f arch.md`) - **Acceptance criteria** — if task-file input, folded verbatim/condensed from the `[INTAKE_REPORT]` "Acceptance Criteria" section; otherwise N/A - **Stack & external dependencies** — if task-file input, folded from the `[INTAKE_REPORT]` "Stack and Dependencies" diff --git a/goga/assets/skills/goga-brainstorm/SKILL.md b/goga/assets/skills/goga-brainstorm/SKILL.md index 6578886c..5d9d8738 100644 --- a/goga/assets/skills/goga-brainstorm/SKILL.md +++ b/goga/assets/skills/goga-brainstorm/SKILL.md @@ -11,7 +11,7 @@ strict order, never performing design work yourself — each stage is delegated ## Mission -Produce an architecture plan (`.goga/history/<year>/<topic>/arch.md`) describing which cells, CODEMANIFEST files, and `.usages/` files +Produce an architecture plan (at the path printed by `goga history path -f arch.md`) describing which cells, CODEMANIFEST files, and `.usages/` files need to be created and in what order — designed collaboratively with the user through exploration, discussion, and refinement. @@ -152,13 +152,13 @@ Execute each phase strictly sequentially — one phase at a time. After each pha ### Phase 9. Plan Assembly - Invoke: **goga-brainstorm-plan-assembly** - Reads: [CELL_ASSEMBLY_REPORT] + [PRIMARY_ANALYSIS_REPORT] -- Output: [ARCHITECTURE_PLAN] written to `.goga/history/<year>/<topic>/arch.md` +- Output: [ARCHITECTURE_PLAN] written to the path printed by `goga history path -f arch.md` - WAIT: present plan to user, obtain confirmation - STOP if: plan incomplete ### Phase 10. Plan Verification - Invoke: **goga-brainstorm-plan-verification** -- Reads: [ARCHITECTURE_PLAN] (`.goga/history/<year>/<topic>/arch.md`) +- Reads: [ARCHITECTURE_PLAN] (at the path printed by `goga history path -f arch.md`) - Output: [VERIFICATION_REPORT] - WAIT: present the final (fixed) plan and [VERIFICATION_REPORT] to the user, obtain final confirmation - STOP if: unresolved DSL errors; any verification gate failed diff --git a/goga/assets/skills/goga-define-prd/SKILL.md b/goga/assets/skills/goga-define-prd/SKILL.md index dba8ba3d..bd7eb60a 100644 --- a/goga/assets/skills/goga-define-prd/SKILL.md +++ b/goga/assets/skills/goga-define-prd/SKILL.md @@ -341,13 +341,9 @@ The document must be self-contained and understandable without access to the int The PRD must contain only validated product decisions. -The final artifact will be saved by the orchestrator to: - -```text -.goga/history/<year>/<topic>/prd.md - -`<year>` = current year, `YYYY`; create the directory lazily -``` +The final artifact will be saved by the orchestrator at the path printed by +`goga history path -f prd.md` (the orchestrator runs `goga history ensure` +first if the topic directory does not exist). Do not create additional PRD files. diff --git a/goga/assets/skills/goga-define/SKILL.md b/goga/assets/skills/goga-define/SKILL.md index a0c089e5..0d168ab3 100644 --- a/goga/assets/skills/goga-define/SKILL.md +++ b/goga/assets/skills/goga-define/SKILL.md @@ -354,15 +354,8 @@ Once the product definition is validated: 1. invoke `goga-define-prd`; 2. provide the complete validated context; 3. receive the final Markdown document; -4. save it as: - -```text -.goga/history/<year>/<topic>/prd.md - -`<year>` = current year, `YYYY`; create the directory lazily -``` - -`<topic>` — lowercase kebab-case slug derived from the product change. +4. save it at the path printed by `goga history path -f prd.md` + (run `goga history ensure` first if the topic directory does not exist). Do not overwrite an unrelated existing PRD. @@ -405,13 +398,8 @@ The orchestrator must not formulate or resolve the decision itself. ## Output -The primary output of `goga-define` is one Markdown file: - -```text -.goga/history/<year>/<topic>/prd.md - -`<year>` = current year, `YYYY`; create the directory lazily -``` +The primary output of `goga-define` is one Markdown file saved at the path +printed by `goga history path -f prd.md`. The orchestrator should provide a concise completion message containing: diff --git a/goga/assets/skills/goga-design-by-changes/SKILL.md b/goga/assets/skills/goga-design-by-changes/SKILL.md index d353935e..4656eec1 100644 --- a/goga/assets/skills/goga-design-by-changes/SKILL.md +++ b/goga/assets/skills/goga-design-by-changes/SKILL.md @@ -326,10 +326,10 @@ Write results to a file using the template from `design-doc-template.md`. #### Step 2: Save -Path: `.goga/history/<year>/<topic>/design.md`. +Path: the path printed by `goga history path -f design.md`. - Prompt for the feature name if not obvious -- Create the `.goga/history/<year>/<topic>/` directory lazily if it does not exist +- Run `goga history ensure` first if the topic directory does not exist - Overwrite if the file already exists --- diff --git a/goga/assets/skills/goga-design-by-changes/design-doc-template.md b/goga/assets/skills/goga-design-by-changes/design-doc-template.md index 8fbdfd88..7485cd1e 100644 --- a/goga/assets/skills/goga-design-by-changes/design-doc-template.md +++ b/goga/assets/skills/goga-design-by-changes/design-doc-template.md @@ -1,6 +1,6 @@ # Design Document Template -The agent persists this document at `.goga/history/<year>/<topic>/design.md`. +The agent persists this document at the path printed by `goga history path -f design.md`. This is a **complete architectural specification** — every detail fully elaborated. diff --git a/goga/assets/skills/goga-discover/SKILL.md b/goga/assets/skills/goga-discover/SKILL.md index 9ec68360..8575d3c1 100644 --- a/goga/assets/skills/goga-discover/SKILL.md +++ b/goga/assets/skills/goga-discover/SKILL.md @@ -23,7 +23,7 @@ Finding _facts_ is your job, never the user's. When a frontier question needs a The interview is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not write the ADR until the user confirms you have reached a shared understanding. -Once confirmed, write the ADR to `.goga/history/<year>/<topic>/adr.md` (`<topic>` — lowercase kebab-case slug; `<year>` = current year, `YYYY`; create the directory lazily), following `adr-template.md` from the current skill directory. +Once confirmed, write the ADR to the path printed by `goga history path -f adr.md` (run `goga history ensure` first if the topic directory does not exist), following `adr-template.md` from the current skill directory. ## Research diff --git a/goga/assets/skills/goga-plan-by-design/SKILL.md b/goga/assets/skills/goga-plan-by-design/SKILL.md index 81644ad2..ebc2e99c 100644 --- a/goga/assets/skills/goga-plan-by-design/SKILL.md +++ b/goga/assets/skills/goga-plan-by-design/SKILL.md @@ -101,11 +101,10 @@ Use the `goga-cell` skill for correct interpretation of DSL elements during comp #### Step 3: Save the Plan -Write the plan to `.goga/history/<year>/<topic>/plan.md` using the template from `output-template.md`. +Write the plan to the path printed by `goga history path -f plan.md`, using the template from `output-template.md`. -`<topic>` — lowercase kebab-case slug (e.g., `http-client`, `auth-module`). -The name should reflect the plan's scope, not the Cell name. -Create the `.goga/history/<year>/<topic>/` directory lazily if it does not exist. +The topic (branch) name should reflect the plan's scope, not the Cell name. +Run `goga history ensure` first if the topic directory does not exist. --- diff --git a/goga/assets/skills/goga-plan-by-design/output-template.md b/goga/assets/skills/goga-plan-by-design/output-template.md index 4ea90be9..0c1950ed 100644 --- a/goga/assets/skills/goga-plan-by-design/output-template.md +++ b/goga/assets/skills/goga-plan-by-design/output-template.md @@ -1,7 +1,7 @@ # Plan Output Template Result of Phase 1 (structure) + Phase 2 (Usages calibration). -Saved to `.goga/history/<year>/<topic>/plan.md`. +Saved to the path printed by `goga history path -f plan.md`. This format is compatible with ralphex execution. --- diff --git a/goga/assets/skills/goga-propose/SKILL.md b/goga/assets/skills/goga-propose/SKILL.md index fd9540b7..5d1fe09c 100644 --- a/goga/assets/skills/goga-propose/SKILL.md +++ b/goga/assets/skills/goga-propose/SKILL.md @@ -20,6 +20,6 @@ Use the **Skill tool** to invoke `goga-task-by-proposing` with the arguments as Arguments: $ARGUMENTS -The skill formulates the task and saves the artifact to `.goga/history/<year>/<topic>/task.md` (`<year>` = current year, `YYYY`; create the directory lazily). +The skill formulates the task and saves the artifact at the path printed by `goga history path -f task.md` (it runs `goga history ensure` first if the topic directory does not exist). --- diff --git a/goga/assets/skills/goga-review-arch/SKILL.md b/goga/assets/skills/goga-review-arch/SKILL.md index 309844f7..de39c76e 100644 --- a/goga/assets/skills/goga-review-arch/SKILL.md +++ b/goga/assets/skills/goga-review-arch/SKILL.md @@ -6,7 +6,7 @@ description: Review an architecture plan for semantic correctness ## Objective -Validate the architecture plan (`.goga/history/<year>/<topic>/arch.md`) for **semantic correctness** — assess model cohesion, domain boundary +Validate the architecture plan (the file at the path printed by `goga history path -f arch.md`) for **semantic correctness** — assess model cohesion, domain boundary soundness, and requirement sufficiency for implementation. The agent **analyzes** the architecture plan, **reports** findings, and **applies fixes** when issues are detected (subject to user approval). @@ -22,7 +22,7 @@ all types, domains, and requirements as a unified whole — not each CODEMANIFES ## Input -- **Required**: architecture plan at `.goga/history/<year>/<topic>/arch.md` +- **Required**: architecture plan at the path printed by `goga history path -f arch.md` - **Optional**: task file at `.goga/history/*/<topic>/task.md` (4-digit year) — when present, used to verify requirements coverage --- @@ -31,7 +31,7 @@ all types, domains, and requirements as a unified whole — not each CODEMANIFES ### Phase 1: Context Loading -1. Read the architecture plan from `.goga/history/<year>/<topic>/arch.md` +1. Read the architecture plan from the path printed by `goga history path -f arch.md` 2. Load the DSL specification and DSL application principles: - Invoke `goga-cell` via **Skill tool** — to understand DSL rules (signature syntax, Import/Usage/Annotation rules, types, mutations, embeddings, constraints) diff --git a/goga/assets/skills/goga-review-design/SKILL.md b/goga/assets/skills/goga-review-design/SKILL.md index 2fab9db9..ff4ef524 100644 --- a/goga/assets/skills/goga-review-design/SKILL.md +++ b/goga/assets/skills/goga-review-design/SKILL.md @@ -43,7 +43,7 @@ All CODEMANIFEST edits must be **proposed to the user** before applying. (document structure, signature syntax, Imports rules, Usages rules, Annotations rules, types, mutations, embeddings, constraints) - Use the **Skill tool** to invoke `goga-cookbook` — for understanding cell design principles and CODEMANIFEST (when to use Entity vs Routine, when to apply mutations and embeddings, usage file authoring principles, cell granularity) -2. Read the design document from `.goga/history/<year>/<topic>/design.md` +2. Read the design document from the path printed by `goga history path -f design.md` 3. Read all relevant CODEMANIFEST files referenced by the design 4. Read existing source files referenced by the design (if any) 5. Execute `goga schema --help` to understand the command, then execute `goga schema` to obtain the full project dependency graph. Use `--depends-on <cell_path>` to discover cells that depend on cells modified by the design. This ensures the review covers all affected cells. diff --git a/goga/assets/skills/goga-review-plan/SKILL.md b/goga/assets/skills/goga-review-plan/SKILL.md index 205031cf..1b7be9d9 100644 --- a/goga/assets/skills/goga-review-plan/SKILL.md +++ b/goga/assets/skills/goga-review-plan/SKILL.md @@ -6,7 +6,7 @@ description: Verify execution plan completeness and correctness ## Objective -Verify the execution plan (`.goga/history/<year>/<topic>/plan.md`) for **completeness and correctness** against the design document and `CODEMANIFEST` contracts before passing to ralphex. +Verify the execution plan (the file at the path printed by `goga history path -f plan.md`) for **completeness and correctness** against the design document and `CODEMANIFEST` contracts before passing to ralphex. You **verify** the plan, **report** findings, and **fix** the plan upon discovery of issues (subject to user approval). @@ -18,7 +18,7 @@ You **verify** the plan, **report** findings, and **fix** the plan upon discover ## Verifiable Artifact -- Plan file at `.goga/history/<year>/<topic>/plan.md` — the execution plan, verified against sources of truth +- Plan file at the path printed by `goga history path -f plan.md` — the execution plan, verified against sources of truth --- @@ -30,7 +30,7 @@ You **verify** the plan, **report** findings, and **fix** the plan upon discover The language skill defines implementation conventions: cell structure, facade, signature rules, **naming**. Examples in other skills may use naming conventions of one language (e.g., snake_case), while the target language requires different conventions (e.g., PascalCase) — the language skill is the authoritative source for the target language. -2. Read the plan from `.goga/history/<year>/<topic>/plan.md` +2. Read the plan from the path printed by `goga history path -f plan.md` 3. Read the design document from `.goga/history/*/<topic>/design.md` (4-digit year) 4. Read all relevant `CODEMANIFEST` files referenced by the design document 5. Load the DSL specification and DSL application principles: @@ -228,7 +228,7 @@ Use AskUserQuestion with options: #### Step 3. Apply the Decision -- **Apply suggested fix**: update the plan file at `.goga/history/<year>/<topic>/plan.md`, then re-verify that the fix introduces no new issues (re-run the relevant checks). Briefly report the re-verification result. +- **Apply suggested fix**: update the plan file at the path printed by `goga history path -f plan.md`, then re-verify that the fix introduces no new issues (re-run the relevant checks). Briefly report the re-verification result. - **Skip**: record the finding as "skipped" and proceed. - **Suggest alternative**: discuss the alternative with the user, agree on a fix, apply it, and re-verify. diff --git a/goga/assets/skills/goga-review-task/SKILL.md b/goga/assets/skills/goga-review-task/SKILL.md index 205a829e..7f9a5cee 100644 --- a/goga/assets/skills/goga-review-task/SKILL.md +++ b/goga/assets/skills/goga-review-task/SKILL.md @@ -6,7 +6,7 @@ description: Review a task for completeness, correctness, and consistency ## Objective -Validates a task (`.goga/history/<year>/<topic>/task.md`) for **completeness, correctness, and consistency** — ensuring the task is formulated clearly enough to proceed to architecture (`goga-brainstorm`). +Validates a task (the file at the path printed by `goga history path -f task.md`) for **completeness, correctness, and consistency** — ensuring the task is formulated clearly enough to proceed to architecture (`goga-brainstorm`). You **verify** the task, **report** findings, and **fix** the task when issues are discovered (with user approval). @@ -24,7 +24,7 @@ You **verify** the task, **report** findings, and **fix** the task when issues a ## Verifiable Artifact -- Task file at `.goga/history/<year>/<topic>/task.md` — a formulated task being verified for completeness and correctness +- Task file at the path printed by `goga history path -f task.md` — a formulated task being verified for completeness and correctness --- @@ -32,7 +32,7 @@ You **verify** the task, **report** findings, and **fix** the task when issues a ### Phase 1: Load Context -1. Read the task from `.goga/history/<year>/<topic>/task.md` +1. Read the task from the path printed by `goga history path -f task.md` 2. Load the DSL specification and DSL application principles: - Use the **Skill tool** to invoke `goga-cell` — for understanding cell terminology and CODEMANIFEST when verifying the "Existing Architecture" section - Use the **Skill tool** to invoke `goga-cookbook` — for understanding cell interaction principles when verifying the correctness of affected cells description diff --git a/goga/assets/skills/goga-task-by-proposing/SKILL.md b/goga/assets/skills/goga-task-by-proposing/SKILL.md index b0b670fe..fdbfa2ce 100644 --- a/goga/assets/skills/goga-task-by-proposing/SKILL.md +++ b/goga/assets/skills/goga-task-by-proposing/SKILL.md @@ -7,7 +7,8 @@ description: Interactive task formulation from a raw request ## Purpose Transforms a raw user request (e.g., "add authorization") into a **formulated task** — a structured document containing -the description, technology stack, dependencies, and scope estimate. The output is persisted to `.goga/history/<year>/<topic>/task.md` (`<year>` = current year, `YYYY`; create the directory lazily) +the description, technology stack, dependencies, and scope estimate. The output is persisted at the path printed by +`goga history path -f task.md` (run `goga history ensure` first if the topic directory does not exist) and serves as input for the `goga-brainstorm` skill. --- @@ -164,9 +165,9 @@ If all external dependencies are covered by current usage files, skip this phase ### Phase 7: Task Persistence -**Objective:** Save the formulated task to `.goga/history/<year>/<topic>/task.md` using the template (`<year>` = current year, `YYYY`; create the directory lazily). +**Objective:** Save the formulated task to the path printed by `goga history path -f task.md`, using the template (run `goga history ensure` first if the topic directory does not exist). -`<topic>` — lowercase kebab-case slug derived from the task topic (from the user's Phase 1 description). +The topic directory is resolved by `goga history path` from the current git branch. 1. Read the `task-template.md` template from the current skill directory and apply its structure. From 7120f9a1cb3dc17599fd73c3b5e5c11ad30d215f Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 22:07:17 +0000 Subject: [PATCH 060/229] fix: address code review findings - make test_history_path_extensionless_file_fails hermetic: pass an explicit topic instead of invoking real git against the host checkout (failed under detached HEAD in CI) - add behavioral tests for uncovered history CLI paths: 'path' without -f (topic directory), 'status' with default year and no filters, repeatable -s with multiple values, explicit TOPIC positional with -y override, explicit NAME for 'ensure' (history.py now at 100% statement and branch coverage) - add 'python -m goga' dispatch test: the development workflow build stage now depends on this entrypoint (runpy pattern per tests/pipeline/test_main_guard.py) --- tests/commands/history/test_history.py | 2 +- .../commands/history/test_history_command.py | 83 +++++++++++++++++++ tests/test_cli.py | 15 ++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/tests/commands/history/test_history.py b/tests/commands/history/test_history.py index ee47cf7b..538b244e 100644 --- a/tests/commands/history/test_history.py +++ b/tests/commands/history/test_history.py @@ -163,7 +163,7 @@ def test_history_path_no_branch_fails_cleanly(self) -> None: def test_history_path_extensionless_file_fails(self) -> None: """An extensionless -f value surfaces the domain error as a clean error.""" - result = CliRunner().invoke(history, ["path", "-f", "noext"]) + result = CliRunner().invoke(history, ["path", "feat-x", "-f", "noext"]) assert result.exit_code == 1 assert "must carry an extension" in result.stderr diff --git a/tests/commands/history/test_history_command.py b/tests/commands/history/test_history_command.py index 987a4d0e..b5b24243 100644 --- a/tests/commands/history/test_history_command.py +++ b/tests/commands/history/test_history_command.py @@ -88,6 +88,45 @@ def test_history_status_filters_and(self, tmp_path: Path, monkeypatch: pytest.Mo assert "other" not in result.output assert "2026" not in result.output + def test_history_status_defaults_to_current_year_unfiltered( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Plain status: the current year, no filters — every topic alphabetically.""" + history_root = tmp_path / ".goga" / "history" + (history_root / "2025" / "old-topic").mkdir(parents=True) + year_dir = history_root / "2031" + for topic in ("alpha", "mid", "zeta"): + (year_dir / topic).mkdir(parents=True) + (year_dir / "alpha" / "plan.md").write_text("plan\n", encoding="utf-8") + (year_dir / "mid" / "prd.md").write_text("prd\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + with mock.patch.object(naming, "datetime", _FixedClock): + result = CliRunner().invoke(history, ["status"]) + + assert result.exit_code == 0 + assert result.output.splitlines() == ["alpha [planned]", "mid [defined]", "zeta [empty]"] + assert "old-topic" not in result.output + + def test_history_status_repeatable_status_filter( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """-s repeats: one -s per name keeps several statuses, drops the rest.""" + year_dir = tmp_path / ".goga" / "history" / "2026" + for topic in ("done-topic", "planned-topic", "defined-topic"): + (year_dir / topic).mkdir(parents=True) + (year_dir / "done-topic" / "completed").mkdir() + (year_dir / "done-topic" / "completed" / "plan.md").write_text("done\n", encoding="utf-8") + (year_dir / "planned-topic" / "plan.md").write_text("plan\n", encoding="utf-8") + (year_dir / "defined-topic" / "prd.md").write_text("prd\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["status", "2026", "-s", "planned", "-s", "done"]) + + assert result.exit_code == 0 + assert result.output.splitlines() == ["done-topic [done]", "planned-topic [planned]"] + assert "defined-topic" not in result.output + class TestHistoryPath: def test_history_path_prints_file_path_only( @@ -108,6 +147,37 @@ def test_history_path_prints_file_path_only( assert result.output.endswith("\n") assert not (tmp_path / ".goga").exists() + def test_history_path_without_file_prints_topic_dir( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """path without -f answers the branch-defaulted topic directory.""" + monkeypatch.chdir(tmp_path) + runner = CliRunner() + with ( + mock.patch.object(naming, "datetime", _FixedClock), + mock.patch.object(_history_module, "resolve_current_branch_name", return_value="Feature/Foo_Bar"), + ): + result = runner.invoke(history, ["path"]) + + expected = str(Path(".goga/history") / "2031" / "feature-foo-bar") + assert result.exit_code == 0 + assert result.output.splitlines() == [expected] + assert result.output.endswith("\n") + assert not (tmp_path / ".goga").exists() + + def test_history_path_explicit_topic_and_year( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """path takes an explicit branch-name topic; -y overrides the year.""" + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["path", "release/1.3.0", "-f", "plan.md", "-y", "2025"]) + + expected = str(Path(".goga/history") / "2025" / "release-1-3-0" / "plan.md") + assert result.exit_code == 0 + assert result.output.splitlines() == [expected] + assert not (tmp_path / ".goga").exists() + class TestHistoryEnsure: def test_history_ensure_creates_dir_silently( @@ -129,6 +199,19 @@ def test_history_ensure_creates_dir_silently( assert second.output == "" assert (tmp_path / ".goga" / "history" / "2031" / "feature-foo-bar").is_dir() + def test_history_ensure_explicit_name_creates_dir( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """ensure with an explicit NAME normalizes it — no git involved.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(naming, "datetime", _FixedClock): + result = CliRunner().invoke(history, ["ensure", "Feature/Foo_Bar"]) + + assert result.exit_code == 0 + assert result.output == "" + assert (tmp_path / ".goga" / "history" / "2031" / "feature-foo-bar").is_dir() + # --- Edge cases --- diff --git a/tests/test_cli.py b/tests/test_cli.py index f1abf123..06055eea 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,6 +2,8 @@ import inspect import json +import runpy +import sys from importlib.metadata import PackageNotFoundError from pathlib import Path from unittest import mock @@ -10,6 +12,7 @@ import pytest from click.testing import CliRunner from goga import app, commands +from goga import cli as cli_module from goga.cli import app as cli_app from tests.conftest import cwd as _cwd @@ -45,6 +48,18 @@ def test_both_imports_reference_same_object(self) -> None: assert app is cli_app +class TestModuleEntrypoint: + def test_python_dash_m_goga_runs_the_root_app(self, monkeypatch: pytest.MonkeyPatch) -> None: + """``python -m goga`` dispatches to the root app — the workflow's entrypoint.""" + monkeypatch.setattr(sys, "argv", ["goga", "history", "path", "-f", "plan.md"]) + sys.modules.pop("goga.__main__", None) + + with mock.patch.object(cli_module, "app", return_value=0) as app_mock: + runpy.run_module("goga", run_name="__main__") + + assert app_mock.call_args == mock.call() + + class TestApiShape: def test_app_is_click_group(self) -> None: """The app object is a click Group instance.""" From 8ab4d15b24732f3c558a79e4498241467c945923 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 28 Aug 2026 22:33:42 +0000 Subject: [PATCH 061/229] fix: describe current state instead of migration history in comments Acceptance finding F1: the naming module docstring narrated a migration whose temporary state had already ended (the pipeline cell's local copy was removed in ce10774), and two pipeline tests plus the facade comment carried the same changelog-style phrasing. Comments now state where the routines live and what the facade exports, in the present tense. --- goga/history/naming.py | 7 +++---- tests/commands/pipeline/test_branch.py | 16 ++++++++-------- tests/commands/pipeline/test_pipeline_command.py | 4 ++-- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/goga/history/naming.py b/goga/history/naming.py index b779722c..27aa1078 100644 --- a/goga/history/naming.py +++ b/goga/history/naming.py @@ -1,10 +1,9 @@ """Naming and time primitives for the history domain. The two routines declared in the cell CODEMANIFEST with ``location: -naming.py``: the pure slug transformer (moved verbatim from the pipeline -cell, whose own copy stays in place until the dedicated migration task) and -the single current-year point shared by every history consumer. Both are -pure — no git, no filesystem, no caching. +naming.py``: the pure slug transformer — the single owner of the topic slug +grammar — and the single current-year point shared by every history +consumer. Both are pure — no git, no filesystem, no caching. """ from __future__ import annotations diff --git a/tests/commands/pipeline/test_branch.py b/tests/commands/pipeline/test_branch.py index fd332a11..d28014b0 100644 --- a/tests/commands/pipeline/test_branch.py +++ b/tests/commands/pipeline/test_branch.py @@ -93,10 +93,10 @@ def _run(argv: list[str], **_kwargs: object) -> _GitResult: def _git_on_both_points(run_mock: mock.Mock) -> Iterator[mock.Mock]: """Lay one ``run`` dispatcher over BOTH git invocation points at once. - ``ensure_pipeline_branch`` spans two modules after the domain migration: - ``--show-current`` runs in ``goga.history.git.branch`` while ``show-ref``, - ``for-each-ref``, and ``switch`` run in this cell's ``branch`` module. A - mock on only one of the two points would let the other run real git. + ``ensure_pipeline_branch`` spans two modules: ``--show-current`` runs in + ``goga.history.git.branch`` while ``show-ref``, ``for-each-ref``, and + ``switch`` run in this cell's ``branch`` module. A mock on only one of the + two points would let the other run real git. """ with contextlib.ExitStack() as stack: stack.enter_context(mock.patch.object(history_git_branch_module.subprocess, "run", run_mock)) @@ -147,15 +147,15 @@ def test_ensure_pipeline_branch_signature(self) -> None: hints = typing.get_type_hints(branch_module.ensure_pipeline_branch) assert hints == {"branch_name": str, "return": str} - def test_moved_routines_are_bound_to_the_domain_not_local_copies(self) -> None: - """The moved names resolve to the DOMAIN objects — a local ``def`` copy would differ.""" + def test_domain_routines_are_bound_to_the_domain_not_local_copies(self) -> None: + """The domain names resolve to the DOMAIN objects — a local ``def`` copy would differ.""" import goga.history assert branch_module.normalize_topic_slug is goga.history.normalize_topic_slug assert branch_module.resolve_current_branch_name is goga.history.resolve_current_branch_name - def test_pipeline_facade_all_without_moved_names(self) -> None: - """The package facade exports exactly the seven names — the moved routines are gone.""" + def test_pipeline_facade_all_excludes_the_domain_routines(self) -> None: + """The package facade exports exactly the seven names — the domain routines live on the history facade.""" from goga.commands.pipeline import __all__ as facade_all assert facade_all == [ diff --git a/tests/commands/pipeline/test_pipeline_command.py b/tests/commands/pipeline/test_pipeline_command.py index 5a8a2f53..38c302b5 100644 --- a/tests/commands/pipeline/test_pipeline_command.py +++ b/tests/commands/pipeline/test_pipeline_command.py @@ -596,8 +596,8 @@ def test_pipeline_info_forms_ignore_run_flags(self, tmp_path: Path, monkeypatch: # The seven names declared in the cell CODEMANIFEST — the pipeline command, the # two container launchers, the two branch routines, and the two runtime-dir # helpers (declared since the cell existed, exported since release 1.3.0; the -# slug transformer and the current-branch reader moved to goga.history with no -# re-export from their old location). +# slug transformer and the current-branch reader belong to goga.history and +# are not re-exported from this facade). _PIPELINE_FACADE_ALL = [ "check_branch_occupancy", "clean_pipeline_runtime_dir", From b8bde15fc25f7b9591484a6be596d25b027aa381 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 02:51:10 +0300 Subject: [PATCH 062/229] fix: skills for goga history workaround --- .../skills/goga-cells-by-brainstorm/SKILL.md | 3 +-- goga/assets/skills/goga-plan-by-design/SKILL.md | 2 +- goga/assets/skills/goga-plan/SKILL.md | 14 +++----------- goga/assets/skills/goga-review-arch/SKILL.md | 6 +++--- goga/assets/skills/goga-review-plan/SKILL.md | 2 +- goga/assets/skills/goga-review/SKILL.md | 16 +++++++--------- 6 files changed, 16 insertions(+), 27 deletions(-) diff --git a/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md b/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md index 5b48c2c8..2ce46346 100644 --- a/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md +++ b/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md @@ -51,8 +51,7 @@ Apply the loaded DSL specifications, DSL application principles, and language ru #### Step 1. Locate the plan file - If the argument contains a file path — use it directly -- If the argument contains only `<topic>` — search for the file `.goga/history/<year>/<topic>/arch.md` -- If no argument is provided — discover all `arch.md` files under `.goga/history/*/` topic directories and present the list via **AskUserQuestion** +- If no argument is provided — use the path printed by `goga history path -f arch.md` - If the file is not found — halt and report the error to the user #### Step 2. Parse the plan structure diff --git a/goga/assets/skills/goga-plan-by-design/SKILL.md b/goga/assets/skills/goga-plan-by-design/SKILL.md index ebc2e99c..62a11657 100644 --- a/goga/assets/skills/goga-plan-by-design/SKILL.md +++ b/goga/assets/skills/goga-plan-by-design/SKILL.md @@ -58,7 +58,7 @@ Use for: #### Step 6: Load Design Document -Read the file from `.goga/history/*/<topic>/design.md` (4-digit year). `<topic>` is taken from skill arguments. +Read the file from the path printed by `goga history path -f design.md`. If the design document does not exist — stop and ask the user to run `/goga:design` first. --- diff --git a/goga/assets/skills/goga-plan/SKILL.md b/goga/assets/skills/goga-plan/SKILL.md index 4dce218d..8815e0c6 100644 --- a/goga/assets/skills/goga-plan/SKILL.md +++ b/goga/assets/skills/goga-plan/SKILL.md @@ -12,14 +12,6 @@ Retain the original arguments for the entire session. ### Design document identification -Determine `<topic>`: - -1. **Arguments provided** — use them as the function name. -2. **Arguments empty** — scan `.goga/history/*/` topic directories for `design.md` (4-digit year) and present the list via **AskUserQuestion**: - - **Directory does not exist or is empty** — stop and ask the user to run `/goga:design` first. - - **Single file** — use its topic directory name as `<topic>`. - - **Multiple files** — display the list via AskUserQuestion and prompt the user to select one. - -Check if `.goga/history/*/<topic>/design.md` exists (4-digit year). -**Does not exist** — stop and ask the user to run `/goga:design` first. -**Exists** — call `goga-plan-by-design` via the **Skill tool** with `<topic>` as the argument. +Check if the path printed by `goga history path -f design.md`: +- **Does not exist** — stop and ask the user to run `/goga:design` first. +- **Exists** — call skill `goga-plan-by-design` via the **Skill tool** with the printed path as the argument. diff --git a/goga/assets/skills/goga-review-arch/SKILL.md b/goga/assets/skills/goga-review-arch/SKILL.md index de39c76e..709299b7 100644 --- a/goga/assets/skills/goga-review-arch/SKILL.md +++ b/goga/assets/skills/goga-review-arch/SKILL.md @@ -23,7 +23,7 @@ all types, domains, and requirements as a unified whole — not each CODEMANIFES ## Input - **Required**: architecture plan at the path printed by `goga history path -f arch.md` -- **Optional**: task file at `.goga/history/*/<topic>/task.md` (4-digit year) — when present, used to verify requirements coverage +- **Optional**: task file at the path printed by `goga history path -f task.md` — when present, used to verify requirements coverage --- @@ -49,7 +49,7 @@ all types, domains, and requirements as a unified whole — not each CODEMANIFES - Classify plan cells: newly created vs. modified 6. Read the existing CODEMANIFESTs of cells the plan marks for modification 7. Read the existing `.usages/` files of cells marked for modification -8. If the task file `.goga/history/*/<topic>/task.md` exists (4-digit year) — read it for subsequent requirements coverage verification +8. If the task file at the path printed by `goga history path -f task.md` — read it for subsequent requirements coverage verification --- @@ -233,7 +233,7 @@ Missing edge cases — log as **Medium**. #### Step 4. Task Requirements Coverage -If the task file `.goga/history/*/<topic>/task.md` exists (4-digit year): +If the task file at the path printed by `goga history path -f task.md` exists: - Each requirement from the "Description" section must map to type(s) in the plan that fulfill it - Each acceptance criterion must have a contractual basis in the plan — the plan must enable fulfilling the criterion diff --git a/goga/assets/skills/goga-review-plan/SKILL.md b/goga/assets/skills/goga-review-plan/SKILL.md index 1b7be9d9..79e02b21 100644 --- a/goga/assets/skills/goga-review-plan/SKILL.md +++ b/goga/assets/skills/goga-review-plan/SKILL.md @@ -31,7 +31,7 @@ You **verify** the plan, **report** findings, and **fix** the plan upon discover Examples in other skills may use naming conventions of one language (e.g., snake_case), while the target language requires different conventions (e.g., PascalCase) — the language skill is the authoritative source for the target language. 2. Read the plan from the path printed by `goga history path -f plan.md` -3. Read the design document from `.goga/history/*/<topic>/design.md` (4-digit year) +3. Read the design document from the path printed by `goga history path -f design.md` 4. Read all relevant `CODEMANIFEST` files referenced by the design document 5. Load the DSL specification and DSL application principles: - Invoke `goga-cell` via the **Skill tool** — obtain the DSL reference diff --git a/goga/assets/skills/goga-review/SKILL.md b/goga/assets/skills/goga-review/SKILL.md index ac2fbfd6..11ac73ce 100644 --- a/goga/assets/skills/goga-review/SKILL.md +++ b/goga/assets/skills/goga-review/SKILL.md @@ -10,9 +10,7 @@ Arguments: $ARGUMENTS ### Review Type Detection -1. **Arguments contain a path under `.goga/history/`** — the path must match - `.goga/history/<year>/<topic>/<kind>.md`: - - `<year>` must be 4 digits (`\d{4}`); otherwise the path is not a valid artifact path → treat as **cell**. +1. **Arguments contain a path under the path printed by `goga history path -f <kind>.md`** : - Derive the review type by `<kind>` (the filename without `.md`): - `prd.md` → **prd** - `adr.md` → **adr** @@ -23,11 +21,11 @@ Arguments: $ARGUMENTS - Any other filename, or a path outside `.goga/history/` → **cell**. Extract `<target>` (the topic): - - For `.goga/history/2026/javascript-contract/arch.md` → `<target>` = `javascript-contract` + - For `.goga/history/<year>/<topic>/<kind>.md` → `<target>` = `<topic>` - For `src/cell/my-cell` → `<target>` = `src/cell/my-cell` - For `my-cell` → `<target>` = `my-cell` -2. **Arguments are empty** — prompt the user via AskUserQuestion: +2. **Arguments are empty** — ask the user: - **question**: "What do you want to review?" - **header**: "Review type" - **multiSelect**: false @@ -47,17 +45,17 @@ There is no review skill for this artifact kind yet. 1. Stop execution and report to the user that PRD/ADR review is not supported. #### architecture -Verify that `.goga/history/<year>/<target>/arch.md` exists — search `.goga/history/*/<target>/arch.md` (4-digit year). +Verify the path printed by `goga history path -f arch.md` exists. 1. **Not found** — stop execution and report to the user. 2. **Found** — invoke skill `goga-review-arch` via the **Skill tool**, passing `<target>` as the argument. #### design -Verify that `.goga/history/<year>/<target>/design.md` exists — search `.goga/history/*/<target>/design.md` (4-digit year). +Verify the path printed by `goga history path -f design.md` exists. 1. **Not found** — stop execution and report to the user. 2. **Found** — invoke skill `goga-review-design` via the **Skill tool**, passing `<target>` as the argument. #### plan -Verify that `.goga/history/<year>/<target>/plan.md` exists — search `.goga/history/*/<target>/plan.md` (4-digit year). +Verify the path printed by `goga history path -f plan.md` exists. 1. **Not found** — stop execution and report to the user. 2. **Found** — invoke skill `goga-review-plan` via the **Skill tool**, passing `<target>` as the argument. @@ -67,6 +65,6 @@ Verify that directory `<target>` and file `<target>/CODEMANIFEST` both exist. 2. **Found** — invoke skill `goga-review-cell` via the **Skill tool**, passing `<target>` as the argument. #### task -Verify that `.goga/history/<year>/<target>/task.md` exists — search `.goga/history/*/<target>/task.md` (4-digit year). +Verify the path printed by `goga history path -f task.md` exists. 1. **Not found** — stop execution and report to the user. 2. **Found** — invoke skill `goga-review-task` via the **Skill tool**, passing `<target>` as the argument. From a82188430532b13ab785cfc9acceda9c2bba6659 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 03:03:37 +0300 Subject: [PATCH 063/229] fix: skills for goga history workaround --- goga/assets/skills/goga-apply/SKILL.md | 12 ++++-------- goga/assets/skills/goga-plan/SKILL.md | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/goga/assets/skills/goga-apply/SKILL.md b/goga/assets/skills/goga-apply/SKILL.md index 784846be..bb8a92ed 100644 --- a/goga/assets/skills/goga-apply/SKILL.md +++ b/goga/assets/skills/goga-apply/SKILL.md @@ -2,7 +2,7 @@ name: goga-apply description: Materialize an architectural plan into the cells file structure --- -You are an architectural plan materialization engineer. You transform plans from `.goga/history/<year>/<topic>/arch.md` into a cells file structure (CODEMANIFEST, `.usages/`). +You are an architectural plan materialization engineer. You transform plans from the path printed by `goga history path -f arch.md` into a cells file structure (CODEMANIFEST, `.usages/`). ## Dispatch @@ -16,13 +16,9 @@ Retain the original arguments for the duration of the session. ### Resolving the architecture file -Resolve `<topic>`: +Check if the path printed by `goga history path -f arch.md`: -1. **Arguments supplied** — use the arguments as `<topic>`. -2. **No arguments** — scan `.goga/history/*/` topic directories for `arch.md` (4-digit year): - - **Directory missing or empty** — halt and report the error. - - **Single file** — use its topic directory name as `<topic>`. - - **Multiple files** — present the list via AskUserQuestion and prompt for selection. +- **Does not exist** — stop and ask the user to run `/goga:brainstorm` first. ## Pre-flight check: goga availability @@ -40,6 +36,6 @@ If the command is unavailable — halt and notify the user. Use the **Skill tool** to invoke `goga-cells-by-brainstorm` with `<topic>` as the argument. -The skill reads the plan from `.goga/history/<year>/<topic>/arch.md` and materializes it into a cells file structure (CODEMANIFEST, `.usages/`). +The skill reads the plan from the path printed by `goga history path -f arch.md` and materializes it into a cells file structure (CODEMANIFEST, `.usages/`). --- diff --git a/goga/assets/skills/goga-plan/SKILL.md b/goga/assets/skills/goga-plan/SKILL.md index 8815e0c6..06010090 100644 --- a/goga/assets/skills/goga-plan/SKILL.md +++ b/goga/assets/skills/goga-plan/SKILL.md @@ -14,4 +14,4 @@ Retain the original arguments for the entire session. Check if the path printed by `goga history path -f design.md`: - **Does not exist** — stop and ask the user to run `/goga:design` first. -- **Exists** — call skill `goga-plan-by-design` via the **Skill tool** with the printed path as the argument. +- **Exists** — use the **Skill tool** to invoke `goga-plan-by-design` with the printed path as the argument. From a9f99407525098fbafc76a2c4bc05714e269a2f5 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 03:14:20 +0300 Subject: [PATCH 064/229] fix: skills for goga history workaround --- goga/assets/skills/goga-cells-by-brainstorm/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md b/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md index 2ce46346..92f4ab7b 100644 --- a/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md +++ b/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md @@ -6,7 +6,7 @@ description: Creation and modification of cells by architecture plan ## Purpose -Creates new cells and modifies existing cells based on the architecture plan defined in `.goga/history/<year>/<topic>/arch.md`. Materializes the plan into the cell file structure: +Creates new cells and modifies existing cells based on the architecture plan defined in the path printed by `goga history path -f arch.md`. Materializes the plan into the cell file structure: CODEMANIFEST, `.usages/`. --- From 7c9fc01cac3f52aad5618ac2a972367e41dfa2f7 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 03:22:33 +0300 Subject: [PATCH 065/229] fix: pipelines history support --- goga/assets/pipelines/development.yml | 24 +------------------ goga/assets/pipelines/refinement.yml | 12 ++-------- .../assets/skills/goga-review-design/SKILL.md | 2 +- 3 files changed, 4 insertions(+), 34 deletions(-) diff --git a/goga/assets/pipelines/development.yml b/goga/assets/pipelines/development.yml index e7454bcc..b0df691f 100644 --- a/goga/assets/pipelines/development.yml +++ b/goga/assets/pipelines/development.yml @@ -6,10 +6,6 @@ description: "Development process" title: "Task-based architecture development" communication: true prompt: | - Use the task file at the path printed by `goga history path -f task.md`, if it exists - Save the architecture plan at the path printed by `goga history path -f arch.md` - (run `goga history ensure` first if the topic directory does not exist) - **CODEMANIFEST files** must be described at a functional and business-logic level, remaining strictly implementation-agnostic: - Focus on defining "what" the system should achieve (expected behavior, business rules, inputs, and outputs) rather than "how" to code it. - Avoid embedding code snippets, database queries, or specific technology details in the requirement descriptions to leave room for optimal engineering design. @@ -34,50 +30,35 @@ description: "Development process" - name: architecture-review title: "Review of the created architectural plan" communication: true - prompt: | - Review the architecture plan at the path printed by `goga history path -f arch.md` skills: - goga-review-arch - name: apply-architecture title: "Apply the created architectural plan" - prompt: | - Apply the architecture plan at the path printed by `goga history path -f arch.md` skills: - goga-apply - name: code-design title: "Designing architecture into code" communication: true - prompt: | - Save the design document at the path printed by `goga history path -f design.md` - (run `goga history ensure` first if the topic directory does not exist) skills: - goga-design - name: design-review title: "Review of the created design plan" communication: true - prompt: | - Review the design document at the path printed by `goga history path -f design.md` skills: - goga-review-design - name: coding-plan title: "Create the coding plan" communication: true - prompt: | - Use the design document at the path printed by `goga history path -f design.md` - Save the plan at the path printed by `goga history path -f plan.md` - (run `goga history ensure` first if the topic directory does not exist) skills: - goga-plan - name: plan-review title: "Review of the created coding plan" communication: true - prompt: | - Review the plan at the path printed by `goga history path -f plan.md` skills: - goga-review-plan @@ -87,14 +68,11 @@ description: "Development process" prompt: | Commit all added and modified files. - Constraints: - - Except changes in the `.goga/history/` tree owned by `goga history`. - - name: accept-result title: "Contracts & coverage audit" communication: true trigger: manual prompt: | - Commit changes after fixes + Commit changes after fixes. skills: - goga-accept diff --git a/goga/assets/pipelines/refinement.yml b/goga/assets/pipelines/refinement.yml index 0ef15305..2a3848d2 100644 --- a/goga/assets/pipelines/refinement.yml +++ b/goga/assets/pipelines/refinement.yml @@ -6,9 +6,6 @@ description: "Task refinement process" title: "Product definition & create PRD" communication: true prompt: | - Save the PRD file at the path printed by `goga history path -f prd.md` - (run `goga history ensure` first if the topic directory does not exist) - Constrains: - Don't research a project until you receive the task skills: @@ -21,8 +18,8 @@ description: "Task refinement process" Use the PRD file at the path printed by `goga history path -f prd.md`, if it exists. If PRD file does not exist — ask user about task. - Save the ADR file at the path printed by `goga history path -f adr.md` - (run `goga history ensure` first if the topic directory does not exist) + Constrains: + - Don't research a project until you receive the task Communication: - You **MUST** follow the `File-Based Dialog Protocol` for every round of questions. @@ -36,16 +33,11 @@ description: "Task refinement process" Use the ADR at the path printed by `goga history path -f adr.md` as the input for task formulation, if it exists. If ADR does not exist — try the PRD file at the path printed by `goga history path -f prd.md`. If PRD file does not exists — ask user about task. - - Save the task file at the path printed by `goga history path -f task.md` - (run `goga history ensure` first if the topic directory does not exist) skills: - goga-propose - name: task-review title: "Review of the created task" communication: true - prompt: | - Review the task file at the path printed by `goga history path -f task.md` skills: - goga-review-task diff --git a/goga/assets/skills/goga-review-design/SKILL.md b/goga/assets/skills/goga-review-design/SKILL.md index ff4ef524..749caca0 100644 --- a/goga/assets/skills/goga-review-design/SKILL.md +++ b/goga/assets/skills/goga-review-design/SKILL.md @@ -6,7 +6,7 @@ description: Design document verification via code stack tracing ## Purpose -Verifies the design document for **logical correctness** by tracing the full code stack for each entry point and test scenario. This is a **verification pass** — the goal is to find logical errors before plan creation. +Verifies the design document (the file at the path printed by `goga history path -f design.md`) for **logical correctness** by tracing the full code stack for each entry point and test scenario. This is a **verification pass** — the goal is to find logical errors before plan creation. You do **not** write implementation code. You **trace** each logical chain and **find** where the logic breaks. From 35cf56643da949e486f777c9b480e73de80fa748 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 02:18:26 +0000 Subject: [PATCH 066/229] feat: workflow notes instruction compiled to flow buttons --- .goga/usages/cooks/afm.md | 3 +- goga/pipeline/CODEMANIFEST | 2 +- .../pipeline/compiler/.usages/compile-flow.md | 36 +++++- .../compiler/.usages/serialize-flow.md | 13 +- goga/pipeline/compiler/CODEMANIFEST | 113 ++++++++++++++---- .../workflow/.usages/parse-workflow.md | 51 ++++++-- goga/pipeline/workflow/CODEMANIFEST | 86 +++++++++---- 7 files changed, 238 insertions(+), 66 deletions(-) diff --git a/.goga/usages/cooks/afm.md b/.goga/usages/cooks/afm.md index b020bded..cf914988 100644 --- a/.goga/usages/cooks/afm.md +++ b/.goga/usages/cooks/afm.md @@ -113,8 +113,9 @@ afm honors the following additional per-stage keys inside each stage of a flow-f | `script` | str | Shell script run as the stage's action (mutually exclusive with the stage's `prompt`/`skills`). | | `script_after` | str | Shell script run after the stage's agent invocation. | | `script_timeout` | str | Timeout for the stage's script action (Go duration), applied via afm's script-timeout defaults. Authored by the goga pipeline compiler from a `timeout` stage directive; the value passes verbatim — a malformed duration surfaces at runtime. | +| `buttons` | map[str]str | Per-stage note buttons — a map of "button name → prompt text". Accepted by `afm validate` (single- and multi-line values); in the current binary the key is not yet interpreted (forward-compat) — it is neither rejected nor processed. Compiled by the goga workflow layer from a `notes` instruction (`workflow.stages.<name>.notes`). | -These keys are optional per stage; stages that do not carry them behave as before (backward compatible). goga authors `auto_approve` from its `approve: auto` workflow directive, translates its authoring `before_script`/`script`/`after_script` stage-body keys into `script_before`/`script`/`script_after`, compiles its authoring `timeout` stage directive into `script_timeout` (verbatim), and authors `auto_run: false` from a `trigger: manual` stage directive or a workflow `manual: true` instruction (never `true`; the key's absence is the norm). +These keys are optional per stage; stages that do not carry them behave as before (backward compatible). goga authors `auto_approve` from its `approve: auto` workflow directive, translates its authoring `before_script`/`script`/`after_script` stage-body keys into `script_before`/`script`/`script_after`, compiles its authoring `timeout` stage directive into `script_timeout` (verbatim), and authors `auto_run: false` from a `trigger: manual` stage directive or a workflow `manual: true` instruction (never `true`; the key's absence is the norm). goga compiles its workflow `notes` instruction (map str→str) into the per-stage `buttons` field; the interpretation of the buttons belongs to afm (a separate repository) — goga only serializes the field. ## Integration pattern — running afm in a container diff --git a/goga/pipeline/CODEMANIFEST b/goga/pipeline/CODEMANIFEST index f66e7ec1..6f5d64e4 100644 --- a/goga/pipeline/CODEMANIFEST +++ b/goga/pipeline/CODEMANIFEST @@ -624,7 +624,7 @@ Annotations: | `WorkflowDocument` whose stages map carries only the skip entries (prompt None, extend empty); skip applies to a workflow-less pipeline - Construct `WorkflowStage` with skip=True and all other fields at their - defaults + defaults — notes stays None (the model field default) - Stage-name validation is NOT performed here — the compiler's strict check raises a structural error on a name absent from the pipeline body; this routine stays declarative diff --git a/goga/pipeline/compiler/.usages/compile-flow.md b/goga/pipeline/compiler/.usages/compile-flow.md index 2408434b..373dd9e3 100644 --- a/goga/pipeline/compiler/.usages/compile-flow.md +++ b/goga/pipeline/compiler/.usages/compile-flow.md @@ -8,7 +8,7 @@ and detects the body format, applies per-stage workflow overrides + loop-expansi ## Stage-body field translation (canonical order) Output FlowStage fields, canonical order: -interactive, auto_approve, auto_run, command, prompt, description, agents, supervisor, +interactive, auto_approve, auto_run, command, prompt, description, buttons, agents, supervisor, supervisor_prompt, skills, script_before, script, script_after, script_timeout, <unknown A-Z>. Authoring key → output key (the authoring key is consumed, not passed through): @@ -18,6 +18,8 @@ Authoring key → output key (the authoring key is consumed, not passed through) - script → script - after_script → script_after - timeout → script_timeout (verbatim str; requires script; omitempty) +- notes (workflow.stages.<name>) → buttons (map verbatim; slot right after + description; omitempty) - roles → agents (via translate_role; default ["auto"] when absent/empty). In a body carrying `script`, NO agents key is emitted at all (afm rejects the combination) — neither the default nor a translated roles value — while the roles elements are still @@ -58,6 +60,30 @@ Interactions: - loop-expansion: every copy of a manual stage carries `auto_run: false` - `PipelineDocument` is unaffected (output-side only; source bodies never mutated) +## notes (workflow directive: map str→str) → buttons (flow-file) + +`notes` is a per-stage workflow instruction carrying note buttons — a map of +"note name → prompt text". The compiler translates it into the afm per-stage +key `buttons`. + +- authoring source is SINGLE: `workflow.stages.<name>.notes`. An authoring + `buttons` key in a stage body (pipeline-file stage or extend-stage body) is a + structural error — "buttons key is forbidden in stage body; use notes in + workflow.stages" +- a non-empty notes map assembles `buttons` (the map verbatim — keys and values + unchanged) into the canonical slot immediately after `description` +- `notes: {}` (empty map) equals absence — no `buttons` key in the output +- `notes` applies per stage name to extend-stages as well (note buttons of a new + stage are authored as `stages.<new-stage-name>.notes`; `notes` in an + extend-entry is rejected by the workflow parser) +- loop-expanded copies carry the same `buttons` +- `skip: true` wins — the stage is removed before the notes application +- a name absent from both the pipeline body and the extend-stages raises the + existing structural error "unknown stage name in workflow.stages: <name>" +- `PipelineDocument` is unaffected (output-side only; source bodies never mutated) +- interpretation of the buttons belongs to afm — the compiler only assembles and + serializes the field + ## approve (workflow directive: auto | plan | dialog) `approve` is a per-stage / extend-entry workflow field — a declarative directive @@ -126,7 +152,7 @@ body) translated to the afm per-stage key `script_timeout`. ## Key presence -Stages without approve, without script directives, without communication/roles -changes, and without a manual-effective trigger produce no auto_approve / -auto_run / script_* / script_timeout keys — those keys appear only when their -source directive is present. +Stages without approve, without notes, without script directives, without +communication/roles changes, and without a manual-effective trigger produce +no auto_approve / buttons / auto_run / script_* / script_timeout keys — those +keys appear only when their source directive is present. diff --git a/goga/pipeline/compiler/.usages/serialize-flow.md b/goga/pipeline/compiler/.usages/serialize-flow.md index c073b601..5900b063 100644 --- a/goga/pipeline/compiler/.usages/serialize-flow.md +++ b/goga/pipeline/compiler/.usages/serialize-flow.md @@ -6,7 +6,7 @@ flow-style agents, block-style skills/depends_on/top-level prompt). ## Canonical per-stage key order -interactive, auto_approve, auto_run, command, prompt, description, agents, +interactive, auto_approve, auto_run, command, prompt, description, buttons, agents, supervisor, supervisor_prompt, skills, script_before, script, script_after, script_timeout, <unknown A-Z>. @@ -18,6 +18,8 @@ assembly (`compile_flow`); `serialize_flow` iterates `fields` as-is. - agents: flow-style - skills, depends_on, top-level prompt: block-style - auto_approve, auto_run: plain bool scalars +- buttons: a block-style mapping; each value — a plain scalar when single-line, + a block-literal scalar when multi-line (the script-family pattern) - script_before/script/script_after: plain scalars when single-line; block-literal scalars when multi-line - script_timeout: plain scalar when single-line; block-literal scalar when @@ -26,7 +28,8 @@ assembly (`compile_flow`); `serialize_flow` iterates `fields` as-is. ## Key presence -Stages without auto_approve / auto_run / script_before / script / script_after / -script_timeout serialize without those keys — each appears only when its source -directive is present. `auto_run` appears only for a manual-effective stage, -always as `auto_run: false`. +Stages without auto_approve / auto_run / buttons / script_before / script / +script_after / script_timeout serialize without those keys — each appears only +when its source directive is present. `auto_run` appears only for a +manual-effective stage, always as `auto_run: false`. `buttons` appears only when +the workflow supplied a non-empty notes instruction for the stage. diff --git a/goga/pipeline/compiler/CODEMANIFEST b/goga/pipeline/compiler/CODEMANIFEST index 332918da..f63bbed1 100644 --- a/goga/pipeline/compiler/CODEMANIFEST +++ b/goga/pipeline/compiler/CODEMANIFEST @@ -76,9 +76,9 @@ Annotations: | responsibility, not this cell's. Canonical `FlowStage` fields key order: interactive, auto_approve, auto_run, - command, prompt, description, agents, supervisor, supervisor_prompt, skills, - script_before, script, script_after, script_timeout, then alphabetically- - sorted unknown keys. command is populated from the agent + command, prompt, description, buttons, agents, supervisor, supervisor_prompt, + skills, script_before, script, script_after, script_timeout, then + alphabetically-sorted unknown keys. command is populated from the agent field of `WorkflowStage` (composed as /home/goga/bin/AGENT-as-claude.sh); description is populated from the prompt field of `WorkflowStage`. Both are independent channels — pipeline-file prompt and workflow-file prompt @@ -259,6 +259,21 @@ Annotations: | Loop-expanded copies carry the stage's effective trigger, so every copy of a manual stage assembles auto_run: false. + Workflow notes instruction: when the workflow stages block carries notes + for a stage (a map of note name → prompt text on `WorkflowStage`), the + compiler assembles the per-stage buttons field of the flow-file in the + canonical slot immediately after description; the map passes through + verbatim — keys and values unchanged. An authoring buttons key in a stage + body (pipeline-file stage OR embedded extend-stage) is a structural error + "buttons key is forbidden in stage body; use notes in workflow.stages" — + buttons are authored ONLY through the workflow notes instruction (a single + authoring source; no collisions). The instruction applies per stage name to + embedded extend-stages as well; loop-expanded copies carry the same buttons; + a skipped stage never reaches the application (skip removal runs first). + Interpretation of the buttons belongs to afm — the compiler only assembles + and serializes the field. Output-side only — `PipelineDocument` stays the + faithful mirror of the source pipeline-file. + Stage script directives: before_script, script, after_script are string stage-body directives translated to script_before, script, script_after (the authoring keys are consumed, not passed through). A stage body that carries @@ -564,10 +579,13 @@ Annotations: | `depends_on`: predecessor step ids, or None. None produces no depends_on key in output; an empty list produces depends_on []. `fields`: extra step fields in canonical key order (interactive, - auto_approve, auto_run, command, prompt, description, agents, - supervisor, supervisor_prompt, skills, script_before, script, - script_after, script_timeout, then alphabetically-sorted + auto_approve, auto_run, command, prompt, description, buttons, + agents, supervisor, supervisor_prompt, skills, script_before, + script, script_after, script_timeout, then alphabetically-sorted unknown keys). + buttons (map of str→str) is present only when the workflow + supplied a non-empty notes instruction for the stage — the map + passes through verbatim. auto_run (bool) is present only when the stage's effective trigger is manual — the value is always False; auto_run: true is never assembled. script_before/script/script_after (str) @@ -620,9 +638,11 @@ Annotations: | Predecessor step ids, or None when absent. "fields -> dict[str, Any]": | Extra fields in canonical key order: interactive, auto_approve, - auto_run, command, prompt, description, agents, supervisor, + auto_run, command, prompt, description, buttons, agents, supervisor, supervisor_prompt, skills, script_before, script, script_after, - script_timeout, then alphabetically-sorted unknown keys. auto_approve + script_timeout, then alphabetically-sorted unknown keys. buttons + (map of str→str) is present only when the workflow supplied a + non-empty notes instruction for the stage. auto_approve (bool) is present only when the effective approve directive drives the roles effect ("auto"/"dialog") + planner-in-roles fired; auto_run (bool) is present only when the stage's effective trigger is manual — the value @@ -805,8 +825,8 @@ Annotations: | 2. For each `FlowStage` in `doc`, build a representation in canonical key order: id, name, then the stage fields as-is (already in canonical order — interactive, auto_approve, auto_run, command, - prompt, description, agents, supervisor, supervisor_prompt, skills, - script_before, script, script_after, script_timeout, then + prompt, description, buttons, agents, supervisor, supervisor_prompt, + skills, script_before, script, script_after, script_timeout, then alphabetically-sorted unknown keys), then depends_on when it is not None. The serializer does NOT reorder — canonical order is fixed at `FlowStage` assembly @@ -816,7 +836,10 @@ Annotations: | plain bool scalars; script_before / script / script_after as plain scalars when single-line and block-literal scalars when multi-line; script_timeout as a plain scalar when single-line and a block-literal - scalar when multi-line (the script-family pattern) + scalar when multi-line (the script-family pattern); buttons values: + single-line text — a plain scalar (quoted as needed), multi-line text + — a block-literal scalar; the buttons map itself is emitted as a + regular block-style mapping 4. Ensure the result ends with a single trailing newline 5. Return the string @@ -828,7 +851,7 @@ Annotations: | when present), optional root_dir (second, when supplied), then fixed top-level key order (name, description, stages), canonical per-stage key order (including auto_approve, auto_run, command, - description, and script_before/script/script_after/script_timeout), + description, buttons, and script_before/script/script_after/script_timeout), flow-style for agents, block-style for skills and depends_on, auto_approve and auto_run as plain bool scalars, single trailing newline - When `doc` prompt is None — the output omits the prompt key @@ -847,6 +870,9 @@ Annotations: | auto_run serializes only as auto_run: false - a stage without an authored timeout serializes without the script_timeout key + - buttons values: single-line text as a plain scalar (quoted as needed), + multi-line text as a block-literal scalar; a stage without a buttons key + serializes without it (byte-identical output for notes-free pipelines) Constraints: - Do not reorder keys — the fields order is the caller's responsibility @@ -1091,6 +1117,13 @@ Annotations: | error "manual: false on non-manual stage <NAME>" The rewrite targets the working body copy only — the original parsed body and PipelineDocument stay untouched + - effective notes = the stages-block notes for this name (None + when the entry provides no notes key or the name has no + entry). The extend-seeded branch carries notes=None — notes + has no inline extend equivalent (notes in an extend-entry is + rejected by the workflow parser). An overlay merge passes the + notes field explicitly — an overlay that omitted it would + silently drop the instruction 4.6. Build a new ordered list of steps with loop-expansion applied — expands an extend-stage when the effective loop for that name is >= 2 (producing NAME-1..N): @@ -1159,6 +1192,12 @@ Annotations: | prompt/skills in stage <name>" (every stage, incl. loop-expanded copies; same pass as the agents/interactive-forbidden checks). before_script/after_script do NOT trigger the error + - In both branches, BEFORE assembling fields, check the + authoring-buttons prohibition: an authoring buttons key in a stage + body (pipeline-file stage OR embedded extend-stage) raises a + structural error "buttons key is forbidden in stage body; use notes + in workflow.stages" (same pass as the agents/interactive-forbidden + checks) - In both branches, BEFORE canonical ordering, inject default stage fields when the source step body has no usable roles value (missing key, explicit null, or empty list): set agents to @@ -1231,12 +1270,18 @@ Annotations: | emitted. The authoring trigger key is consumed by the translation — it never reaches the output as an unknown key. Uniform across every loop-expanded copy + - Workflow notes translation: when the effective notes for the stage is + not None — assemble buttons (the map verbatim, keys and values + unchanged) into the fields canonical slot immediately after + description; uniform across every loop-expanded copy. A stage without + an effective notes assembles NO buttons key. Output-side only — the + source bodies and PipelineDocument stay untouched - In both branches, assemble the fields of each `FlowStage` in the EXTENDED canonical key order (interactive, auto_approve, auto_run, - command, prompt, description, agents, supervisor, supervisor_prompt, - skills, script_before, script, script_after, script_timeout, then - alphabetically-sorted unknown keys), copying from the - (defaults-injected) step body. + command, prompt, description, buttons, agents, supervisor, + supervisor_prompt, skills, script_before, script, script_after, + script_timeout, then alphabetically-sorted unknown keys), copying + from the (defaults-injected) step body. auto_approve (bool) is present only when the effective approve directive drives the roles effect ("auto"/"dialog") + planner-in-roles fired; script_before / script / script_after / script_timeout (str) @@ -1293,7 +1338,9 @@ Annotations: | injected INTO the body at step 4.5 before this assembly, so a single body dict is the sole source for fields. Pipeline-file fields and workflow-injected fields coexist in the same dict without - merging or collision + merging or collision. The ONE exception is buttons: assembled from + the effective notes only, never threaded into the body (the + authoring-buttons prohibition closes that channel) - Unknown stage names in workflow.stages (names absent from both the original body and the extend-stages) raise a structural error "unknown stage name in workflow.stages: <name>"; @@ -1421,9 +1468,9 @@ Annotations: | - script together with prompt and/or skills is a structural error "script is mutually exclusive with prompt/skills in stage <name>"; before_script/after_script are compatible (no error) - - auto_approve, script_before, script, and script_after appear only when - their source directive is present; flow-files without those directives - carry none of these keys + - auto_approve, buttons, script_before, script, and script_after appear + only when their source directive is present; flow-files without those + directives carry none of these keys - trigger (when present in a stage body or extend body) must be exactly on_success or manual; any other value (including on_failure) is a structural error "trigger must be one of: on_success, manual" @@ -1451,6 +1498,19 @@ Annotations: | - script_timeout occupies the canonical fields slot immediately after script_after — a pipeline without timeout compiles byte-identically (no script_timeout key anywhere) + - buttons is authored ONLY via the workflow stages-block notes instruction; + an authoring buttons key in a stage body (pipeline-file stage or + extend-stage body) is a structural error raised by `compile_flow` + - a non-None effective notes assembles the stage's buttons field (the map + verbatim) in the canonical slot immediately after description; every + loop-expanded copy carries the same buttons + - a stage without an effective notes assembles no buttons key — a pipeline + without notes compiles byte-identically (no buttons key anywhere) + - notes on an extend-stage name applies (embedded before validation); a + name absent from both the original body and the extend-stages raises the + existing structural error "unknown stage name in workflow.stages: <name>" + - buttons is output-side only — `PipelineDocument` and the source bodies + are never affected Constraints: - Do not read AFM_DIR or any environment variable — `flow_path` is @@ -1534,7 +1594,7 @@ Annotations: | `FlowStage` field only - Canonical key order is fixed at `FlowStage` assembly — the full order (interactive, auto_approve, auto_run, command, prompt, - description, agents, supervisor, supervisor_prompt, skills, + description, buttons, agents, supervisor, supervisor_prompt, skills, script_before, script, script_after, script_timeout, then alphabetically-sorted unknown keys) - Do not leak auto_approve or script_* into `PipelineDocument` — they @@ -1548,6 +1608,17 @@ Annotations: | - Do not mutate the original parsed body when applying manual — the rewrite lives on the working copy; PipelineDocument carries the source bodies + - Do not interpret the buttons — the runtime meaning belongs to afm; the + compiler only assembles and serializes the field + - Do not let an authoring buttons key pass through to output as an unknown + key — it is rejected as a structural error + - Do not apply notes to a skipped stage — skip removal runs before the + override pass + - Do not thread the effective notes into the (working) stage body — unlike + the command/description overrides, buttons are assembled into `FlowStage` + fields directly from the effective-notes value; the body channel is closed + by the authoring-buttons prohibition (an injected key would trip the + step-5 check) "translate_role(role: str) -> name: str": location: compile_flow.py diff --git a/goga/pipeline/workflow/.usages/parse-workflow.md b/goga/pipeline/workflow/.usages/parse-workflow.md index 93240999..605be4b9 100644 --- a/goga/pipeline/workflow/.usages/parse-workflow.md +++ b/goga/pipeline/workflow/.usages/parse-workflow.md @@ -3,8 +3,8 @@ `parse_workflow` reads a project-level workflow-file, validates its structure, and returns a `WorkflowDocument` carrying declarative instructions for the compiler. It is a pure parser: no I/O beyond the path it receives, no agent resolution, no loop -expansion, no stage removal, no approval or manual-launch logic — all of that is -the compiler's job. +expansion, no stage removal, no approval, manual-launch, or note-buttons logic — +all of that is the compiler's job. ## Accepted structure @@ -21,6 +21,34 @@ Top-level keys: `prompt`, `stages`, `extend`. | skip | bool | instruct the compiler to DELETE the stage | | approve | str ("auto"/"plan"/"dialog") | auto-approval directive; one of the three accepted; declarative | | manual | bool | manual-launch instruction; strictly bool; stages block only; declarative | +| notes | map[str]str | note buttons — map "note name → prompt text"; compiled into the stage's `buttons` field; stages block only; declarative | + +## The notes field + +`notes` is an optional per-stage instruction (stages block only) carrying note +buttons — a map of "note name → prompt text". Structurally validated at parse +time: + +- a non-map value → structural error "non-mapping notes in workflow.stages.<name>" +- a non-str value inside the map → structural error "non-str value in + workflow.stages.<name>.notes.<key>" + +| In the workflow-file | WorkflowStage.notes | Meaning | +|----------------------|---------------------|---------| +| key absent | None | no instruction — the stage compiles without `buttons` | +| `notes: {...}` non-empty | the map | the compiler emits the stage's `buttons` field with the same keys and values | +| `notes: {}` empty | None | equals absence — no `buttons` in the output | + +This cell does NOT act on `notes`. The compiler consumes it: a non-None notes +map becomes the per-stage `buttons` field of the flow-file (canonical slot +right after `description`); every loop-expanded copy carries the same +`buttons`; a skipped stage never reaches the application. Interpretation of +the buttons belongs to afm — goga only compiles the field. + +`notes` applies to extend-stages by name: note buttons of a new stage from +`extend` are authored in the same workflow-file as +`stages.<new-stage-name>.notes` — there is no separate authoring inside an +extend-entry. ## Extend-entry keys (workflow.extend.<name>) @@ -35,6 +63,10 @@ approve/depends_on. in workflow.extend.<name>") — the launch mode of a new stage is authored in its body via `trigger`, not via workflow instructions. +`notes` is forbidden in an extend-entry (structural error "notes is forbidden +in workflow.extend.<name>") — note buttons of a new stage are authored in the +stages block by the new stage's name. + ## The manual field `manual` is an optional per-stage instruction (stages block only) controlling the @@ -62,7 +94,7 @@ there is nothing to cancel. - in the stages block it is an unknown key → structural error "unknown key in workflow.stages.<name>: trigger; valid keys: agent, prompt, loop, skills, - skip, approve, manual" + skip, approve, manual, notes" - in an extend-entry body it is legal and passes through verbatim — the compiler validates its value (`on_success` | `manual`) at compilation time @@ -75,19 +107,22 @@ structural error raised at parse time: - stages-block: "approve must be one of: auto, plan, dialog in workflow.stages.<name>" - extend-entry: "approve must be one of: auto, plan, dialog in workflow.extend.<name>" -This cell does NOT act on `approve`. The compiler -consumes it and applies two INDEPENDENT effects, each value driving a subset: -for a stage with `approve: "auto"` the stage body's `communication: true` is -suppressed (no `interactive: true`) AND its `roles` containing `planner` emits +This cell does NOT act on `approve`. The compiler consumes it and applies two +INDEPENDENT effects, each value driving a subset: for a stage with +`approve: "auto"` the stage body's `communication: true` is suppressed (no +`interactive: true`) AND its `roles` containing `planner` emits `auto_approve: true`; `approve: "plan"` drives only the `interactive` suppression (communication effect); `approve: "dialog"` drives only the `auto_approve` emission (roles effect). ## Anti-patterns -- Do not perform approval or manual-launch logic here — this cell stays declarative. +- Do not perform approval, manual-launch, or note-buttons logic here — this cell + stays declarative. - Do not pass `approve` into the extend-entry `body` — it is extracted inline. - Do not author `manual` in an extend-entry — the launch mode of a new stage belongs to its body (`trigger`). +- Do not author `notes` in an extend-entry — note buttons of a new stage are + authored in the stages block by its name. - Do not author `trigger` in the stages block — it is a stage-body field, not a workflow modifier. diff --git a/goga/pipeline/workflow/CODEMANIFEST b/goga/pipeline/workflow/CODEMANIFEST index ef1438a6..90d93853 100644 --- a/goga/pipeline/workflow/CODEMANIFEST +++ b/goga/pipeline/workflow/CODEMANIFEST @@ -50,6 +50,13 @@ Annotations: | stage. This cell performs NO manual-launch logic — the import graph stays one-directional. + notes is a declarative note-buttons instruction (a map of note name → prompt + text, str→str, validated structurally, stages-block only). This cell extracts + it into `WorkflowStage`; the compiler consumes it to emit the per-stage + buttons field of the flow-file. Interpretation of the buttons belongs to + afm — this cell performs NO note-button logic, and the import graph stays + one-directional. + trigger is a full stage-body field, not a workflow key. In the stages block it is an unknown key; in an extend-entry body it passes through verbatim inside the extend-stage body and its value (on_success | manual) is @@ -59,13 +66,14 @@ Annotations: | --- -"WorkflowStage(agent: str | None = None, prompt: str | None = None, loop: int | None = None, skills: list[str] | None = None, skip: bool = False, approve: str | None = None, manual: bool | None = None)": +"WorkflowStage(agent: str | None = None, prompt: str | None = None, loop: int | None = None, skills: list[str] | None = None, skip: bool = False, approve: str | None = None, manual: bool | None = None, notes: dict[str, str] | None = None)": location: workflow_stage.py annotations: | Data model of a single per-stage override instruction in a workflow-file — which agent, which prompt, how many loop iterations, which skills to merge, whether to SKIP (delete) the stage, an optional auto-approval directive, - and an optional manual-launch instruction. Constructed by `parse_workflow` + an optional manual-launch instruction, and an optional note-buttons + instruction. Constructed by `parse_workflow` from one entry of the workflow-file stages map; carried verbatim inside `WorkflowDocument`. @@ -100,30 +108,40 @@ Annotations: | a structural error). This cell does NOT act on `manual` — the compiler applies the force / cancel logic. None when not specified. + `notes`: optional map of note name → prompt text consumed by the compiler + to emit the per-stage buttons field of the flow-file. None when + not specified; an empty map equals absence — `parse_workflow` + normalizes it to None, so the model carries either None or a + non-empty map. This cell does NOT act on `notes` — it is + declarative; the compiler emits the buttons. Build the data model with the standard library dataclasses module (NOT pydantic, per `convention`). Use @dataclass(kw_only=True). Requirements: - Use @dataclass(kw_only=True) (per `convention`) - - Fields agent/prompt/loop/skills/approve/manual default to None; `skip` - defaults to False (NOT None); `manual` defaults to None (NOT False) — an - absent key and an explicit manual: false are DIFFERENT instructions and - must stay distinguishable to the compiler - - Field order is fixed: agent, prompt, loop, skills, skip, approve, manual - — matches the canonical order of the per-stage keys in the workflow-file + - Fields agent/prompt/loop/skills/approve/manual/notes default to None; + `skip` defaults to False (NOT None); `manual` defaults to None (NOT + False) — an absent key and an explicit manual: false are DIFFERENT + instructions and must stay distinguishable to the compiler + - Field order is fixed: agent, prompt, loop, skills, skip, approve, + manual, notes — matches the canonical order of the per-stage keys in + the workflow-file - `approve` accepts ONLY "auto"/"plan"/"dialog"; `manual` accepts ONLY True/False — any other value is rejected by `parse_workflow` as a structural error before this dataclass is built Constraints: - - Do not validate loop >= 1 / skills list[str] / skip bool / manual bool - here — `parse_workflow` enforces these during parsing + - Do not validate loop >= 1 / skills list[str] / skip bool / manual bool / + notes dict[str, str] here — `parse_workflow` enforces these during + parsing - Do not resolve agent to a wrapper path, merge skills, DELETE the stage, or RECONNECT dependents here — all the compiler's job - Do not act on `approve` or `manual` here — they are declarative; the compiler performs the approval and manual-launch logic when applying the workflow + - Do not act on `notes` here — it is declarative; the compiler emits the + buttons when applying the workflow properties: "agent -> str | None": | Agent name consumed by the compiler to compose the wrapper path, or None. @@ -145,6 +163,10 @@ Annotations: | False = cancel). Declarative — extracted here, consumed by the compiler to force or cancel the stage's manual launch mode. None when not specified. + "notes -> dict[str, str] | None": | + Optional note-buttons instruction (map of note name → prompt text). + Declarative — extracted here, consumed by the compiler to emit the stage's + buttons field. None when not specified (an empty map equals absence). "WorkflowExtendStage(before: list[str] | None = None, after: list[str] | None = None, agent: str | None = None, loop: int | None = None, approve: str | None = None, body: dict[str, Any])": location: workflow_extend_stage.py @@ -307,9 +329,9 @@ Annotations: | 6.1.1. If the stage value is not a dict — raise a structural error "non-mapping stage NAME in workflow.stages" 6.1.2. Validate the key set of the stage value against agent, prompt, - loop, skills, skip, approve, manual: an unknown key raises "unknown - key in workflow.stages.NAME: KEY; valid keys: agent, prompt, loop, - skills, skip, approve, manual" + loop, skills, skip, approve, manual, notes: an unknown key raises + "unknown key in workflow.stages.NAME: KEY; valid keys: agent, prompt, + loop, skills, skip, approve, manual, notes" 6.1.3. agent (when present) must be a str; otherwise raise "non-str value in workflow.stages.NAME.agent" 6.1.4. prompt (when present) must be a str; otherwise raise @@ -327,9 +349,14 @@ Annotations: | workflow.stages.NAME" (str outside the set) 6.1.9. manual (when present) must be a bool; otherwise raise a structural error "non-bool value in workflow.stages.NAME.manual" - 6.1.10. Build a `WorkflowStage` from the validated values (agent, prompt, - loop, skills, skip, approve, manual); an absent manual key yields - None (NOT False) + 6.1.10. notes (when present) must be a dict and every value must be a + str; otherwise raise a structural error "non-mapping notes in + workflow.stages.NAME" (non-dict) or "non-str value in + workflow.stages.NAME.notes.KEY" (non-str value). An empty map equals + absence — build with notes=None + 6.1.11. Build a `WorkflowStage` from the validated values (agent, prompt, + loop, skills, skip, approve, manual, notes); an absent manual key + yields None (NOT False); an empty notes map yields None 6.2. For each entry of extend (when present), identified by stage name and entry value: 6.2.1. If the entry value is not a dict — raise a structural error @@ -340,26 +367,28 @@ Annotations: | error "skip is forbidden in workflow.extend.NAME" 6.2.4. If the entry value contains a manual key — raise a structural error "manual is forbidden in workflow.extend.NAME" - 6.2.5. before (when present) must be a list[str]; otherwise raise + 6.2.5. If the entry value contains a notes key — raise a structural + error "notes is forbidden in workflow.extend.NAME" + 6.2.6. before (when present) must be a list[str]; otherwise raise "non-list-of-str before in workflow.extend.NAME" - 6.2.6. after (when present) must be a list[str]; otherwise raise + 6.2.7. after (when present) must be a list[str]; otherwise raise "non-list-of-str after in workflow.extend.NAME" - 6.2.7. agent (when present) must be a str; otherwise raise + 6.2.8. agent (when present) must be a str; otherwise raise "non-str value in workflow.extend.NAME.agent" - 6.2.8. loop (when present) must be an int and >= 1; otherwise raise + 6.2.9. loop (when present) must be an int and >= 1; otherwise raise "non-int value in workflow.extend.NAME.loop" (non-int) or "loop must be >= 1 in workflow.extend.NAME" (int < 1) - 6.2.9. approve (when present) must be a str and one of "auto"/"plan"/"dialog"; + 6.2.10. approve (when present) must be a str and one of "auto"/"plan"/"dialog"; otherwise raise "non-str value in workflow.extend.NAME.approve" (non-str) or "approve must be one of: auto, plan, dialog in workflow.extend.NAME" (str outside the set) - 6.2.10. If neither before nor after is present — raise a structural error + 6.2.11. If neither before nor after is present — raise a structural error "extend entry NAME requires at least one of before/after" - 6.2.11. Other keys of the entry value are NOT validated (open-ended: + 6.2.12. Other keys of the entry value are NOT validated (open-ended: title, prompt, skills, roles, communication, trigger — a full stage-body field — and any other stage field) and pass through verbatim - 6.2.12. Build a `WorkflowExtendStage` from the validated before/after, + 6.2.13. Build a `WorkflowExtendStage` from the validated before/after, agent/loop/approve, and the REMAINING entry value (excluding before, after, agent, loop, approve, and depends_on) as body — agent/loop/ approve are extracted into the model, not carried in body, so they @@ -381,7 +410,7 @@ Annotations: | before/after names pass through; the compiler decides whether to apply or ignore (silently with a warning) - Per-stage unknown keys are a structural error — only agent, prompt, - loop, skills, skip, approve, manual are accepted + loop, skills, skip, approve, manual, notes are accepted - skip (when present) must be a bool; a non-bool value is a structural error - skip is forbidden in an extend-entry — a structural error (skip is @@ -394,6 +423,11 @@ Annotations: | "auto"/"plan"/"dialog"; any other value or a non-str is a structural error - manual is accepted ONLY in the stages block; a non-bool manual is a structural error; manual in an extend-entry is a structural error + - notes (stages-block only) must be a dict of str→str; a non-dict value + raises "non-mapping notes in workflow.stages.NAME"; a non-str value + raises "non-str value in workflow.stages.NAME.notes.KEY"; an empty notes + map is treated as absence (notes=None); notes in an extend-entry is a + structural error - trigger in the stages block is an unknown-key structural error; trigger in an extend-entry body passes through verbatim (validated at compile time, not here) @@ -439,6 +473,8 @@ Annotations: | the interactive-suppress / auto_approve logic - Do not act on manual here — it is declarative; the compiler applies the force / cancel logic + - Do not emit or act on the buttons here — notes is declarative; the + compiler emits the per-stage buttons field - Do not let an inline extend approve reach the extend-stage body — it is extracted into the model (like agent/loop) - Do not skip structural validation on missing files — OSError From 4e2bb226676031c7d90babf6ef902298334af6b3 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 02:23:58 +0000 Subject: [PATCH 067/229] feat: add notes field to WorkflowStage model --- goga/pipeline/workflow/workflow_stage.py | 38 ++++++++++++------- .../workflow/test_workflow_stage_contract.py | 16 +++++++- .../workflow/test_workflow_stage_logic.py | 17 ++++++++- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/goga/pipeline/workflow/workflow_stage.py b/goga/pipeline/workflow/workflow_stage.py index 0ec5421e..90ae5117 100644 --- a/goga/pipeline/workflow/workflow_stage.py +++ b/goga/pipeline/workflow/workflow_stage.py @@ -20,15 +20,18 @@ ``manual`` is an optional manual-launch instruction — strictly a bool, with ``None`` (the default, key absent), ``True`` (force), and ``False`` (explicit cancel) as three DIFFERENT states: an absent key and an explicit -``manual: false`` are distinct instructions (the compiler resolves them). -No validation lives here either: ``parse_workflow`` enforces every invariant -(key set, field types, ``loop >= 1``, ``skip`` is a bool, ``approve`` is one -of ``"auto"``/``"plan"``/``"dialog"``, ``manual`` is a bool) and raises a -structural error before this dataclass is built. +``manual: false`` are distinct instructions (the compiler resolves them); +``notes`` is an optional map of note name → prompt text (a declarative +note-buttons instruction — the compiler emits the stage's ``buttons`` field +from it). No validation lives here either: ``parse_workflow`` enforces every +invariant (key set, field types, ``loop >= 1``, ``skip`` is a bool, +``approve`` is one of ``"auto"``/``"plan"``/``"dialog"``, ``manual`` is a +bool, ``notes`` is a str→str map) and raises a structural error before this +dataclass is built. Field order is fixed — ``agent``, ``prompt``, ``loop``, ``skills``, ``skip``, -``approve``, ``manual`` — to match the canonical order of the per-stage keys -in the workflow-file. +``approve``, ``manual``, ``notes`` — to match the canonical order of the +per-stage keys in the workflow-file. """ from __future__ import annotations @@ -40,12 +43,13 @@ class WorkflowStage: """A single per-stage override instruction from a workflow-file. - The six fields ``agent``, ``prompt``, ``loop``, ``skills``, ``approve``, - and ``manual`` default to ``None`` — a workflow-file may omit any of them, - and ``parse_workflow`` produces ``None`` for missing fields; ``skip`` - defaults to ``False``. Field order is fixed (``agent``, ``prompt``, - ``loop``, ``skills``, ``skip``, ``approve``, ``manual``) to match the - canonical order of the per-stage keys in the workflow-file. + The seven fields ``agent``, ``prompt``, ``loop``, ``skills``, + ``approve``, ``manual``, and ``notes`` default to ``None`` — a + workflow-file may omit any of them, and ``parse_workflow`` produces + ``None`` for missing fields; ``skip`` defaults to ``False``. Field order + is fixed (``agent``, ``prompt``, ``loop``, ``skills``, ``skip``, + ``approve``, ``manual``, ``notes``) to match the canonical order of the + per-stage keys in the workflow-file. Args: agent: Agent name consumed by the compiler to compose the per-stage @@ -92,6 +96,13 @@ class WorkflowStage: distinguishable to the compiler. This cell does not act on ``manual`` — it is declarative; the compiler applies the force / cancel logic. + notes: Optional note-buttons instruction (a map of note name → + prompt text), or ``None`` when not specified. Declarative — + extracted here, consumed by the compiler to emit the stage's + ``buttons`` field. An empty map equals absence (``parse_workflow`` + normalizes it to ``None``), so this field carries either ``None`` + or a non-empty map. This cell does not act on ``notes`` — it is + declarative. """ agent: str | None = None @@ -101,3 +112,4 @@ class WorkflowStage: skip: bool = False approve: str | None = None manual: bool | None = None + notes: dict[str, str] | None = None diff --git a/tests/pipeline/workflow/test_workflow_stage_contract.py b/tests/pipeline/workflow/test_workflow_stage_contract.py index 86211b78..2a7a5351 100644 --- a/tests/pipeline/workflow/test_workflow_stage_contract.py +++ b/tests/pipeline/workflow/test_workflow_stage_contract.py @@ -68,6 +68,18 @@ def test_workflow_stage_has_manual_property(self) -> None: assert WorkflowStage(manual=True).manual is True assert WorkflowStage(manual=False).manual is False + def test_workflow_stage_has_notes_property(self) -> None: + """WorkflowStage exposes a ``notes`` property defaulting to None (NOT {}). + + The ``None`` default pins the "None | non-empty map" model invariant — + ``parse_workflow`` normalizes an empty map to ``None`` upstream, and a + mutable ``{}`` default would silently diverge from the ``is not None`` + check the compiler relies on. + """ + assert hasattr(WorkflowStage(), "notes") + assert WorkflowStage().notes is None + assert WorkflowStage(notes={"fix": "F"}).notes == {"fix": "F"} + def test_workflow_stage_defaults_all_none(self) -> None: """Every field defaults to None when constructed with no arguments.""" stage = WorkflowStage() @@ -79,7 +91,7 @@ def test_workflow_stage_defaults_all_none(self) -> None: assert stage.approve is None def test_workflow_stage_constructible_kw_only(self) -> None: - """WorkflowStage accepts all seven fields as keyword-only arguments.""" + """WorkflowStage accepts all eight fields as keyword-only arguments.""" stage = WorkflowStage( agent="codex", prompt="text", @@ -88,6 +100,7 @@ def test_workflow_stage_constructible_kw_only(self) -> None: skip=True, approve="auto", manual=True, + notes={"fix": "Fix and continue"}, ) assert stage.agent == "codex" @@ -97,3 +110,4 @@ def test_workflow_stage_constructible_kw_only(self) -> None: assert stage.skip is True assert stage.approve == "auto" assert stage.manual is True + assert stage.notes == {"fix": "Fix and continue"} diff --git a/tests/pipeline/workflow/test_workflow_stage_logic.py b/tests/pipeline/workflow/test_workflow_stage_logic.py index 43a3f0ae..75791848 100644 --- a/tests/pipeline/workflow/test_workflow_stage_logic.py +++ b/tests/pipeline/workflow/test_workflow_stage_logic.py @@ -114,10 +114,10 @@ def test_workflow_stage_skip_defaults_false(self) -> None: assert WorkflowStage(skip=True).skip is True def test_field_order_fixed_canonical(self) -> None: - """Field order is fixed: agent, prompt, loop, skills, skip, approve, manual.""" + """Field order is fixed: agent, prompt, loop, skills, skip, approve, manual, notes.""" names = [field.name for field in fields(WorkflowStage)] - assert names == ["agent", "prompt", "loop", "skills", "skip", "approve", "manual"] + assert names == ["agent", "prompt", "loop", "skills", "skip", "approve", "manual", "notes"] def test_workflow_stage_approve_defaults_none(self) -> None: """Omitting ``approve`` yields None — no auto-approval directive.""" @@ -158,6 +158,19 @@ def test_workflow_stage_manual_defaults_none_not_false(self) -> None: assert WorkflowStage(manual=True).manual is True assert WorkflowStage(manual=False).manual is False + def test_workflow_stage_notes_defaults_none(self) -> None: + """Omitting ``notes`` yields None — no note-buttons instruction.""" + assert WorkflowStage().notes is None + assert WorkflowStage(agent="codex").notes is None + + def test_workflow_stage_notes_stored_verbatim(self) -> None: + """notes stores the keyword-passed map verbatim (same object, mirroring skills).""" + notes = {"fix": "F"} + stage = WorkflowStage(notes=notes) + + assert stage.notes == {"fix": "F"} + assert stage.notes is notes + def test_skip_accepts_true_and_false(self) -> None: """skip=True and skip=False round-trip verbatim.""" assert WorkflowStage(skip=True).skip is True From cf8b731d7e6191d8427f60c5ecf5206ceeb242d2 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 02:31:43 +0000 Subject: [PATCH 068/229] feat: parse_workflow validates notes instruction and rejects it in extend entries --- goga/pipeline/workflow/parse_workflow.py | 183 +++++++++++------- .../workflow/test_parse_workflow_contract.py | 22 ++- .../workflow/test_parse_workflow_logic.py | 118 ++++++++++- 3 files changed, 247 insertions(+), 76 deletions(-) diff --git a/goga/pipeline/workflow/parse_workflow.py b/goga/pipeline/workflow/parse_workflow.py index bc70e2e7..70361e31 100644 --- a/goga/pipeline/workflow/parse_workflow.py +++ b/goga/pipeline/workflow/parse_workflow.py @@ -14,10 +14,10 @@ A workflow-file is structurally malformed when its YAML is invalid, its root is not a mapping, it carries an unknown top-level or per-stage key, a field has the wrong type (including a non-bool ``manual``), an extend-entry forbids -``depends_on`` / ``skip`` / ``manual`` / mistypes ``before`` / ``after`` / omits -both / mistypes an inline ``agent`` / ``loop`` / ``approve``, a ``loop`` is -below one, or it provides neither a top-level prompt, any stage entry, nor any -extend entry. Each of those raises ``WorkflowSyntaxError`` (a +``depends_on`` / ``skip`` / ``manual`` / ``notes`` / mistypes ``before`` / +``after`` / omits both / mistypes an inline ``agent`` / ``loop`` / ``approve``, +a ``loop`` is below one, or it provides neither a top-level prompt, any stage +entry, nor any extend entry. Each of those raises ``WorkflowSyntaxError`` (a ``ValueError`` subclass, mirroring the compiler cell's ``StructuralError``) with an authored-time message. A missing or unreadable file lets the underlying ``OSError`` propagate unchanged — consistent with the compiler behavior. @@ -30,6 +30,12 @@ compiler validates its value), never via a workflow instruction. This cell does not act on ``manual`` — it is declarative; the compiler applies the force / cancel logic. + +``notes`` is accepted ONLY in the ``stages`` block too (a map of note name → +prompt text, str→str; an empty map equals absence and builds ``None``), and is +likewise forbidden in an extend-entry — the compiler consumes it per stage name +to emit the flow-file buttons. This cell does not act on ``notes`` — it is +declarative; the runtime meaning of the buttons belongs to afm. """ from __future__ import annotations @@ -48,13 +54,13 @@ # Fixed keys of a per-stage entry, in canonical order. Used both for unknown-key # rejection and for documenting the accepted per-stage field set. -_STAGE_KEYS = ("agent", "prompt", "loop", "skills", "skip", "approve", "manual") +_STAGE_KEYS = ("agent", "prompt", "loop", "skills", "skip", "approve", "manual", "notes") # Keys extracted out of an extend-entry's body before construction: the # positioning keys (``before``/``after``) and the inline default overrides # (``agent``/``loop``/``approve``). Every other key passes through verbatim as -# the stage body (``depends_on``, ``skip`` and ``manual`` never reach the body — -# they are rejected outright). +# the stage body (``depends_on``, ``skip``, ``manual`` and ``notes`` never +# reach the body — they are rejected outright). _EXTEND_BODY_EXCLUDED = ("before", "after", "agent", "loop", "approve") # Accepted values for the ``approve`` directive (per-stage AND inline extend), @@ -74,8 +80,10 @@ class WorkflowSyntaxError(ValueError): A structural error is an authored-time defect in the workflow-file: invalid YAML, a non-mapping root, an unknown top-level or per-stage key, a - wrong-typed field (including a non-bool ``manual``), an extend-entry that - forbids ``depends_on`` / ``skip`` / ``manual`` / mistypes ``before`` / + wrong-typed field (including a non-bool ``manual`` or a malformed + ``notes``), an extend-entry that + forbids ``depends_on`` / ``skip`` / ``manual`` / ``notes`` / mistypes + ``before`` / ``after`` / an inline ``agent`` / an inline ``loop`` / an inline ``approve`` / omits both ``before`` and ``after``, a ``loop`` below one, or a workflow that provides neither a top-level prompt, any stage @@ -91,11 +99,13 @@ def parse_workflow(workflow_path: Path) -> WorkflowDocument: Read the file at ``workflow_path``, parse it as YAML, validate the expected top-level keys (``prompt``, ``stages``, ``extend``) and the per-stage key set (``agent``, ``prompt``, ``loop``, ``skills``, ``skip``, ``approve``, - ``manual``), + ``manual``, ``notes``), type-check each present field (``manual`` strictly a bool; an absent key - builds ``None``, NOT ``False``), validate each extend-entry's positioning + builds ``None``, NOT ``False``; ``notes`` a map of note name → prompt text + whose empty form builds ``None``), validate each extend-entry's positioning (``before``/``after`` as ``list[str]``, ``depends_on`` forbidden, ``skip`` - forbidden, ``manual`` forbidden, at least one of ``before``/``after`` + forbidden, ``manual`` forbidden, ``notes`` forbidden, at least one of + ``before``/``after`` required) and any inline ``agent`` (str) / ``loop`` (int >= 1) / ``approve`` (one of ``auto``/ ``plan``/``dialog``), enforce @@ -106,7 +116,8 @@ def parse_workflow(workflow_path: Path) -> WorkflowDocument: ``depends_on`` rewriting, no stage removal. A ``trigger`` key in the ``stages`` block is an unknown-key structural error; a ``trigger`` key in an extend-entry body passes through verbatim (the compiler validates its - value). This cell does not act on ``manual`` — it is declarative. + value). This cell does not act on ``manual`` or ``notes`` — they are + declarative. Args: workflow_path: Absolute path to the workflow-file. @@ -120,10 +131,12 @@ def parse_workflow(workflow_path: Path) -> WorkflowDocument: (propagated unchanged). WorkflowSyntaxError: If the file is invalid YAML, the root is not a mapping, an unknown top-level or per-stage key is present, a field - has the wrong type (including a non-bool ``skip`` or a non-bool - ``manual``), an extend-entry + has the wrong type (including a non-bool ``skip``, a non-bool + ``manual``, or a ``notes`` that is non-mapping or carries a + non-str value), an extend-entry is malformed (non-mapping value, ``depends_on`` present, ``skip`` - present, ``manual`` present, ``before``/``after`` not a + present, ``manual`` present, ``notes`` present, ``before``/``after`` + not a ``list[str]``, an inline ``agent`` not a str or ``loop`` not an ``int >= 1`` or ``approve`` not one of ``auto``/``plan``/``dialog``, neither ``before`` nor @@ -230,7 +243,7 @@ def _build_stage(name: Any, value: Any) -> WorkflowStage: The entry value must be a mapping. Its key set is validated against ``agent``, ``prompt``, ``loop``, ``skills``, ``skip``, ``approve``, - ``manual`` (unknown + ``manual``, ``notes`` (unknown key → structural error); each present field is then type-checked, ``loop`` must be an ``int >= 1``, ``skills`` must be a ``list[str]``, ``skip`` must be a ``bool``, ``approve`` must be one of ``auto``/``plan``/``dialog``, and @@ -239,6 +252,8 @@ def _build_stage(name: Any, value: Any) -> WorkflowStage: default — since absence is equivalent to ``False``; ``manual`` stays ``None`` — NOT ``False`` — since an absent key and an explicit ``manual: false`` are DIFFERENT instructions the compiler must tell apart). + ``notes`` must be a ``dict`` of ``str``→``str``; an EMPTY map is normalized + to ``None`` — the model carries either ``None`` or a non-empty map. Args: name: The stage-name map key (used in error messages). @@ -252,45 +267,30 @@ def _build_stage(name: Any, value: Any) -> WorkflowStage: per-stage key is present, ``agent``/``prompt`` is not a str, ``loop`` is not an ``int >= 1``, ``skills`` is not a ``list[str]``, ``skip`` is not a ``bool``, ``approve`` is not - one of ``auto``/``plan``/``dialog``, or ``manual`` is not a - ``bool``. + one of ``auto``/``plan``/``dialog``, ``manual`` is not a + ``bool``, or ``notes`` is not a ``dict`` of ``str``→``str``. """ if not isinstance(value, dict): raise WorkflowSyntaxError(f"non-mapping stage {name} in workflow.stages") - agent: str | None = None - prompt: str | None = None - loop: int | None = None - skills: list[str] | None = None - skip: bool = False - approve: str | None = None - manual: bool | None = None + # ``_validate_stage_field`` dispatches per key and rejects unknown keys, so + # only the valid stage keys land here — the map is then unpacked onto the + # constructor with per-field defaults for the absent ones (``skip`` stays + # ``False``, everything else ``None``). + fields: dict[str, Any] = {} for key, field_value in value.items(): - validated = _validate_stage_field(name, key, field_value) - if key == "agent": - agent = validated - elif key == "prompt": - prompt = validated - elif key == "loop": - loop = validated - elif key == "skills": - skills = validated - elif key == "skip": - skip = validated - elif key == "approve": - approve = validated - elif key == "manual": - manual = validated + fields[key] = _validate_stage_field(name, key, field_value) return WorkflowStage( - agent=agent, - prompt=prompt, - loop=loop, - skills=skills, - skip=skip, - approve=approve, - manual=manual, + agent=fields.get("agent"), + prompt=fields.get("prompt"), + loop=fields.get("loop"), + skills=fields.get("skills"), + skip=fields.get("skip", False), + approve=fields.get("approve"), + manual=fields.get("manual"), + notes=fields.get("notes"), ) @@ -300,13 +300,15 @@ def _validate_stage_field(name: Any, key: Any, field_value: Any) -> Any: Dispatches by ``key`` over the ``_STAGE_KEYS`` set, enforcing each field's type (``agent``/``prompt`` str, ``loop`` int >= 1, ``skills`` list[str], ``skip`` bool, ``approve`` one of ``auto``/``plan``/``dialog``, ``manual`` - bool). An unknown key raises + bool, ``notes`` a str→str map). An unknown key raises the unknown-key structural error with the full valid-set fragment (``_STAGE_KEYS`` is the single source of that fragment — ``trigger`` is a full stage-body field, NOT a workflow key, so it lands here as an unknown key). Returns the validated value unchanged (only ``loop`` is normalized via - ``_validate_loop``, which already returns an ``int``). + ``_validate_loop``, which already returns an ``int``; ``notes`` is + normalized via ``_validate_notes``, which returns ``None`` for an empty + map). Args: name: The stage-name map key (used in error messages). @@ -316,14 +318,16 @@ def _validate_stage_field(name: Any, key: Any, field_value: Any) -> Any: Returns: The validated field value (``agent``/``prompt`` str, ``loop`` int, ``skills`` list[str], ``skip`` bool, ``approve`` str equal to one of - ``auto``/``plan``/``dialog``, or ``manual`` bool). + ``auto``/``plan``/``dialog``, ``manual`` bool, or ``notes`` a non-empty + ``dict[str, str]`` — ``None`` when the map is empty). Raises: WorkflowSyntaxError: If ``key`` is an unknown per-stage key, or the field value has the wrong type (non-str agent/prompt, non-int/<1 loop, non-list[str] skills, non-bool skip, an ``approve`` that - is not a str equal to ``auto``/``plan``/``dialog``, or a non-bool - ``manual``). + is not a str equal to ``auto``/``plan``/``dialog``, a non-bool + ``manual``, or ``notes`` that is non-mapping or carries a non-str + value). """ if key in ("agent", "prompt"): return _validate_str_field(f"workflow.stages.{name}", key, field_value) @@ -343,6 +347,8 @@ def _validate_stage_field(name: Any, key: Any, field_value: Any) -> Any: return field_value elif key == "approve": return _validate_approve(f"workflow.stages.{name}", field_value) + elif key == "notes": + return _validate_notes(f"workflow.stages.{name}", field_value) else: raise WorkflowSyntaxError(f"unknown key in workflow.stages.{name}: {key}; valid keys: {', '.join(_STAGE_KEYS)}") @@ -377,7 +383,9 @@ def _build_extend_stage(name: Any, value: Any) -> WorkflowExtendStage: ``manual`` is forbidden too (the launch mode of a NEW stage is authored in its body via ``trigger`` — a full stage-body field that passes through verbatim and is validated by the compiler — never via a workflow - instruction); + instruction); ``notes`` is forbidden likewise (a declarative note-buttons + instruction is stages-block only — the compiler consumes it per stage + name); ``before`` and ``after`` (when present) must each be a ``list[str]``; an inline ``agent`` (when present) must be a ``str``; an inline ``loop`` (when present) must be an ``int >= 1`` (``bool`` rejected first, symmetric with @@ -387,11 +395,12 @@ def _build_extend_stage(name: Any, value: Any) -> WorkflowExtendStage: at least one of ``before``/``after`` must be present. Every other key passes through verbatim as the stage body. ``before``, ``after``, ``agent``, ``loop`` and ``approve`` are removed from the body before construction - (``depends_on``, ``skip`` and ``manual`` never reach it: they are rejected - outright). + (``depends_on``, ``skip``, ``manual`` and ``notes`` never reach it: they are + rejected outright). The structural checks run in the CODEMANIFEST order (step 6.2): - non-mapping → ``depends_on`` → ``skip`` → ``manual`` → ``before`` → + non-mapping → ``depends_on`` → ``skip`` → ``manual`` → ``notes`` → + ``before`` → ``after`` → ``agent`` → ``loop`` → ``approve`` → at-least-one-of-before/after. The at-least-one check runs LAST so an entry carrying BOTH a positioning defect @@ -409,7 +418,7 @@ def _build_extend_stage(name: Any, value: Any) -> WorkflowExtendStage: Raises: WorkflowSyntaxError: If the entry value is not a mapping, it contains a ``depends_on`` key, it contains a ``skip`` key, it contains a - ``manual`` key, ``before`` is not a + ``manual`` key, it contains a ``notes`` key, ``before`` is not a ``list[str]``, ``after`` is not a ``list[str]``, an inline ``agent`` is not a ``str``, an inline ``loop`` is not an ``int >= 1``, an inline ``approve`` is not a str equal to one of @@ -460,25 +469,28 @@ def _build_extend_stage(name: Any, value: Any) -> WorkflowExtendStage: def _reject_forbidden_extend_keys(name: Any, value: dict[str, Any]) -> None: - """Reject the keys an extend-entry must never carry (contract 6.2.2 - 6.2.4). + """Reject the keys an extend-entry must never carry (contract 6.2.2 - 6.2.5). - Three keys are forbidden outright, each with its own message, checked in the + Four keys are forbidden outright, each with its own message, checked in the CODEMANIFEST order before any positioning/type validation: ``depends_on`` (positioning is declared via ``before``/``after`` instead), ``skip`` (a new stage has no existing stage to delete — skip is defined only for existing - pipeline stages via the ``stages`` block), and ``manual`` (the launch mode + pipeline stages via the ``stages`` block), ``manual`` (the launch mode of a new stage is authored in its body via ``trigger``, never via a - workflow instruction). All three never reach the extend body. + workflow instruction), and ``notes`` (a declarative note-buttons + instruction is stages-block only — it mirrors ``manual``: the compiler + consumes it per stage name, and an extend-stage receives it through the + ``stages`` block). All four never reach the extend body. Args: name: The stage-name map key (used in error messages). value: The raw entry value for this extend stage. Raises: - WorkflowSyntaxError: If the entry carries ``depends_on``, ``skip``, or - ``manual`` (checked in that order). + WorkflowSyntaxError: If the entry carries ``depends_on``, ``skip``, + ``manual``, or ``notes`` (checked in that order). """ - for forbidden_key in ("depends_on", "skip", "manual"): + for forbidden_key in ("depends_on", "skip", "manual", "notes"): if forbidden_key in value: raise WorkflowSyntaxError(f"{forbidden_key} is forbidden in workflow.extend.{name}") @@ -588,3 +600,44 @@ def _validate_approve(scope: str, field_value: Any) -> str: raise WorkflowSyntaxError(f"approve must be one of: {', '.join(_APPROVE_DIRECTIVES)} in {scope}") return field_value + + +def _validate_notes(scope: str, field_value: Any) -> dict[str, str] | None: + """Validate a ``notes`` field value and return it (or ``None`` when empty). + + ``notes`` is a declarative note-buttons instruction: a map of note name → + prompt text. A non-``dict`` value is rejected first — an explicit + ``notes: null`` included, since presence of the key forces the type check + (the presence gating mirrors ``skip``/``manual``) — then every non-``str`` + value is rejected with the note key interpolated verbatim into the location. + Map KEYS are deliberately not validated (the open stance mirrors the afm + agent namespace — afm owns the runtime note grammar), so a non-str key + flows through and lands verbatim in the error-message fragment when a + value fails. An EMPTY map equals absence and returns ``None`` — the + compiler's ``is not None`` check must never see an empty instruction. + ``scope`` is the dotted location up to but excluding ``notes`` (e.g. + ``"workflow.stages.deploy"``), used verbatim in both messages. + + Args: + scope: The dotted location (without the trailing ``.notes``). + field_value: The raw ``notes`` value to validate. + + Returns: + The validated notes map, or ``None`` when the map is empty (absence). + + Raises: + WorkflowSyntaxError: If ``field_value`` is not a ``dict``, or any of + its values is not a ``str``. + """ + if not isinstance(field_value, dict): + raise WorkflowSyntaxError(f"non-mapping notes in {scope}") + + for key, text in field_value.items(): + if not isinstance(text, str): + raise WorkflowSyntaxError(f"non-str value in {scope}.notes.{key}") + + if not field_value: + # An empty map equals absence. + return None + + return field_value diff --git a/tests/pipeline/workflow/test_parse_workflow_contract.py b/tests/pipeline/workflow/test_parse_workflow_contract.py index 52b7932e..b422f746 100644 --- a/tests/pipeline/workflow/test_parse_workflow_contract.py +++ b/tests/pipeline/workflow/test_parse_workflow_contract.py @@ -144,14 +144,24 @@ def test_parse_workflow_stage_keys_includes_approve(self) -> None: Pins the contract: ``_STAGE_KEYS`` is the single source of the accepted per-stage key set and of the unknown-key ``valid keys`` message fragment, - so it must carry ``approve`` (after ``skip``) and ``manual`` (after - ``approve``). + so it must carry ``approve`` (after ``skip``), ``manual`` (after + ``approve``), and ``notes`` (after ``manual``). """ from goga.pipeline.workflow.parse_workflow import _STAGE_KEYS assert "approve" in _STAGE_KEYS - # Fixed canonical order: agent, prompt, loop, skills, skip, approve, manual. - assert _STAGE_KEYS == ("agent", "prompt", "loop", "skills", "skip", "approve", "manual") + # Fixed canonical order: agent, prompt, loop, skills, skip, approve, + # manual, notes. + assert _STAGE_KEYS == ( + "agent", + "prompt", + "loop", + "skills", + "skip", + "approve", + "manual", + "notes", + ) def test_parse_workflow_manual_is_accepted_stage_key(self, tmp_path: Path) -> None: """``manual`` is part of the accepted per-stage key set (contract surface). @@ -175,7 +185,7 @@ def test_parse_workflow_unknown_stage_key_message_lists_approve(self, tmp_path: Pins the contract: the ``valid keys`` fragment of the unknown-key message is generated from ``_STAGE_KEYS`` and therefore includes ``approve`` and - the trailing ``manual``. + the trailing ``manual, notes``. """ workflow_path = tmp_path / "workflow.yml" workflow_path.write_text("stages:\n propose:\n bad: value\n") @@ -185,4 +195,4 @@ def test_parse_workflow_unknown_stage_key_message_lists_approve(self, tmp_path: message = str(exc_info.value) assert "unknown key in workflow.stages.propose: bad" in message - assert "valid keys: agent, prompt, loop, skills, skip, approve, manual" in message + assert "valid keys: agent, prompt, loop, skills, skip, approve, manual, notes" in message diff --git a/tests/pipeline/workflow/test_parse_workflow_logic.py b/tests/pipeline/workflow/test_parse_workflow_logic.py index 2ffe5a38..501154c6 100644 --- a/tests/pipeline/workflow/test_parse_workflow_logic.py +++ b/tests/pipeline/workflow/test_parse_workflow_logic.py @@ -282,6 +282,49 @@ def test_parse_workflow_manual_coexists_with_other_fields(self, tmp_path: Path) assert deploy.approve == "auto" assert deploy.loop == 2 + def test_parse_workflow_notes_map_builds_stage_notes(self, tmp_path: Path) -> None: + """A ``notes`` map on a stage builds WorkflowStage.notes verbatim (authoring order). + + Pins the model invariant: ``notes`` is either ``None`` or a NON-EMPTY + map stored verbatim with insertion order preserved — the compiler + assembles the flow-file buttons from exactly this map. + """ + workflow_path = _write( + tmp_path, + "workflow.yml", + "stages:\n" + " deploy:\n" + " notes:\n" + " fix: Fix the failure and continue\n" + " investigate: Gather diagnostics\n", + ) + + document = parse_workflow(workflow_path) + + deploy = document.stages["deploy"] + assert deploy.notes == { + "fix": "Fix the failure and continue", + "investigate": "Gather diagnostics", + } + # Authoring order is preserved (the serializer emits the map as authored). + assert list(deploy.notes) == ["fix", "investigate"] + # notes is independent of the other fields; they stay at their defaults. + assert deploy.agent is None + assert deploy.skip is False + + def test_parse_workflow_empty_notes_map_normalizes_to_none(self, tmp_path: Path) -> None: + """An empty ``notes`` map normalizes to None (``{}`` equals absence). + + The compiler's ``is not None`` check must never see an empty-map + instruction — an empty map would assemble a pointless ``buttons: {}`` + key into the flow-file. + """ + workflow_path = _write(tmp_path, "workflow.yml", "stages:\n deploy:\n notes: {}\n") + + document = parse_workflow(workflow_path) + + assert document.stages["deploy"].notes is None + def test_parse_workflow_extend_populates_document(self, tmp_path: Path) -> None: """A workflow-file with an extend block parses each entry into WorkflowExtendStage.""" workflow_path = _write( @@ -559,10 +602,10 @@ def test_parse_workflow_rejects_unknown_stage_key(self, tmp_path: Path) -> None: message = str(exc_info.value) assert "unknown key in workflow.stages.propose: bad" in message # The full valid-keys list now includes ``skip`` (5th), ``approve`` - # (6th), and ``manual`` (7th); the substring ``agent, prompt, loop, - # skills`` alone would pass even without them, so assert the full - # trailing fragment. - assert "valid keys: agent, prompt, loop, skills, skip, approve, manual" in message + # (6th), ``manual`` (7th), and ``notes`` (8th); the substring + # ``agent, prompt, loop, skills`` alone would pass even without them, + # so assert the full trailing fragment. + assert "valid keys: agent, prompt, loop, skills, skip, approve, manual, notes" in message def test_parse_workflow_rejects_non_str_agent(self, tmp_path: Path) -> None: """A non-str agent raises WorkflowSyntaxError naming the stage and field.""" @@ -707,6 +750,71 @@ def test_parse_workflow_manual_forbidden_in_extend(self, tmp_path: Path) -> None with pytest.raises(WorkflowSyntaxError, match=r"manual is forbidden in workflow\.extend\.extra"): parse_workflow(workflow_path) + @pytest.mark.parametrize( + "notes_yaml", + ["[1, 2]", "~", "text"], + ids=["list", "explicit-null", "str"], + ) + def test_parse_workflow_non_mapping_notes_raises(self, tmp_path: Path, notes_yaml: str) -> None: + """A non-mapping ``notes`` raises WorkflowSyntaxError('non-mapping notes ...'). + + Presence of the key gates the check (like ``skip``/``manual``), so an + explicit ``notes: null`` is ALSO a non-mapping error — unlike + ``trigger`` whose null would be an unknown-key error. The message is + asserted in full: the shape matches ``non-list-of-str skills in ...`` + (a stage-level location with no trailing field name). + """ + workflow_path = _write( + tmp_path, + "workflow.yml", + f"stages:\n deploy:\n notes: {notes_yaml}\n", + ) + + with pytest.raises(WorkflowSyntaxError) as exc_info: + parse_workflow(workflow_path) + + assert str(exc_info.value) == "non-mapping notes in workflow.stages.deploy" + + def test_parse_workflow_non_str_note_value_raises(self, tmp_path: Path) -> None: + """A non-str note value raises WorkflowSyntaxError naming the note key. + + The KEY-interpolated message shape is unique to notes: no other stage + field validates a map's values, so the location fragment ends in the + offending note name (``...stages.deploy.notes.fix``). + """ + workflow_path = _write(tmp_path, "workflow.yml", "stages:\n deploy:\n notes:\n fix: 5\n") + + with pytest.raises(WorkflowSyntaxError) as exc_info: + parse_workflow(workflow_path) + + assert str(exc_info.value) == "non-str value in workflow.stages.deploy.notes.fix" + + def test_parse_workflow_notes_forbidden_in_extend_entry(self, tmp_path: Path) -> None: + """A ``notes`` key inside an extend entry raises WorkflowSyntaxError naming the entry. + + notes is stages-block only (it mirrors ``manual``): the compiler + consumes it per stage name, so an extend-stage receives its notes + through the ``stages`` block — never inline. The check sits at contract + position 6.2.5, right after the ``manual`` check (6.2.4). + """ + workflow_path = _write( + tmp_path, + "workflow.yml", + "stages:\n" + " deploy:\n" + " agent: codex\n" + "extend:\n" + " extra:\n" + " after: [deploy]\n" + " notes:\n" + " fix: x\n", + ) + + with pytest.raises(WorkflowSyntaxError) as exc_info: + parse_workflow(workflow_path) + + assert str(exc_info.value) == "notes is forbidden in workflow.extend.extra" + def test_parse_workflow_trigger_in_stages_is_unknown_key(self, tmp_path: Path) -> None: """A ``trigger`` key in the stages block is an unknown-key structural error. @@ -731,7 +839,7 @@ def test_parse_workflow_trigger_unknown_key_message_lists_manual(self, tmp_path: message = str(exc_info.value) assert "unknown key in workflow.stages.deploy: trigger" in message - assert "valid keys: agent, prompt, loop, skills, skip, approve, manual" in message + assert "valid keys: agent, prompt, loop, skills, skip, approve, manual, notes" in message def test_parse_workflow_approve_non_auto_rejected(self, tmp_path: Path) -> None: """An ``approve`` value outside the accepted set is rejected (stages entry). From 8fd772fdb34980e885e7fe83f45b2093828e5ef8 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 02:39:57 +0000 Subject: [PATCH 069/229] feat: compile_flow assembles buttons from effective workflow notes --- goga/pipeline/compiler/compile_flow.py | 221 +++++++++-- goga/pipeline/compiler/flow_stage.py | 16 +- tests/pipeline/compiler/test_compile_flow.py | 8 +- .../compiler/test_compile_flow_notes.py | 367 ++++++++++++++++++ tests/pipeline/compiler/test_integration.py | 1 + 5 files changed, 579 insertions(+), 34 deletions(-) create mode 100644 tests/pipeline/compiler/test_compile_flow_notes.py diff --git a/goga/pipeline/compiler/compile_flow.py b/goga/pipeline/compiler/compile_flow.py index fe9e7bb1..de6dfdf2 100644 --- a/goga/pipeline/compiler/compile_flow.py +++ b/goga/pipeline/compiler/compile_flow.py @@ -61,6 +61,22 @@ loop-expanded copy (``NAME-i``) inherits the translated value verbatim. The translation is local to ``FlowStage`` assembly — the ``PipelineDocument.body`` returned to consumers keeps the authored ``timeout`` untouched. + +Symmetrically, the workflow ``notes`` instruction compiles into the per-stage +``buttons`` field: when the workflow stages block carries notes for a stage +(a map of note name → prompt text on ``WorkflowStage``), the compiler +assembles the stage's ``buttons`` field in the canonical slot immediately +after ``description``; the map passes through verbatim — keys and values +unchanged. An authoring ``buttons`` key in a stage body (pipeline-file stage +OR embedded extend-stage) is a structural error ``"buttons key is forbidden +in stage body; use notes in workflow.stages"`` — buttons are authored ONLY +through the workflow notes instruction (a single authoring source; no +collisions). The instruction applies per stage name to embedded extend-stages +as well; loop-expanded copies carry the same buttons; a skipped stage never +reaches the application (skip removal runs first). Interpretation of the +buttons belongs to afm — the compiler only assembles and serializes the +field. Output-side only — the ``PipelineDocument.body`` returned to consumers +stays the faithful mirror of the source pipeline-file. ``auto`` is a sentinel string emitted verbatim (goga does not interpret it; afm resolves the agent). In a body carrying ``script``, the ``agents`` directive is NOT @@ -129,6 +145,11 @@ # ``script_timeout`` (str) sits immediately after ``script_after`` — the # translated form of the authoring ``timeout`` directive — and is likewise # present only when authored (or directly authored under its output name). +# ``buttons`` (map of str→str) sits immediately after ``description`` — the +# compiled form of the workflow ``notes`` instruction (the map verbatim, one +# deep copy per ``FlowStage``); present only when the workflow supplied a +# non-empty notes instruction for the stage, so pipelines without notes +# compile byte-identically. _CANONICAL_KEY_ORDER = [ "interactive", "auto_approve", @@ -136,6 +157,7 @@ "command", "prompt", "description", + "buttons", "agents", "supervisor", "supervisor_prompt", @@ -303,11 +325,14 @@ def _inject_defaults(body: dict[str, Any], suppress_agents: bool = False) -> dic def _reject_authoring_output_keys(body: dict[str, Any]) -> None: """Reject authoring-side stage-body keys that duplicate output-only afm fields. - Three authoring keys are forbidden because each names an afm OUTPUT field + Four authoring keys are forbidden because each names an afm OUTPUT field whose authoring-side counterpart is a different key: ``agents`` (author the ``roles`` field — translated element-wise), ``interactive`` (author the - ``communication`` field — renamed), and ``auto_run`` (author the ``trigger`` - field — ``trigger: manual`` assembles ``auto_run: false``). Checking them in + ``communication`` field — renamed), ``auto_run`` (author the ``trigger`` + field — ``trigger: manual`` assembles ``auto_run: false``), and ``buttons`` + (author the workflow ``notes`` instruction — a different FILE, not a + different body key: buttons live in the workflow-file stages block, never + in a stage body). Checking them in one place, at the very start of ``_canonical_fields``, keeps every prohibition ahead of any translation, exactly as the contract orders it. @@ -316,7 +341,7 @@ def _reject_authoring_output_keys(body: dict[str, Any]) -> None: extend body). Raises: - StructuralError: When ``body`` carries any of the three authoring keys, + StructuralError: When ``body`` carries any of the four authoring keys, with the contract message naming the authoring-side field to use. """ if "agents" in body: @@ -328,6 +353,9 @@ def _reject_authoring_output_keys(body: dict[str, Any]) -> None: if "auto_run" in body: raise StructuralError("auto_run key is forbidden in stage body; use trigger: manual") + if "buttons" in body: + raise StructuralError("buttons key is forbidden in stage body; use notes in workflow.stages") + def _validate_trigger(body: dict[str, Any]) -> str | None: """Return the effective ``trigger`` of ``body``, validating the closed value set. @@ -394,7 +422,37 @@ def _apply_timeout_directive(body: dict[str, Any], stage_name: str, timeout_valu body["script_timeout"] = timeout_value -def _canonical_fields(body: dict[str, Any], stage_name: str) -> dict[str, Any]: +def _assemble_buttons(source: dict[str, Any], notes: dict[str, str] | None) -> None: + """Deep-copy the effective workflow notes into the output ``buttons`` field. + + The single assembly site of the workflow notes instruction: a + non-``None`` notes map is deep-copied verbatim — keys and values + unchanged — into the REBUILT source dict under the output ``buttons`` key + (the canonical-order loop in ``_canonical_fields`` slots it immediately + after ``description``). The notes value travels as a function argument, + never threaded through the stage body (the authoring-buttons prohibition + in ``_reject_authoring_output_keys`` guards that channel), and the deep + copy keeps ``FlowStage.fields["buttons"]`` from aliasing + ``WorkflowStage.notes`` — post-compile mutation of either side cannot + corrupt the other. ``None`` (no instruction, or an empty map normalized + to ``None`` by ``parse_workflow``) assembles no key at all. + + Args: + source: The REBUILT body dict (authoring keys already translated) — + mutated in place by the assignment (the caller's fresh dict, never + the caller's original parsed body). + notes: The effective workflow notes instruction for the stage, or + ``None`` when the workflow carries none. Read-only input. + """ + if notes is not None: + source["buttons"] = copy.deepcopy(notes) + + +def _canonical_fields( + body: dict[str, Any], + stage_name: str, + notes: dict[str, str] | None = None, +) -> dict[str, Any]: """Reorder ``body`` into canonical key order, deep-copying each value. A legacy ``agents`` key in the step body is rejected up front with @@ -406,7 +464,12 @@ def _canonical_fields(body: dict[str, Any], stage_name: str) -> dict[str, Any]: is the output-only afm field. Likewise, an authoring ``auto_run`` key is rejected with ``StructuralError("auto_run key is forbidden in stage body; use trigger: manual")`` — the authoring-side field for the launch mode is - ``trigger``; ``auto_run`` is the output-only afm field. + ``trigger``; ``auto_run`` is the output-only afm field. Likewise, an + authoring ``buttons`` key is rejected with + ``StructuralError("buttons key is forbidden in stage body; use notes in + workflow.stages")`` — buttons are authored ONLY through the workflow + ``notes`` instruction (a different FILE, not a different body key); + ``buttons`` is the output-only afm field. The ``trigger`` key (when present in a pipeline-file body, an embedded extend body, or a loop-expanded copy) is read and validated: any non-``None`` @@ -464,9 +527,20 @@ def _canonical_fields(body: dict[str, Any], stage_name: str) -> dict[str, Any]: ``interactive``). A body whose effective ``trigger`` is ``manual`` assembles ``auto_run: false`` (canonical slot immediately after ``auto_approve``); ``trigger: on_success`` or no trigger assembles NO ``auto_run`` key — - ``auto_run: true`` is never emitted. Known keys (``interactive``, + ``auto_run: true`` is never emitted. The ``notes`` argument (the effective + workflow notes instruction for this stage, already resolved by base name + so loop-expanded copies share it) is assembled into the output ``buttons`` + field whenever it is not ``None``: the map is deep-copied verbatim — keys + and values unchanged — into the canonical slot immediately after + ``description``. The notes value travels as a function argument ONLY; it + is never threaded into ``body`` under any key (a body ``buttons`` key + trips the authoring-buttons prohibition above), keeping the + single-authoring-source rule intact. A ``None`` notes (no instruction, or + an empty map normalized to ``None`` upstream) assembles no ``buttons`` key + at all. Known keys (``interactive``, ``auto_approve``, ``auto_run``, ``command``, - ``prompt``, ``description``, ``agents``, ``supervisor``, ``supervisor_prompt``, + ``prompt``, ``description``, ``buttons``, ``agents``, ``supervisor``, + ``supervisor_prompt``, ``skills``, ``script_before``, ``script``, ``script_after``, ``script_timeout``) are emitted in that fixed order; any remaining keys are appended alphabetically. The @@ -488,6 +562,11 @@ def _canonical_fields(body: dict[str, Any], stage_name: str) -> dict[str, Any]: reconstructed deep copy carrying the ``_APPROVE_SENTINEL``). stage_name: The stage id (used in the mutual-exclusion error message — for loop-expanded copies this is ``NAME-i``). + notes: The effective workflow notes instruction for this stage (a map + of note name → prompt text, already resolved by base name so + loop-expanded copies share it), or ``None`` when the workflow + carries none. Read-only input — deep-copied into the assembled + ``buttons`` field, never threaded into ``body``. Returns: A new dict in canonical key order with deep-copied values. @@ -499,7 +578,10 @@ def _canonical_fields(body: dict[str, Any], stage_name: str) -> dict[str, Any]: field is ``communication``; ``interactive`` is output-only. Or if ``body`` carries an authoring ``auto_run`` key — the authoring-side field for the launch mode is ``trigger``; ``auto_run`` is - output-only. Or if ``body`` carries a ``trigger`` value outside the + output-only. Or if ``body`` carries an authoring ``buttons`` key — + buttons are authored ONLY through the workflow ``notes`` + instruction; ``buttons`` is output-only. Or if ``body`` carries a + ``trigger`` value outside the closed set ``on_success``/``manual`` (a ``None`` value counts as absent). Or if ``body`` carries ``script`` together with ``prompt`` and/or ``skills`` @@ -580,6 +662,15 @@ def _canonical_fields(body: dict[str, Any], stage_name: str) -> dict[str, Any]: # ``script`` is legal; the agents slot is simply not emitted). source = _inject_defaults(body, suppress_agents="script" in body) + # Workflow notes instruction → the output ``buttons`` field (single + # authoring source). The notes map arrives as a function argument — never + # threaded through the body (the authoring-buttons prohibition above would + # trip on a body ``buttons`` key) — and is deep-copied into the assembled + # fields by ``_assemble_buttons`` (see its docstring for the no-aliasing + # rationale). The ``notes`` argument itself is read-only input; this + # function stays non-mutating. + _assemble_buttons(source, notes) + # An approve directive that drives the roles effect (``auto``/``dialog``) + # ``planner`` in the raw roles ⇒ emit ``auto_approve: true`` (canonical slot # right after ``interactive``). The two approve effects are independent: @@ -621,21 +712,29 @@ def _effective_overrides(workflow: WorkflowDocument) -> dict[str, WorkflowStage] ``manual`` (``parse_workflow`` rejects it in an extend-entry), so the merged branch passes ``manual=stg.manual`` EXPLICITLY — the ``WorkflowStage`` constructor defaults it to ``None``, and an overlay that omitted it would - silently drop the instruction. + silently drop the instruction. The note-buttons instruction is + stages-block-only the same way: the extend seed carries ``notes=None`` + (the constructor default — ``parse_workflow`` rejects ``notes`` in an + extend-entry), and the merged branch passes ``notes=stg.notes`` + explicitly, mirroring ``manual``. Args: workflow: The declarative workflow instructions. Returns: The effective per-stage override map keyed by stage name. Extend-seeded - entries carry only ``agent``/``loop``/``approve`` (``manual`` stays - ``None``); stages-block entries carry their full ``WorkflowStage``; - merged entries combine them per-field, always carrying the stages-block - ``manual``. + entries carry only ``agent``/``loop``/``approve`` (``manual`` and + ``notes`` stay ``None``); stages-block entries carry their full + ``WorkflowStage``; merged entries combine them per-field, always + carrying the stages-block ``manual`` and ``notes``. """ effective: dict[str, WorkflowStage] = {} for name, ext in workflow.extend.items(): + # Extend-seeded default: only the inline fields an extend-entry can + # carry. ``manual``/``notes`` stay ``None`` (the constructor defaults) + # — both are stages-block-only (``parse_workflow`` rejects them in an + # extend-entry). effective[name] = WorkflowStage(agent=ext.agent, loop=ext.loop, approve=ext.approve) for name, stg in workflow.stages.items(): @@ -645,8 +744,9 @@ def _effective_overrides(workflow: WorkflowDocument) -> dict[str, WorkflowStage] effective[name] = stg continue # Per-field overlay: stages-block wins whenever its field is not None. - # ``manual`` is passed explicitly — the extend seed carries none, and the - # constructor default (None) would silently drop the instruction. + # ``manual`` and ``notes`` are passed explicitly — the extend seed + # carries neither, and the constructor default (None) would silently + # drop the instruction. effective[name] = WorkflowStage( agent=stg.agent if stg.agent is not None else base.agent, prompt=stg.prompt, @@ -654,11 +754,53 @@ def _effective_overrides(workflow: WorkflowDocument) -> dict[str, WorkflowStage] skills=stg.skills, approve=stg.approve if stg.approve is not None else base.approve, manual=stg.manual, + notes=stg.notes, ) return effective +def _effective_notes_by_id( + effective: dict[str, WorkflowStage], + expanded_ids: dict[str, list[str]], +) -> dict[str, dict[str, str] | None]: + """Resolve the effective notes for every FINAL step id (base name → copies). + + Built from the resolved per-stage override map (``_effective_overrides``) + and the loop-expansion map (``_expand_loops``): each base name's effective + notes value is copied onto EVERY id that base produced. Resolution is by + BASE name on purpose — a loop-expanded copy's id (``NAME-i``) never appears + in ``workflow.stages``, so a naive ``effective.get(step.name)`` lookup on + the final id would return ``None`` for every copy and silently drop the + instruction; routing through ``expanded_ids`` keeps the buttons uniform + across all copies of one stage. + + ``expanded_ids`` covers EVERY final id by construction (``_expand_loops`` + maps a non-expanded base to ``[base]`` itself), so every final step id has + an entry in the result — a missing key is impossible. + + Args: + effective: The resolved per-stage override map (from + ``_effective_overrides``), keyed by stage name. + expanded_ids: The base-name → produced-ids map from ``_expand_loops`` + (every final id appears in exactly one produced-ids list). + + Returns: + The final-id → effective-notes map. A name absent from ``effective`` + (or carrying ``notes=None``) maps to ``None`` — no buttons key. + """ + notes_by_id: dict[str, dict[str, str] | None] = {} + + for base_name, produced_ids in expanded_ids.items(): + stage = effective.get(base_name) + notes = stage.notes if stage is not None else None + + for produced_id in produced_ids: + notes_by_id[produced_id] = notes + + return notes_by_id + + def _merge_skills( pipeline_skills: list[str] | None, workflow_skills: list[str] | None, @@ -1375,7 +1517,7 @@ def _reconstruct_body( fmt: BodyFormat, body: PhasesBody | StagesBody, workflow: WorkflowDocument, -) -> list[PhaseStep | StageStep]: +) -> tuple[list[PhaseStep | StageStep], dict[str, dict[str, str] | None]]: """Apply the workflow reconstruction branch, returning a NEW step sequence. Deep-copies the parsed steps first so the ORIGINAL body (returned later via @@ -1398,9 +1540,18 @@ def _reconstruct_body( before strict validation (so an extend-embedded name is a valid ``workflow.stages`` target) and before the effective map is resolved (so their inline ``agent``/``loop`` seed the default override); skip removal runs - before ``4a`` so a skipped stage's overrides are never applied ("skip wins"); - the empty-body guard runs once on the working copy, format-agnostic, before - any assembly. + before ``4a`` so a skipped stage's overrides are never applied ("skip wins") + — and before the notes companion map is resolved, so a skipped stage's + notes can never leak into a survivor; the empty-body guard runs once on the + working copy, format-agnostic, before any assembly. + + The return also carries the per-final-id effective-notes companion map + (built by ``_effective_notes_by_id`` from the effective override map and + the expanded-ids map). The companion travels as a SEPARATE map — never + threaded into the step bodies — so the notes instruction reaches + ``_canonical_fields`` as a function argument while the working bodies stay + free of any buttons/notes key (the authoring-buttons prohibition would trip + on a body ``buttons`` key). Args: fmt: The body format — PHASES or STAGES. @@ -1408,7 +1559,10 @@ def _reconstruct_body( workflow: The declarative workflow instructions. Returns: - The reconstructed step sequence (PHASES or STAGES steps). + The reconstructed step sequence (PHASES or STAGES steps) and the + final-id → effective-notes map consumed per step by + ``_canonical_fields`` (loop-expanded copies resolve through their base + name, keeping the buttons uniform across copies). Raises: StructuralError: When a ``workflow.extend.<name>.before/.after`` ref @@ -1430,7 +1584,7 @@ def _reconstruct_body( if fmt is BodyFormat.STAGES: _rewrite_external_depends_on(expanded, expanded_ids) - return expanded + return expanded, _effective_notes_by_id(effective, expanded_ids) def compile_flow( @@ -1465,7 +1619,13 @@ def compile_flow( ``trigger: on_success`` or no trigger assembles NO ``auto_run`` key). ``supervisor``/``supervisor_prompt`` are authored-only — never injected, but they pass through the canonical slot when - the source body carries them. The translation/injection is local to + the source body carries them. When the ``workflow`` carries the per-stage + ``notes`` instruction, each stage's ``buttons`` field is assembled from it + (the map verbatim, canonical slot immediately after ``description``, + uniform across every loop-expanded copy and applied to embedded + extend-stages by name); an authoring ``buttons`` key in any stage body is + rejected with ``StructuralError`` — buttons are authored ONLY through the + workflow notes instruction. The translation/injection is local to ``FlowStage.fields`` — the ``PipelineDocument.body`` returned to consumers is never affected. @@ -1552,15 +1712,22 @@ def compile_flow( # The step sequence consumed for FlowStage assembly. When a workflow is # applied, this is a reconstructed (deep-copied + overridden + expanded) - # sequence; the ORIGINAL `body` is preserved for PipelineDocument below. - reconstructed = _reconstruct_body(fmt, body, workflow) if workflow is not None else list(body.steps) + # sequence plus the per-final-id effective-notes companion map (the source + # of each stage's ``buttons`` field — resolved by base name so + # loop-expanded copies share it); the ORIGINAL `body` is preserved for + # PipelineDocument below. Workflow-less compiles carry an empty notes map, + # so every lookup below is ``None`` and no ``buttons`` key is assembled. + if workflow is not None: + reconstructed, notes_by_id = _reconstruct_body(fmt, body, workflow) + else: + reconstructed, notes_by_id = list(body.steps), {} stages: list[FlowStage] = [] if fmt is BodyFormat.PHASES: for i, step in enumerate(reconstructed): depends_on = [reconstructed[i - 1].name] if i > 0 else None - fields = _canonical_fields(step.body, step.name) + fields = _canonical_fields(step.body, step.name, notes=notes_by_id.get(step.name)) stages.append( FlowStage( id=step.name, @@ -1571,7 +1738,7 @@ def compile_flow( ) elif fmt is BodyFormat.STAGES: for step in reconstructed: - fields = _canonical_fields(step.body, step.name) + fields = _canonical_fields(step.body, step.name, notes=notes_by_id.get(step.name)) stages.append( FlowStage( id=step.name, diff --git a/goga/pipeline/compiler/flow_stage.py b/goga/pipeline/compiler/flow_stage.py index 45ed4139..9e4ab61a 100644 --- a/goga/pipeline/compiler/flow_stage.py +++ b/goga/pipeline/compiler/flow_stage.py @@ -10,12 +10,15 @@ an empty list produces ``depends_on: []``. ``fields`` insertion order IS the output order — the serializer iterates it as-is, so the compiler must build it in canonical order: ``interactive``, ``auto_approve``, ``auto_run``, ``command``, -``prompt``, ``description``, ``agents``, ``supervisor``, ``supervisor_prompt``, +``prompt``, ``description``, ``buttons``, ``agents``, ``supervisor``, +``supervisor_prompt``, ``skills``, ``script_before``, ``script``, ``script_after``, ``script_timeout``, then any unknown keys alphabetically. ``auto_run`` (bool) is present only when the stage's effective trigger is ``manual`` — the value is always ``False``; -``auto_run: true`` is never assembled. +``auto_run: true`` is never assembled. ``buttons`` (map of str→str) is present +only when the workflow supplied a non-empty notes instruction for the stage — +the map passes through verbatim. """ from __future__ import annotations @@ -34,13 +37,16 @@ class FlowStage: depends_on: Predecessor step ids, or ``None`` when absent. fields: Extra step fields in canonical key order (``interactive``, ``auto_approve``, ``auto_run``, ``command``, - ``prompt``, ``description``, ``agents``, ``supervisor``, - ``supervisor_prompt``, ``skills``, ``script_before``, ``script``, + ``prompt``, ``description``, ``buttons``, ``agents``, + ``supervisor``, ``supervisor_prompt``, ``skills``, + ``script_before``, ``script``, ``script_after``, ``script_timeout``, then unknown keys alphabetically). ``auto_run`` (bool) is present only when the stage's effective trigger is ``manual`` — the value is always ``False``; ``auto_run: true`` is - never assembled. + never assembled. ``buttons`` (map of str→str) is present only when + the workflow supplied a non-empty notes instruction for the stage + — the map passes through verbatim. """ id: str diff --git a/tests/pipeline/compiler/test_compile_flow.py b/tests/pipeline/compiler/test_compile_flow.py index 62538e00..eb8ce262 100644 --- a/tests/pipeline/compiler/test_compile_flow.py +++ b/tests/pipeline/compiler/test_compile_flow.py @@ -146,14 +146,15 @@ def test_private_helpers_not_on_facade(self) -> None: assert "_CANONICAL_KEY_ORDER" not in facade_all def test_canonical_fields_signature_has_stage_name(self) -> None: - """``_canonical_fields`` takes ``(body, stage_name)`` for the mutual-exclusion message.""" + """``_canonical_fields`` takes ``(body, stage_name, notes)`` — ``notes`` optional, defaults to None.""" import inspect from goga.pipeline.compiler.compile_flow import _canonical_fields parameters = list(inspect.signature(_canonical_fields).parameters) - assert parameters == ["body", "stage_name"] + assert parameters == ["body", "stage_name", "notes"] + assert inspect.signature(_canonical_fields).parameters["notes"].default is None def test_canonical_key_order_includes_approve_and_script_slots(self) -> None: """The extended canonical order slots ``auto_approve`` and the script_* keys.""" @@ -166,6 +167,9 @@ def test_canonical_key_order_includes_approve_and_script_slots(self) -> None: assert _CANONICAL_KEY_ORDER.index("auto_approve") == _CANONICAL_KEY_ORDER.index("interactive") + 1 assert _CANONICAL_KEY_ORDER[-4:] == ["script_before", "script", "script_after", "script_timeout"] assert _CANONICAL_KEY_ORDER.index("skills") < _CANONICAL_KEY_ORDER.index("script_before") + # ``buttons`` — the compiled form of the workflow notes instruction — + # occupies the canonical slot immediately after ``description``. + assert _CANONICAL_KEY_ORDER.index("buttons") == _CANONICAL_KEY_ORDER.index("description") + 1 def test_approve_sentinel_constant_exists(self) -> None: """The ``_APPROVE_SENTINEL`` constant (approve directive plumbing) exists.""" diff --git a/tests/pipeline/compiler/test_compile_flow_notes.py b/tests/pipeline/compiler/test_compile_flow_notes.py new file mode 100644 index 00000000..712c8358 --- /dev/null +++ b/tests/pipeline/compiler/test_compile_flow_notes.py @@ -0,0 +1,367 @@ +"""Contract and logic tests for the ``compile_flow`` workflow notes instruction. + +Covers the notes half of step 5 (``_canonical_fields``) plus the +reconstruction plumbing that feeds it: when the workflow stages block carries +notes for a stage (a map of note name → prompt text on ``WorkflowStage``), +the compiler assembles the stage's ``buttons`` field — + +- in the canonical slot immediately after ``description`` (and before + ``agents``), for BOTH body formats (STAGES and PHASES); +- verbatim — keys and values unchanged, one deep copy per ``FlowStage`` so + the fields never alias ``WorkflowStage.notes``; +- uniformly across every loop-expanded copy (``NAME-1``..``NAME-N`` all carry + the same buttons — resolution by BASE name); +- for embedded extend-stages by name (the stages block is the single + authoring source for extend-stages too); +- never for a stage without a non-empty notes instruction (omitempty — + ``{}`` is normalized to ``None`` upstream, so no ``buttons`` key appears + and notes-free pipelines compile byte-identically); +- never for a skipped stage (skip removal runs before the notes resolution); +- never through a stage-body ``buttons`` key — an authoring buttons key in a + pipeline-file stage OR an embedded extend body raises + ``StructuralError("buttons key is forbidden in stage body; use notes in + workflow.stages")``; +- output-side only — the ``PipelineDocument`` mirror and the workflow's own + notes map stay untouched. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from goga.pipeline.compiler import StructuralError, compile_flow +from goga.pipeline.workflow import ( + WorkflowDocument, + WorkflowExtendStage, + WorkflowStage, + parse_workflow, +) + +_HEADER = "name: Feature\ndescription: Feature implementation\n---\n" + + +def _write(tmp_path: Path, body: str) -> Path: + """Write a STAGES pipeline (or a PHASES list body) to a temp file and return its path.""" + pipeline_path = tmp_path / "pipeline.yml" + pipeline_path.write_text(_HEADER + body) + + return pipeline_path + + +def _compile(tmp_path: Path, pipeline_text: str, workflow_text: str | None = None) -> str: + """Write the pipeline (and optional workflow), compile, return the flow-file text.""" + pipeline_path = tmp_path / "pipeline.yml" + pipeline_path.write_text(pipeline_text) + flow_path = tmp_path / "flow.yml" + + workflow = None + + if workflow_text is not None: + workflow_path = tmp_path / "workflow.yml" + workflow_path.write_text(workflow_text) + workflow = parse_workflow(workflow_path) + + compile_flow(pipeline_path, flow_path, workflow=workflow) + + return flow_path.read_text() + + +class TestNotesButtonsAssembly: + """Step 5 — notes → buttons assembly across body sources and formats.""" + + def test_notes_assemble_buttons_after_description(self, tmp_path: Path) -> None: + """The assembled ``buttons`` occupies the canonical slot right after ``description``. + + The workflow ``prompt: override`` populates the stage's ``description`` + so the byte-level neighbor assertion has a ``description`` key to sit + against; ``agents`` (the next canonical key after ``buttons``) is + injected by default. The canonical slot is the contract's externally + visible guarantee — this pins the exact neighbors. + """ + flow_text = _compile( + tmp_path, + _HEADER + "s:\n title: S\n prompt: do work\n", + "stages:\n" + " s:\n" + " prompt: override\n" + " notes:\n" + " fix: Fix it\n", + ) + + keys = list(yaml.safe_load(flow_text)["stages"][0]) + + assert keys.index("description") + 1 == keys.index("buttons") + assert keys.index("buttons") < keys.index("agents") + assert "buttons:" in flow_text + assert " fix: Fix it" in flow_text + + def test_notes_assemble_buttons_phases_format(self, tmp_path: Path) -> None: + """The PHASES list body assembles buttons identically — the directive is format-agnostic. + + Pins the PHASES call site of ``_canonical_fields`` with the ``notes=`` + argument: a regression dropping ``notes`` only in the PHASES branch + silently loses buttons for phases pipelines while every STAGES test + stays green. + """ + flow_text = _compile( + tmp_path, + _HEADER + "- name: build\n title: Build\n prompt: do work\n", + "stages:\n build:\n notes:\n fix: Fix it\n", + ) + + stage = yaml.safe_load(flow_text)["stages"][0] + + assert stage["buttons"] == {"fix": "Fix it"} + assert "fix: Fix it" in flow_text + assert "depends_on" not in stage + + def test_loop_expanded_copies_carry_same_buttons(self, tmp_path: Path) -> None: + """Every loop-expanded copy ``NAME-i`` carries the same buttons map. + + Resolution is by base name — a copy's final id (``build-i``) never + appears in ``workflow.stages``, so a naive ``effective.get(step.name)`` + lookup would silently drop the buttons for every copy. The in-memory + variant also pins the deep-copy discipline: the three maps are equal + content but independent objects (one deep copy per ``FlowStage``). + """ + flow_text = _compile( + tmp_path, + _HEADER + "build:\n title: Build\n prompt: do work\n", + "stages:\n build:\n loop: 3\n notes:\n retry: Retry now\n", + ) + + stages = yaml.safe_load(flow_text)["stages"] + + assert [(stage["id"], stage.get("buttons")) for stage in stages] == [ + ("build-1", {"retry": "Retry now"}), + ("build-2", {"retry": "Retry now"}), + ("build-3", {"retry": "Retry now"}), + ] + + _pipeline_doc, flow_doc = compile_flow( + _write(tmp_path, "build:\n title: Build\n prompt: do work\n"), + tmp_path / "flow2.yml", + workflow=WorkflowDocument( + stages={"build": WorkflowStage(loop=3, notes={"retry": "Retry now"})}, + ), + ) + buttons = [stage.fields["buttons"] for stage in flow_doc.stages] + + assert buttons[0] == buttons[1] == buttons[2] == {"retry": "Retry now"} + assert buttons[0] is not buttons[1] + assert buttons[1] is not buttons[2] + assert buttons[0] is not buttons[2] + + def test_notes_apply_to_extend_stage_by_name(self, tmp_path: Path) -> None: + """An extend-stage receives its buttons through the stages block by name. + + The single authoring source: ``stages.<new-stage-name>.notes`` — there + is no separate authoring inside an extend-entry (and ``notes`` there + is a parse-time structural error). + """ + flow_text = _compile( + tmp_path, + _HEADER + "a:\n title: A\n prompt: do a\n", + "stages:\n" + " extra:\n" + " notes:\n" + " fix: Fix extra\n" + "extend:\n" + " extra:\n" + " after: [a]\n" + " title: Extra\n" + " prompt: extra work\n", + ) + + stages = yaml.safe_load(flow_text)["stages"] + extra = next(stage for stage in stages if stage["id"] == "extra") + first = next(stage for stage in stages if stage["id"] == "a") + + assert extra["buttons"] == {"fix": "Fix extra"} + assert "buttons" not in first + + +class TestEffectiveNotesResolution: + """Step 4.5 — the effective override map carries the stages-block notes.""" + + def test_effective_overrides_merged_branch_passes_notes(self) -> None: + """The merged overlay branch passes ``notes=stg.notes`` explicitly. + + ``x`` is extend-seeded (inline defaults) AND overridden by a + stages-block entry carrying notes — the merged branch must carry the + notes. ``z`` is extend-only (no stages entry), so its notes can only + be the constructor default ``None`` — pinning that an extend-only + name keeps the seed untouched. Omitting the kwarg is the silent-drop + regression the contract calls out. + """ + from goga.pipeline.compiler.compile_flow import _effective_overrides + + workflow = WorkflowDocument( + stages={"x": WorkflowStage(notes={"a": "1"})}, + extend={ + "x": WorkflowExtendStage(after=["y"], body={}), + "z": WorkflowExtendStage(after=["x"], body={}), + }, + ) + + effective = _effective_overrides(workflow) + + assert effective["x"].notes == {"a": "1"} + assert effective["z"].notes is None + + +class TestNotesProhibitions: + """The single-authoring-source rule — authoring ``buttons`` keys are rejected.""" + + def test_authoring_buttons_in_pipeline_body_rejected(self, tmp_path: Path) -> None: + """An authoring ``buttons`` key in a pipeline-file stage body raises — no workflow needed. + + The prohibition is body-side: it fires with no workflow at all, + proving buttons are authored ONLY via the workflow stages-block notes + instruction. + """ + with pytest.raises(StructuralError) as excinfo: + _compile(tmp_path, _HEADER + "s:\n title: S\n buttons:\n fix: x\n") + + assert str(excinfo.value) == "buttons key is forbidden in stage body; use notes in workflow.stages" + + def test_authoring_buttons_in_extend_body_rejected(self, tmp_path: Path) -> None: + """An authoring ``buttons`` key in an embedded extend body raises the same error. + + Extend bodies flow through the same ``_canonical_fields`` pass, so the + prohibition covers "pipeline-file stage OR embedded extend-stage". + """ + with pytest.raises(StructuralError) as excinfo: + _compile( + tmp_path, + _HEADER + "a:\n title: A\n prompt: do a\n", + "extend:\n" + " extra:\n" + " after: [a]\n" + " title: Extra\n" + " prompt: extra work\n" + " buttons:\n" + " fix: x\n", + ) + + assert str(excinfo.value) == "buttons key is forbidden in stage body; use notes in workflow.stages" + + def test_notes_on_unknown_stage_name_raises_existing_error(self, tmp_path: Path) -> None: + """A notes-bearing entry for an unknown name hits the existing strict name validation. + + A notes instruction must not weaken the pre-existing 4pre check — the + error message is the same as for any other unknown stage name. + """ + with pytest.raises(StructuralError) as excinfo: + _compile( + tmp_path, + _HEADER + "a:\n title: A\n prompt: do a\n", + "stages:\n ghost:\n notes:\n fix: x\n", + ) + + assert str(excinfo.value) == "unknown stage name in workflow.stages: ghost" + + +class TestNotesEdges: + """Omission edges — empty notes, skip precedence, no-mutation, byte-identity.""" + + def test_empty_notes_compile_without_buttons_key(self, tmp_path: Path) -> None: + """The empty-notes workflow (``notes: {}`` → ``None`` upstream) assembles no buttons. + + The omitempty presence rule: ``parse_workflow`` normalizes ``{}`` to + ``None``, so the compiler never sees an empty-map instruction and the + word ``buttons`` never appears anywhere in the output. + """ + flow_text = _compile( + tmp_path, + _HEADER + "s:\n title: S\n prompt: do work\n", + "stages:\n s:\n notes: {}\n", + ) + + assert "buttons" not in yaml.safe_load(flow_text)["stages"][0] + assert "buttons" not in flow_text + + def test_notes_on_skipped_stage_not_applied(self, tmp_path: Path) -> None: + """A skip+notes entry never leaks buttons into the survivors. + + Skip removal (4skip) runs BEFORE the effective-notes resolution and the + assembly, so the skipped stage's notes are unreachable by design; the + dependent stage ``t`` is reconnected past ``s``. + """ + flow_text = _compile( + tmp_path, + _HEADER + + "s:\n" + + " title: S\n" + + " prompt: do s\n" + + "t:\n" + + " title: T\n" + + " prompt: do t\n" + + " depends_on: [s]\n", + "stages:\n s:\n skip: true\n notes:\n fix: x\n", + ) + + stages = yaml.safe_load(flow_text)["stages"] + + assert [stage["id"] for stage in stages] == ["t"] + assert "buttons" not in flow_text + + def test_workflow_notes_do_not_mutate_pipeline_document(self, tmp_path: Path) -> None: + """Output-side only — neither the pipeline mirror nor the workflow's map is touched. + + The notes instruction travels as a function argument into + ``FlowStage.fields`` (a deep copy): the ``PipelineDocument`` body never + carries a ``buttons``/``notes`` key, the workflow's own dict keeps its + content and identity, and the assembled map never aliases it. + """ + workflow = WorkflowDocument(stages={"s": WorkflowStage(notes={"fix": "F"})}) + notes_object = workflow.stages["s"].notes + + pipeline_doc, flow_doc = compile_flow( + _write(tmp_path, "s:\n title: S\n prompt: do work\n"), + tmp_path / "flow.yml", + workflow=workflow, + ) + + for step in pipeline_doc.body.steps: + assert "buttons" not in step.body + assert "notes" not in step.body + + assert workflow.stages["s"].notes == {"fix": "F"} + assert workflow.stages["s"].notes is notes_object + assert flow_doc.stages[0].fields["buttons"] == {"fix": "F"} + assert flow_doc.stages[0].fields["buttons"] is not workflow.stages["s"].notes + + def test_pipeline_without_notes_compiles_byte_identical(self, tmp_path: Path) -> None: + """A directive-rich pipeline compiles byte-identically with a notes-free NO-OP workflow. + + The regression gate for the canonical-order extension: an empty-entry + workflow (``stages: {build: {}}`` — a real stage name, all fields + defaulted, a no-op override) changes nothing, so the workflow-less and + NO-OP-workflow flow-files are equal byte-for-byte and neither carries + a ``buttons`` key. + """ + pipeline_text = ( + _HEADER + + "build:\n" + + " title: Build\n" + + " communication: true\n" + + " before_script: echo prep\n" + + " script: make all\n" + + " after_script: echo done\n" + + " timeout: 30m\n" + + "check:\n" + + " title: Check\n" + + " prompt: verify\n" + + " roles: [planner, reviewer]\n" + + " skills: [goga-review]\n" + + " trigger: manual\n" + ) + + text_a = _compile(tmp_path, pipeline_text) + text_b = _compile(tmp_path, pipeline_text, "stages:\n build: {}\n") + + assert text_a == text_b + assert "buttons" not in text_a diff --git a/tests/pipeline/compiler/test_integration.py b/tests/pipeline/compiler/test_integration.py index d5722762..97868df5 100644 --- a/tests/pipeline/compiler/test_integration.py +++ b/tests/pipeline/compiler/test_integration.py @@ -29,6 +29,7 @@ "command", "prompt", "description", + "buttons", "agents", "supervisor", "supervisor_prompt", From 969a666acb5402e90c245d9e2152e19909283def Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 02:43:37 +0000 Subject: [PATCH 070/229] feat: serialize_flow emits per-stage buttons mapping --- goga/pipeline/compiler/serialize_flow.py | 42 ++++++--- .../test_serialize_flow_buttons_slot.py | 88 +++++++++++++++++++ 2 files changed, 117 insertions(+), 13 deletions(-) create mode 100644 tests/pipeline/compiler/test_serialize_flow_buttons_slot.py diff --git a/goga/pipeline/compiler/serialize_flow.py b/goga/pipeline/compiler/serialize_flow.py index 3bc6328e..c9f6e9fb 100644 --- a/goga/pipeline/compiler/serialize_flow.py +++ b/goga/pipeline/compiler/serialize_flow.py @@ -10,15 +10,18 @@ (``_FlowAgents``) while ``skills`` and ``depends_on`` stay block-style, and block-literal scalar style for the top-level ``prompt`` (``_BlockLiteralPrompt``) and for multi-line ``script_before``/``script``/``script_after``/``script_timeout`` -stage fields (``_BlockLiteralScript``). ``serialize_flow`` wraps any ``agents`` +stage fields and multi-line ``buttons`` values (``_BlockLiteralScript``). +``serialize_flow`` wraps any ``agents`` list value in ``_FlowAgents``, any non-``None`` top-level prompt in -``_BlockLiteralPrompt``, and any multi-line ``script_*`` string value in +``_BlockLiteralPrompt``, any multi-line ``script_*`` string value in +``_BlockLiteralScript``, and any multi-line ``buttons`` map value in ``_BlockLiteralScript`` before passing the document to ``yaml.dump``, so the rules never leak into the rest of the pipeline. The default ``beautiful_yaml`` parameters render a multi-line string -single-quoted, so the block-literal marker is mandatory for multi-line scripts; -single-line scripts and the boolean ``auto_approve``/``auto_run`` fields stay -plain scalars. +single-quoted, so the block-literal marker is mandatory for multi-line scripts +and multi-line button texts; +single-line scripts, single-line button values, and the boolean +``auto_approve``/``auto_run`` fields stay plain scalars. """ from __future__ import annotations @@ -86,7 +89,11 @@ def _build_stage_repr(stage: FlowStage) -> dict[str, object]: they serialize in block-literal scalar style (the default parameters render a multi-line string single-quoted); single-line scripts and the boolean ``auto_approve``/``auto_run`` stay plain - scalars. ``auto_run`` occupies the canonical field slot immediately after + scalars. A ``buttons`` mapping is rebuilt entry by entry — single-line values + stay plain scalars (quoted as needed), multi-line values are wrapped in + ``_BlockLiteralScript`` — preserving the map's insertion order (never sorted); + the mapping itself serializes as a regular block-style mapping. + ``auto_run`` occupies the canonical field slot immediately after ``auto_approve`` and is present only when the stage's effective trigger is ``manual`` — the value is always ``False`` (``auto_run: false``; the serializer itself enforces neither rule, it emits ``fields`` verbatim). @@ -110,6 +117,11 @@ def _build_stage_repr(stage: FlowStage) -> dict[str, object]: and "\n" in value ): stage_repr[key] = _BlockLiteralScript(value) + elif key == "buttons" and isinstance(value, dict): + stage_repr[key] = { + note: (_BlockLiteralScript(text) if isinstance(text, str) and "\n" in text else text) + for note, text in value.items() + } else: stage_repr[key] = value @@ -130,15 +142,19 @@ def serialize_flow(doc: FlowDocument) -> str: Each stage is emitted as ``id``, ``name``, then the stage's ``fields`` verbatim (preserving their canonical order), then ``depends_on`` only when it is not ``None``. ``agents`` lists serialize in flow-style; - ``skills`` and ``depends_on`` serialize in block-style. Multi-line - ``script_before``/``script``/``script_after``/``script_timeout`` string values - serialize in block-literal scalar style; single-line scripts and the boolean - ``auto_approve``/``auto_run`` serialize as plain bool scalars (the canonical - field order slots ``auto_run`` immediately after ``auto_approve``; a stage + ``skills``, ``depends_on``, and the ``buttons`` mapping serialize in + block-style. Multi-line ``script_before``/``script``/``script_after``/ + ``script_timeout`` string values serialize in block-literal scalar style; + single-line scripts and the boolean ``auto_approve``/``auto_run`` serialize + as plain bool scalars (the canonical field order slots ``auto_run`` + immediately after ``auto_approve``; a stage without a manual-effective trigger serializes without the ``auto_run`` key — byte-identical output for trigger-free pipelines — and ``auto_run`` - serializes only as ``auto_run: false``). The output ends with exactly one - trailing newline. + serializes only as ``auto_run: false``). ``buttons`` values serialize as + plain scalars when single-line (quoted as needed) and in block-literal + scalar style when multi-line, preserving the map's insertion order; a stage + without a ``buttons`` key serializes without it. The output ends with exactly + one trailing newline. The serializer does not reorder, validate, or otherwise transform the input — a document with out-of-order ``fields`` produces out-of-order output. diff --git a/tests/pipeline/compiler/test_serialize_flow_buttons_slot.py b/tests/pipeline/compiler/test_serialize_flow_buttons_slot.py new file mode 100644 index 00000000..56ad5d97 --- /dev/null +++ b/tests/pipeline/compiler/test_serialize_flow_buttons_slot.py @@ -0,0 +1,88 @@ +"""Logic tests for the per-stage ``buttons`` slot + ``serialize_flow`` emission. + +Covers the Task 4 serializer extension: the per-stage ``buttons`` mapping (a map +of note name → prompt text assembled by ``compile_flow`` from the workflow +``notes`` instruction) serializes as a regular block-style mapping — +``default_flow_style=False`` already renders it block-style, so the branch adds +no new dump parameters. Single-line values serialize as plain scalars (quoted as +needed by the ``SafeDumper``), multi-line values are wrapped in the existing +``_BlockLiteralScript`` marker so they serialize in block-literal scalar style; +insertion order is preserved verbatim (never sorted). +""" + +from __future__ import annotations + +import yaml +from goga.pipeline.compiler import FlowDocument, FlowStage, serialize_flow + + +def _doc_with_fields(fields: dict[str, object]) -> FlowDocument: + """Build a minimal ``FlowDocument`` carrying one stage with the given ``fields``.""" + return FlowDocument( + name="N", + description="D", + stages=[FlowStage(id="a", name="A", depends_on=None, fields=fields)], + ) + + +class TestSerializeFlowButtonsSlot: + """Behavioral tests for the per-stage ``buttons`` emission rules.""" + + def test_serialize_flow_importable(self) -> None: + """``serialize_flow`` is exported from the compiler facade.""" + from goga.pipeline.compiler import serialize_flow as imported + + assert callable(imported) + + def test_serialize_single_line_button_value_plain(self) -> None: + """A single-line button value serializes as a plain scalar — unquoted.""" + doc = _doc_with_fields({"buttons": {"fix": "Fix it", "probe": "Line1\nLine2"}}) + + text = serialize_flow(doc) + + assert " fix: Fix it" in text + # Not block-literal, not quoted. + assert " fix: |" not in text + assert " fix: 'Fix it'" not in text + + def test_serialize_multiline_button_value_block_literal(self) -> None: + """A multi-line button value serializes in block-literal scalar style.""" + doc = _doc_with_fields({"buttons": {"fix": "Fix it", "probe": "Line1\nLine2"}}) + + text = serialize_flow(doc) + + assert " probe: |" in text + assert " probe: 'Line1" not in text + # Each line indented under the block-literal header. + assert " Line1" in text + assert " Line2" in text + + def test_serialize_buttons_map_block_style(self) -> None: + """The buttons mapping serializes block-style — not as a flow-style map.""" + doc = _doc_with_fields({"buttons": {"fix": "Fix it", "probe": "Line1\nLine2"}}) + + text = serialize_flow(doc) + + assert "buttons:" in text + assert "buttons: {" not in text + + def test_serialize_button_value_quoted_as_needed(self) -> None: + """YAML-ambiguous single-line values are quoted so afm reads them back as strings.""" + doc = _doc_with_fields({"buttons": {"probe": "L1\nL2", "num": "123", "flag": "yes"}}) + + text = serialize_flow(doc) + + assert "num: '123'" in text + assert "flag: 'yes'" in text + + def test_serialize_buttons_round_trips_through_safe_load(self) -> None: + """A buttons mapping parses back to the original map with all values ``str``.""" + doc = _doc_with_fields({"buttons": {"probe": "L1\nL2", "num": "123", "flag": "yes"}}) + + text = serialize_flow(doc) + loaded = yaml.safe_load(text) + + assert loaded["stages"][0]["buttons"] == {"probe": "L1\nL2", "num": "123", "flag": "yes"} + assert all( + isinstance(value, str) for value in loaded["stages"][0]["buttons"].values() + ) From 52deef10622f0b0f18eef2c019001b12afd967cb Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 02:45:02 +0000 Subject: [PATCH 071/229] feat: pin apply_skip_stages notes behavior with tests --- goga/pipeline/apply_skip_stages.py | 3 ++- tests/pipeline/test_apply_skip_stages.py | 33 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/goga/pipeline/apply_skip_stages.py b/goga/pipeline/apply_skip_stages.py index 57ab2bb4..59e255e3 100644 --- a/goga/pipeline/apply_skip_stages.py +++ b/goga/pipeline/apply_skip_stages.py @@ -40,7 +40,8 @@ def apply_skip_stages(workflow: WorkflowDocument | None, skip_stages: list[str]) (``None`` stays ``None``; no skip applied). - Skip always wins — a name present in both the workflow stages and ``skip_stages`` is replaced with a ``WorkflowStage(skip=True)`` (all - other fields at defaults). + other fields at defaults — ``notes`` stays ``None``, the model-field + default). - Do not mutate the input ``workflow`` or its stages/extend maps. - When ``workflow`` is ``None`` and ``skip_stages`` is non-empty, construct a document whose stages map carries only the skip entries diff --git a/tests/pipeline/test_apply_skip_stages.py b/tests/pipeline/test_apply_skip_stages.py index c74944b7..5994fa8c 100644 --- a/tests/pipeline/test_apply_skip_stages.py +++ b/tests/pipeline/test_apply_skip_stages.py @@ -150,3 +150,36 @@ def test_apply_skip_stages_idempotent_duplicate_name(self) -> None: assert set(result.stages.keys()) == {"build"} assert result.stages["build"].skip is True + + def test_skip_replaces_notes_entry(self) -> None: + """A skipped name's replacement stage carries notes=None (model default).""" + workflow = WorkflowDocument( + stages={ + "a": WorkflowStage(notes={"x": "1"}), + "b": WorkflowStage(notes={"y": "2"}), + }, + ) + + result = apply_skip_stages(workflow, ["a"]) + + # The wholesale replacement carries skip=True and the notes model + # default (None) — dropped notes are unreachable by design (the + # compiler removes the stage before any notes application). + assert result.stages["a"].notes is None + assert result.stages["a"].skip is True + # The input document is unchanged. + assert workflow.stages["a"].notes == {"x": "1"} + assert workflow.stages["a"].skip is False + + def test_apply_skip_stages_preserves_notes_of_other_stages(self) -> None: + """Surviving stages keep their notes through the shallow stages copy.""" + workflow = WorkflowDocument( + stages={ + "a": WorkflowStage(notes={"x": "1"}), + "b": WorkflowStage(notes={"y": "2"}), + }, + ) + + result = apply_skip_stages(workflow, ["a"]) + + assert result.stages["b"].notes == {"y": "2"} From 33c89bc9ac79c6099e1b93ed90c9b6f38a958bef Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 03:03:23 +0000 Subject: [PATCH 072/229] fix: address code review findings Tests (5 new, all previously unpinned contract boundaries): - compile path with a multi-line note value (block-literal emission + safe_load round-trip, not just the directly-constructed FlowDocument) - buttons insertion-order preservation in the serializer (never sorted) - a body 'notes' key passes through as an ordinary unknown key (it is not the workflow instruction) - open-key stance of _validate_notes: a non-str key flows verbatim into WorkflowStage.notes and is interpolated verbatim into the error location when a value fails Docs (notes/buttons feature was absent from user-facing documentation): - workflows.md: notes in the capability list, document-shape example, field table (seven -> eight fields), rules bullet, new 'Note buttons' subsection, 3 new error rows, stale valid-keys fragments fixed - pipeline-file.md: body-buttons prohibition documented (errors table + stage-fields callout) - index.md + README.md: notes lever added to the workflow enumerations --- README.md | 13 +++- docs/pipelines/index.md | 6 +- docs/pipelines/pipeline-file.md | 6 ++ docs/pipelines/workflows.md | 64 +++++++++++++++++-- .../compiler/test_compile_flow_notes.py | 42 ++++++++++++ .../test_serialize_flow_buttons_slot.py | 14 ++++ .../workflow/test_parse_workflow_logic.py | 36 +++++++++++ 7 files changed, 170 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 40921364..87c0dbf3 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ goga pipeline review # scoped review of code, contracts, docs, then lint goga pipeline sync # sync specifications & tests with the code after changes ``` -Each pipeline is a flat YAML file describing the stages; layer project-specific behavior on top via an optional [workflow](https://qarium.github.io/goga/pipelines/workflows/) file (per-stage agent, additional skills, prompt context, loop expansion, auto-approval, manual stage launch, stage skipping, new stages). +Each pipeline is a flat YAML file describing the stages; layer project-specific behavior on top via an optional [workflow](https://qarium.github.io/goga/pipelines/workflows/) file (per-stage agent, additional skills, prompt context, loop expansion, auto-approval, manual stage launch, stage skipping, note buttons, new stages). **4. Drive the cycle by hand (optional)** — if you want explicit control over each step instead of running a full pipeline, formulate the task and step through each command manually: @@ -203,7 +203,7 @@ A running pipeline executes inside a Docker container, where its flows, run-stat ### Workflows — configure and extend a pipeline -A **workflow-file** (`.goga/workflows/<name>.yml`) configures and extends a compiled pipeline at run time, without touching the pipeline-file. Six levers, each with a short example. +A **workflow-file** (`.goga/workflows/<name>.yml`) configures and extends a compiled pipeline at run time, without touching the pipeline-file. Seven levers, each with a short example. **`agent` — hire a different agent per stage.** Authoring on `codex`, reviews on `claude`, no pipeline duplication: @@ -267,6 +267,15 @@ stages: - Do not build architecture in the task. ``` +**`notes` — attach note buttons to a stage.** A map of note name → prompt text, compiled verbatim into the stage's `buttons` field: + +```yaml +stages: + deploy: + notes: + fix: Fix the failure and continue +``` + Additionally: `skip: true` removes a stage with transparent reconnection of dependents, and `extend:` adds brand-new stages with `before`/`after` positioning (a new stage's own launch mode is authored in its body via `trigger: manual`). The full model is in the [Workflows](https://qarium.github.io/goga/pipelines/workflows/) documentation. Run with a workflow: diff --git a/docs/pipelines/index.md b/docs/pipelines/index.md index d88507e5..e8f82a95 100644 --- a/docs/pipelines/index.md +++ b/docs/pipelines/index.md @@ -37,7 +37,8 @@ The pipelines layer is split into two authoring surfaces: - **[Workflows](workflows.md)** — an optional layering document that extends a compiled pipeline at run time with a top-level prompt, per-stage agent / prompt overrides, loop expansion, stage skipping via `skip`, manual launch - via `manual`, and new stages declared via `extend`. + via `manual`, note buttons via `notes` (compiled to the afm `buttons` + field), and new stages declared via `extend`. Authored per project; lives in `.goga/workflows/<name>.yml` (project-only). A pipeline-file answers **what** the pipeline does. A workflow answers @@ -83,7 +84,8 @@ the afm auto-approval effects (interactive suppression and/or `auto_approve` emission), and per-stage `manual: true|false` forces or cancels the stage's manual launch mode (a stage-body `trigger: manual` compiles to the afm `auto_run: false` key — the stage pauses until -launched). The pipeline-file itself can also carry +launched). A per-stage `notes` map compiles verbatim to the afm `buttons` +field (note buttons). The pipeline-file itself can also carry `before_script` / `script` / `after_script` shell directives on any stage — compiled to the afm `script_*` keys — and a `timeout` directive (Go duration string) that compiles to the afm `script_timeout` key and bounds diff --git a/docs/pipelines/pipeline-file.md b/docs/pipelines/pipeline-file.md index 727ada19..dcb5d0b4 100644 --- a/docs/pipelines/pipeline-file.md +++ b/docs/pipelines/pipeline-file.md @@ -129,6 +129,11 @@ assigned semantics: > (the stage pauses when reached and runs only when launched manually). Authoring > `auto_run` directly is rejected with a structural error — use > `trigger: manual`. +> +> The per-stage `buttons` field (note buttons) has no pipeline-file authoring +> key at all: it is assembled by the compiler from a workflow `notes` +> instruction (see [Workflows — Note buttons](workflows.md#note-buttons-notes)). +> Authoring `buttons` in a stage body is rejected with a structural error. | Field | Type | Default | Description | |---------------|------------------|-----------------------------|------------------------------------------------------------------------------| @@ -446,6 +451,7 @@ workflow-agent semantics. | Legacy `agents` key in a stage body | `agents key is forbidden in stage body; use roles` | | Authoring `interactive` in a stage body | `interactive key is forbidden in stage body; use communication` | | Authoring `auto_run` in a stage body | `auto_run key is forbidden in stage body; use trigger: manual` | +| Authoring `buttons` in a stage body | `buttons key is forbidden in stage body; use notes in workflow.stages` | | `trigger` value outside `on_success`/`manual` | `trigger must be one of: on_success, manual` | | `timeout` value is not a string (including YAML-null) | `timeout must be a string in stage <NAME>` | | `timeout` without `script` in the same body | `timeout requires script in stage <NAME>` | diff --git a/docs/pipelines/workflows.md b/docs/pipelines/workflows.md index 84629364..760f0468 100644 --- a/docs/pipelines/workflows.md +++ b/docs/pipelines/workflows.md @@ -5,8 +5,8 @@ behavior on top of a compiled pipeline at run time. A workflow can inject a top-level prompt, override the agent or prompt of specific stages, expand a stage into N chained copies via `loop`, **skip (delete) a stage**, declare per-stage **auto-approval** via `approve`, force or cancel a stage's -**manual launch mode** via `manual`, and **declaratively add -new stages** to the pipeline via `extend`. +**manual launch mode** via `manual`, attach **note buttons** to a stage via +`notes`, and **declaratively add new stages** to the pipeline via `extend`. Stage names in `workflow.stages` are matched strictly: a name that does not match any pipeline step or extend-stage is a compile error. Workflows that @@ -38,6 +38,8 @@ stages: loop: 2 # optional iteration count (>= 1) skills: [web-search] # optional skills merged with the pipeline stage's skills approve: auto # optional auto-approval directive: auto | plan | dialog + notes: # optional note buttons compiled to the afm `buttons` field + fix: Fix the failure and continue extend: <new-stage-name>: @@ -62,7 +64,7 @@ Unknown top-level keys are rejected with ## Stage entries -Each entry under `stages` is keyed by stage name and accepts up to seven +Each entry under `stages` is keyed by stage name and accepts up to eight fields: | Field | Type | Default | Description | @@ -74,12 +76,13 @@ fields: | `skip` | bool | — | When `true`, the compiler DELETES this stage from the compiled pipeline (the stage is absent from the flow-file entirely). Dependents of the skipped stage are transparently reconnected to its predecessors (no dangling references). `false` (or an absent key) leaves the stage in place. `skip` is allowed ONLY in the `stages` block — it is a structural error under `extend`. `skip` wins over `agent`/`prompt`/`loop`/`skills` overrides on the same entry. | | `approve` | string | — | Auto-approval directive. Accepted values are `auto`, `plan`, and `dialog`; any other value (or a non-string) is a structural error. Each value drives a subset of two INDEPENDENT effects the compiler applies to the stage body (see [Auto-approval (`approve: auto/plan/dialog`)](#auto-approval-approve-auto-plan-dialog)): (1) **communication effect** — if the body has `communication: true`, the stage's `interactive` output is SUPPRESSED (omitted, not `false`); (2) **roles effect** — if the body's raw `roles` contain `planner`, the stage emits `auto_approve: true`. `auto` drives BOTH effects; `plan` drives only the communication effect; `dialog` drives only the roles effect. Allowed in both `stages` and `extend` (inline default override; a `stages` entry wins per-field). | | `manual` | bool | — | Manual-launch instruction, `stages` block only. `true` forces the manual launch mode: the compiler emits `auto_run: false` for the stage, overriding any authored `trigger` in its body. `false` cancels a manual state coming from either body source (a pipeline-file `trigger: manual` or an extend body `trigger: manual`) and is a structural error (`manual: false on non-manual stage <NAME>`) when the stage is not manual. An absent key means no instruction — the stage's own `trigger` decides. The three states (`true`/`false`/absent) are distinct; a non-bool value (including `null`) is a structural error. Allowed ONLY in `stages` — it is a structural error under `extend` (a new stage's launch mode is authored in its body via `trigger`). `skip` wins over `manual`: a skipped stage is removed before the manual instruction is applied. See [Manual launch (`manual` and `trigger`)](#manual-launch-manual-and-trigger). | +| `notes` | map of str→str | — | Note buttons — a map of "note name → prompt text" compiled verbatim into the stage's afm `buttons` field (canonical slot after `description`). Single-line texts serialize as plain scalars, multi-line texts as block literals. An empty map equals absence (no `buttons` key emitted). Allowed ONLY in `stages` — it is a structural error under `extend` (an extend-stage receives its buttons through the `stages` block by name). Every `loop`-expanded copy carries the same buttons; `skip` wins over `notes`. Interpretation of the buttons belongs to afm — the compiler only assembles and serializes the field. See [Note buttons (`notes`)](#note-buttons-notes). | Rules: -- Only `agent`, `prompt`, `loop`, `skills`, `skip`, `approve`, `manual` are valid. An unknown key - is rejected with `unknown key in workflow.stages.<NAME>: <KEY>; valid keys: - agent, prompt, loop, skills, skip, approve, manual`. +- Only `agent`, `prompt`, `loop`, `skills`, `skip`, `approve`, `manual`, `notes` are valid. An + unknown key is rejected with `unknown key in workflow.stages.<NAME>: <KEY>; valid keys: + agent, prompt, loop, skills, skip, approve, manual, notes`. - `loop` must be an int `>= 1`. Zero, negative values, and non-int types raise a structural error. - `skills` must be a `list[str]`. A non-list (or a list with non-string @@ -97,6 +100,12 @@ Rules: explicit `null`) raises `non-bool value in workflow.stages.<NAME>.manual`. `manual` is allowed only in the `stages` block — it is a structural error under `extend`. +- `notes` (when present) must be a mapping with string values. A non-mapping + value (including an explicit `null`) raises `non-mapping notes in + workflow.stages.<NAME>`; a non-string value raises `non-str value in + workflow.stages.<NAME>.notes.<KEY>`. An empty map is treated as absence. + `notes` is allowed only in the `stages` block — it is a structural error + under `extend` (see [Note buttons (`notes`)](#note-buttons-notes)). - The stage value must be a mapping. Non-mapping values raise `non-mapping stage <NAME> in workflow.stages`. - Stage names are validated against the target pipeline: a name that does not @@ -296,6 +305,44 @@ before the manual instruction is applied, so `skip: true` + `manual: true` on one entry simply deletes the stage. On a `loop`-expanded stage every copy carries the launch mode of the original. +### Note buttons (`notes`) + +A workflow's per-stage `notes` field attaches **note buttons** to a stage: a +map of "note name → prompt text" that the compiler emits verbatim as the +stage's afm `buttons` field (in the canonical slot right after +`description`). + +```yaml +stages: + deploy: + notes: + fix: Fix the failure and continue + investigate: | + Gather diagnostics for the failure. + Include the last 50 log lines. +``` + +- The map passes through verbatim — keys and values unchanged, authoring + order preserved. Single-line texts serialize as plain scalars (quoted as + needed); multi-line texts serialize as block literals. +- Buttons are authored ONLY through this instruction. An authoring + `buttons` key in a stage body (a pipeline-file stage or an `extend` body) + is a structural error (`buttons key is forbidden in stage body; use notes + in workflow.stages`) — there is exactly one authoring source, so the + compiler-assembled field can never collide with an authored one. +- `notes` is allowed ONLY in the `stages` block — under `extend` it is a + structural error (`notes is forbidden in workflow.extend.<NAME>`). An + extend-stage receives its buttons through the `stages` block by its name, + like any other stage. +- An empty map (`notes: {}`) equals absence — no `buttons` key is emitted. + A pipeline compiled without notes is byte-identical to one compiled + without a workflow. +- Every `loop`-expanded copy carries the same buttons, and `skip` wins over + `notes`: a skipped stage is removed before the buttons are resolved, so + its notes never leak into a survivor. +- Interpretation of the buttons belongs to afm — the compiler only assembles + and serializes the field. + ## Extending the pipeline with new stages The `stages` block only overrides stages that already exist in the target @@ -789,7 +836,7 @@ untouched — `extend` layers new stages on top at run time. | Inline `approve` in an extend entry not a string | `non-str value in workflow.extend.<NAME>.approve` | | Inline `approve` in an extend entry not one of `auto`/`plan`/`dialog` | `approve must be one of: auto, plan, dialog in workflow.extend.<NAME>` | | Extend entry has neither `before` nor `after` | `extend entry <NAME> requires at least one of before/after` | -| Unknown per-stage key | `unknown key in workflow.stages.<NAME>: <KEY>; valid keys: agent, prompt, loop, skills, skip, approve, manual` | +| Unknown per-stage key | `unknown key in workflow.stages.<NAME>: <KEY>; valid keys: agent, prompt, loop, skills, skip, approve, manual, notes` | | `agent` present but not a string | `non-str value in workflow.stages.<NAME>.agent` | | `prompt` present but not a string | `non-str value in workflow.stages.<NAME>.prompt` | | `loop` present but not an int | `non-int value in workflow.stages.<NAME>.loop` | @@ -801,6 +848,9 @@ untouched — `extend` layers new stages on top at run time. | `approve` present but not one of `auto`/`plan`/`dialog` | `approve must be one of: auto, plan, dialog in workflow.stages.<NAME>` | | `skip` present under `extend` | `skip is forbidden in workflow.extend.<NAME>` | | `manual` present under `extend` | `manual is forbidden in workflow.extend.<NAME>` | +| `notes` present under `extend` | `notes is forbidden in workflow.extend.<NAME>` | +| `notes` present but not a mapping (including `null`) | `non-mapping notes in workflow.stages.<NAME>` | +| `notes` value not a string | `non-str value in workflow.stages.<NAME>.notes.<KEY>` | | `manual: false` on a stage that is not manual | `manual: false on non-manual stage <NAME>` | | Unknown stage name in `workflow.stages` (absent from pipeline and extend) | `unknown stage name in workflow.stages: <NAME>` | | Unknown ref in `workflow.extend.<NAME>.before` | `unknown stage name in workflow.extend.<NAME>.before: <REF>` | diff --git a/tests/pipeline/compiler/test_compile_flow_notes.py b/tests/pipeline/compiler/test_compile_flow_notes.py index 712c8358..e3560226 100644 --- a/tests/pipeline/compiler/test_compile_flow_notes.py +++ b/tests/pipeline/compiler/test_compile_flow_notes.py @@ -154,6 +154,33 @@ def test_loop_expanded_copies_carry_same_buttons(self, tmp_path: Path) -> None: assert buttons[1] is not buttons[2] assert buttons[0] is not buttons[2] + def test_multiline_note_value_compiles_block_literal(self, tmp_path: Path) -> None: + """A multi-line note text compiles to a block-literal value through the full path. + + Exercising the whole chain (parse → effective-notes resolution → the + deep-copy hops → the serializer's block-literal wrapping) rather than + only a directly constructed ``FlowDocument``: the authored multi-line + prompt text must survive the compile unchanged and round-trip through + ``yaml.safe_load``. + """ + flow_text = _compile( + tmp_path, + _HEADER + "s:\n title: S\n prompt: do work\n", + "stages:\n" + " s:\n" + " notes:\n" + " fix: |-\n" + " Line1\n" + " Line2\n", + ) + + stage = yaml.safe_load(flow_text)["stages"][0] + + assert stage["buttons"] == {"fix": "Line1\nLine2"} + assert " fix: |" in flow_text + assert " Line1" in flow_text + assert " Line2" in flow_text + def test_notes_apply_to_extend_stage_by_name(self, tmp_path: Path) -> None: """An extend-stage receives its buttons through the stages block by name. @@ -263,6 +290,21 @@ def test_notes_on_unknown_stage_name_raises_existing_error(self, tmp_path: Path) assert str(excinfo.value) == "unknown stage name in workflow.stages: ghost" + def test_body_notes_key_is_not_the_instruction(self, tmp_path: Path) -> None: + """A ``notes`` key in a stage body passes through as an ordinary unknown key. + + Only the authoring ``buttons`` key is prohibited; ``notes`` in a body + is NOT the workflow instruction (that lives in ``workflow.stages`` + alone), so it is neither consumed nor rejected — it lands in the + output verbatim like any unknown key and never becomes buttons. + """ + flow_text = _compile(tmp_path, _HEADER + "s:\n title: S\n notes:\n fix: x\n") + + stage = yaml.safe_load(flow_text)["stages"][0] + + assert stage["notes"] == {"fix": "x"} + assert "buttons" not in stage + class TestNotesEdges: """Omission edges — empty notes, skip precedence, no-mutation, byte-identity.""" diff --git a/tests/pipeline/compiler/test_serialize_flow_buttons_slot.py b/tests/pipeline/compiler/test_serialize_flow_buttons_slot.py index 56ad5d97..7ed6efe8 100644 --- a/tests/pipeline/compiler/test_serialize_flow_buttons_slot.py +++ b/tests/pipeline/compiler/test_serialize_flow_buttons_slot.py @@ -86,3 +86,17 @@ def test_serialize_buttons_round_trips_through_safe_load(self) -> None: assert all( isinstance(value, str) for value in loaded["stages"][0]["buttons"].values() ) + + def test_serialize_buttons_preserves_insertion_order(self) -> None: + """The buttons mapping serializes in authored order — never sorted. + + The branch rebuilds the dict (wrapping multi-line values), so the + rebuild itself could reorder it; the authored order is what afm + renders as the button order. Non-alphabetical keys make a ``sorted`` + regression observable. + """ + doc = _doc_with_fields({"buttons": {"zebra": "Z", "alpha": "A", "mid": "M"}}) + + text = serialize_flow(doc) + + assert list(yaml.safe_load(text)["stages"][0]["buttons"]) == ["zebra", "alpha", "mid"] diff --git a/tests/pipeline/workflow/test_parse_workflow_logic.py b/tests/pipeline/workflow/test_parse_workflow_logic.py index 501154c6..20fb526c 100644 --- a/tests/pipeline/workflow/test_parse_workflow_logic.py +++ b/tests/pipeline/workflow/test_parse_workflow_logic.py @@ -325,6 +325,24 @@ def test_parse_workflow_empty_notes_map_normalizes_to_none(self, tmp_path: Path) assert document.stages["deploy"].notes is None + def test_parse_workflow_non_str_note_key_flows_through_verbatim(self, tmp_path: Path) -> None: + """A non-str note KEY is not rejected — the open-key stance. + + Map keys are deliberately unvalidated (afm owns the runtime note + grammar — the same open stance as the agent namespace): a non-str + key flows through verbatim into ``WorkflowStage.notes``. Only the + VALUES are validated at parse time. + """ + workflow_path = _write( + tmp_path, + "workflow.yml", + "stages:\n deploy:\n notes:\n 1: Fix it\n", + ) + + document = parse_workflow(workflow_path) + + assert document.stages["deploy"].notes == {1: "Fix it"} + def test_parse_workflow_extend_populates_document(self, tmp_path: Path) -> None: """A workflow-file with an extend block parses each entry into WorkflowExtendStage.""" workflow_path = _write( @@ -789,6 +807,24 @@ def test_parse_workflow_non_str_note_value_raises(self, tmp_path: Path) -> None: assert str(exc_info.value) == "non-str value in workflow.stages.deploy.notes.fix" + def test_parse_workflow_non_str_note_key_interpolated_in_error(self, tmp_path: Path) -> None: + """A failing value under a non-str key names the key verbatim in the location. + + The open-key stance means the error fragment carries whatever key was + authored — the integer key ``1`` lands in the message unchanged + (``...stages.deploy.notes.1``). + """ + workflow_path = _write( + tmp_path, + "workflow.yml", + "stages:\n deploy:\n notes:\n 1: 5\n", + ) + + with pytest.raises(WorkflowSyntaxError) as exc_info: + parse_workflow(workflow_path) + + assert str(exc_info.value) == "non-str value in workflow.stages.deploy.notes.1" + def test_parse_workflow_notes_forbidden_in_extend_entry(self, tmp_path: Path) -> None: """A ``notes`` key inside an extend entry raises WorkflowSyntaxError naming the entry. From 49ca1ca7ce23bd21ebe88c1471dbfb346b1c906b Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 03:13:32 +0000 Subject: [PATCH 073/229] fix: address code review findings --- docs/pipelines/workflows.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/pipelines/workflows.md b/docs/pipelines/workflows.md index 760f0468..e6705cd9 100644 --- a/docs/pipelines/workflows.md +++ b/docs/pipelines/workflows.md @@ -335,8 +335,10 @@ stages: extend-stage receives its buttons through the `stages` block by its name, like any other stage. - An empty map (`notes: {}`) equals absence — no `buttons` key is emitted. - A pipeline compiled without notes is byte-identical to one compiled - without a workflow. + `notes` changes nothing else: a workflow whose entries are all empty + compiles byte-identically to compiling without a workflow, while any + other instruction (`prompt`, `agent`, `loop`, `skip`, ...) changes the + output as usual — notes or not. - Every `loop`-expanded copy carries the same buttons, and `skip` wins over `notes`: a skipped stage is removed before the buttons are resolved, so its notes never leak into a survivor. From 2e78176818819424ed61f44d0215752821466c62 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 17:02:58 +0000 Subject: [PATCH 074/229] feat: add topics cell architecture with statuses registration and topics command New cells from the usability-for-topic-workaround plan: - goga/topics: topic board domain - BoardRecord, collect_topic_board, SwitchCandidate, resolve_switch_candidates, switch_topic, create_topic, check_branch_occupancy - goga/topics/git: git access for the topics domain - BranchRef, list_branch_refs, read_ref_tree_paths, checkout_local_branch, create_branch_from_remote_tracking, create_and_switch_branch, is_working_tree_clean - goga/history/statuses: status scale assembly - StatusScale, Stage, StatusRegistry, assemble_status_scale with the tool-package register_topic_statuses registration contract - goga/commands/topics: goga topics command group (board table, create, switch) Updated contracts: - goga/history: statuses moved to goga/history/statuses; the facade re-exports StatusScale, Stage, StatusRegistry, assemble_status_scale - goga/commands/pipeline: -b/--branch replaced with -t/--topic resolving through switch_topic; the git usage narrowed to the identity read - goga/commands/history, goga/commands/tool, goga/commands, goga: imports and annotations aligned with the new cells and practices Usages: new topics-command, registering-statuses, creating, switching, topic-board, refs-and-switching; updated cli-commands, history-command, pipeline-command, topic-paths, topic-statuses --- goga/CODEMANIFEST | 2 + goga/commands/.usages/cli-commands.md | 7 +- goga/commands/CODEMANIFEST | 14 +- .../history/.usages/history-command.md | 37 +- goga/commands/history/CODEMANIFEST | 51 +-- .../pipeline/.usages/pipeline-command.md | 61 +--- goga/commands/pipeline/CODEMANIFEST | 217 ++++-------- goga/commands/tool/CODEMANIFEST | 5 + .../commands/topics/.usages/topics-command.md | 48 +++ goga/commands/topics/CODEMANIFEST | 169 +++++++++ goga/history/.usages/registering-statuses.md | 38 ++ goga/history/.usages/topic-paths.md | 18 +- goga/history/.usages/topic-statuses.md | 69 ++-- goga/history/CODEMANIFEST | 169 ++++----- goga/history/statuses/CODEMANIFEST | 226 ++++++++++++ goga/topics/.usages/creating.md | 30 ++ goga/topics/.usages/switching.md | 43 +++ goga/topics/.usages/topic-board.md | 31 ++ goga/topics/CODEMANIFEST | 328 ++++++++++++++++++ goga/topics/git/.usages/refs-and-switching.md | 60 ++++ goga/topics/git/CODEMANIFEST | 206 +++++++++++ 21 files changed, 1463 insertions(+), 366 deletions(-) create mode 100644 goga/commands/topics/.usages/topics-command.md create mode 100644 goga/commands/topics/CODEMANIFEST create mode 100644 goga/history/.usages/registering-statuses.md create mode 100644 goga/history/statuses/CODEMANIFEST create mode 100644 goga/topics/.usages/creating.md create mode 100644 goga/topics/.usages/switching.md create mode 100644 goga/topics/.usages/topic-board.md create mode 100644 goga/topics/CODEMANIFEST create mode 100644 goga/topics/git/.usages/refs-and-switching.md create mode 100644 goga/topics/git/CODEMANIFEST diff --git a/goga/CODEMANIFEST b/goga/CODEMANIFEST index e8365e4c..cd561a4a 100644 --- a/goga/CODEMANIFEST +++ b/goga/CODEMANIFEST @@ -14,6 +14,7 @@ Imports: - install - uninstall - history + - topics Usages: - cli-commands From: goga/commands @@ -82,6 +83,7 @@ app(): - `install` - `uninstall` - `history` + - `topics` --- diff --git a/goga/commands/.usages/cli-commands.md b/goga/commands/.usages/cli-commands.md index 65be1dfd..fffad61c 100644 --- a/goga/commands/.usages/cli-commands.md +++ b/goga/commands/.usages/cli-commands.md @@ -1,6 +1,6 @@ # CLI Commands — goga/commands facade -The `goga.commands` package is a facade that re-exports 14 CLI commands. Each command is a `click.Command` registered in a click group. Each subcell is an independent Python package (`goga/commands/<name>/`) with implementation in `<name>.py` and re-export through `__init__.py`. +The `goga.commands` package is a facade that re-exports 15 CLI commands. Each command is a `click.Command` registered in a click group. Each subcell is an independent Python package (`goga/commands/<name>/`) with implementation in `<name>.py` and re-export through `__init__.py`. ## Import @@ -22,6 +22,7 @@ from goga.commands import ( install, uninstall, history, + topics, ) ``` @@ -42,6 +43,7 @@ from goga.commands.upgrade import upgrade from goga.commands.install import install from goga.commands.install import uninstall from goga.commands.history import history +from goga.commands.topics import topics ``` ## Registration in click group @@ -64,6 +66,7 @@ from goga.commands import ( install, uninstall, history, + topics, ) @@ -86,6 +89,7 @@ app.add_command(upgrade) app.add_command(install) app.add_command(uninstall) app.add_command(history) +app.add_command(topics) ``` ## Testing with CliRunner @@ -119,3 +123,4 @@ def test_example(): | `install` | `goga/commands/install/` | Install a goga_tool_* package | | `uninstall` | `goga/commands/install/` | Remove a goga_tool_* package | | `history` | `goga/commands/history/` | Work with the .goga/history/ tree | +| `topics` | `goga/commands/topics/` | Topic board, creation, and switching | diff --git a/goga/commands/CODEMANIFEST b/goga/commands/CODEMANIFEST index 21326692..554e7045 100644 --- a/goga/commands/CODEMANIFEST +++ b/goga/commands/CODEMANIFEST @@ -46,6 +46,11 @@ Imports: Usages: - history-command From: goga/commands/history + - Types: + - topics + Usages: + - topics-command + From: goga/commands/topics Usages: convention: .goga/usages/conventions.md @@ -63,8 +68,8 @@ Annotations: | re-sync. Use the `pipeline-command` practice for consumer scenarios of the - pipeline command: the five forms, the branch preparation flow of - -b/--branch, and the flag behavior per form. + pipeline command: the five forms, the topic switch flow of -t/--topic, + and the flag behavior per form. Use the `install-usage` practice for consumer scenarios of the install command: the four modes, the post-install hooks, and the exit codes. @@ -72,6 +77,10 @@ Annotations: | command group: the four subcommands, their options and filters, the path and ensure behaviors, and the exit codes. + Use the `topics-command` practice for consumer scenarios of the topics + command group: the board table, the creation flow, the switching flow, + and the exit codes. + --- ->lint: {} @@ -88,6 +97,7 @@ Annotations: | ->install: {} ->uninstall: {} ->history: {} +->topics: {} --- diff --git a/goga/commands/history/.usages/history-command.md b/goga/commands/history/.usages/history-command.md index c86ccd71..03f04485 100644 --- a/goga/commands/history/.usages/history-command.md +++ b/goga/commands/history/.usages/history-command.md @@ -17,30 +17,19 @@ artifacts. - Read-only. An empty history prints nothing, exit 0. -## goga history status [YEAR] [-t TOPIC] [-s STATUS]… - -Prints one `topic [status]` line per topic of the year — flat, no year, no -tree. - - release-1-3-0 [done] - history-commands [planned] - -- `YEAR` — optional positional, four digits; defaults to the current year. -- `-t/--topic` — substring filter; the value is normalized (a branch name - works as a filter too). A `--topic` value that normalizes to an empty slug - (fully non-ASCII) is an error, not a match-all. -- `-s/--status` — repeatable (`-s defined -s discovered`); combined with - `--topic` by AND. Valid names: `empty`, `defined`, `discovered`, - `backlog`, `designed`, `specified`, `planned`, `done`. An unknown name is - an error (non-zero exit). -- Topics come out alphabetically. An empty result prints nothing, exit 0. -- Statuses are colorized on a terminal; piped output is plain; `NO_COLOR` - disables color always. - -A topic's status is the deepest artifact present: `prd.md` → defined, -`adr.md` → discovered, `task.md` → backlog, `arch.md` → designed, -`design.md` → specified, `plan.md` → planned, `completed/plan.md` → done; -no artifacts → empty. +## Reading the statuses of a year + + goga history status + goga history status 2025 + goga history status --topic release + goga history status -s done -s mkdocs.published + +Prints one line per topic: the slug and every maximal status in brackets, +in scale order — for example "release-1-3-0 [done] [mkdocs.published]". +Status filters take qualified status names: built-in names bare, tool +statuses as <tool>.<name>; a record matches when any of its maximal +statuses is one of the requested names. An unknown name is a clean error. +The year is never printed; an empty result prints nothing and exits 0. ## goga history path [TOPIC] [-f FILENAME] [-y YEAR] diff --git a/goga/commands/history/CODEMANIFEST b/goga/commands/history/CODEMANIFEST index a2df3c1e..6a2b1d7f 100644 --- a/goga/commands/history/CODEMANIFEST +++ b/goga/commands/history/CODEMANIFEST @@ -2,7 +2,6 @@ Imports: - Types: - HistoryYear - TopicRecord - - TopicStatus - collect_history_tree - collect_topic_statuses - ensure_topic_dir @@ -10,6 +9,7 @@ Imports: - resolve_current_branch_name - resolve_topic_dir - resolve_topic_file + - assemble_status_scale Usages: - topic-paths - topic-statuses @@ -86,14 +86,14 @@ Annotations: | "status(year: str | None = None, topic: str | None = None, statuses: tuple[str, ...]) -> exit_code: int": | Subcommand goga history status: print the flat list of topics of one - year, each line "topic [status]". + year, each line "topic [status] [status] ...". `year`: optional YEAR positional — four digits; None means the current year; the year is never printed `topic`: --topic/-t value — a substring filter; the value is normalized via `normalize_topic_slug` before matching - `statuses`: -s/--status values (repeatable, multiple=True) — status - names to keep; combined with `topic` by AND + `statuses`: -s/--status values (repeatable, multiple=True) — qualified + status names to keep; combined with `topic` by AND `exit_code`: 0 on success (an empty result included), 1 on error Apply the `topic-statuses` practice for the record and status @@ -102,18 +102,20 @@ Annotations: | the color rules. Algorithm: - 1. Validate every name in `statuses` against `TopicStatus` — an - unknown name is a clean error (stderr, non-zero exit) - 1.1. A `topic` value that normalizes to an empty slug is a clean error - (stderr, non-zero exit) — an empty filter would silently match - every topic and is rejected instead - 2. Collect the records via `collect_topic_statuses` with `year` - 3. Filter: when `topic` is given, keep the records whose topic contains - the normalized filter as a substring; when `statuses` is non-empty, - keep the records whose status is one of the resolved names; both - filters combine by AND - 4. Render the surviving records via `render_topic_statuses` - 5. An empty result renders nothing and exits 0 + 1. Assemble the status scale via `assemble_status_scale` once and + validate every name in `statuses` against it — an unknown name is + a clean error (stderr, non-zero exit) + 2. A `topic` value that normalizes to an empty slug is a clean error + (stderr, non-zero exit) — an empty filter would silently match + every topic and is rejected instead + 3. Collect the records via `collect_topic_statuses` with `year` and + the assembled scale — the single scale of this command run + 4. Filter: when `topic` is given, keep the records whose topic + contains the normalized filter as a substring; when `statuses` is + non-empty, keep the records carrying at least one of the resolved + names; both filters combine by AND + 5. Render the surviving records via `render_topic_statuses` + 6. An empty result renders nothing and exits 0 Requirements: - Topics come out alphabetically — the domain sorts, the command does @@ -121,8 +123,8 @@ Annotations: | - The year is never printed - Color follows the `click` practice: ANSI only on a TTY, NO_COLOR disables it always - - An empty normalized `topic` filter is an error, not a match-all — - consistent with the empty-slug policy of `resolve_topic_dir` + - A record matches a status filter when any of its maximal statuses + is one of the requested names Constraints: - Do not print the year, a header, or a summary line — one record per @@ -213,7 +215,8 @@ Annotations: | "render_topic_statuses(records: list[TopicRecord])": location: render.py annotations: | - Render the status view: one flat "topic [status]" line per record. + Render the status view: one flat "topic [status] [status] ..." line per + record — every maximal status of the record, in scale order. `records`: the (already filtered) records to print @@ -221,14 +224,14 @@ Annotations: | only on a TTY, NO_COLOR disables it always. Algorithm: - 1. For each `TopicRecord` in `records`, print the topic followed by the - bracketed status display name - 2. Colorize the status segment per the `click` practice; leave the topic - plain + 1. For each `TopicRecord` in `records`, print the topic followed by one + bracketed status segment per maximal status, in scale order + 2. Colorize the status segments per the `click` practice; leave the + topic plain 3. An empty `records` prints nothing Requirements: - - The status segment uses the display name of the record's status + - Every maximal status of the record is printed — none is hidden - Empty input renders empty output — not an error Constraints: diff --git a/goga/commands/pipeline/.usages/pipeline-command.md b/goga/commands/pipeline/.usages/pipeline-command.md index 3ec1a492..b9241593 100644 --- a/goga/commands/pipeline/.usages/pipeline-command.md +++ b/goga/commands/pipeline/.usages/pipeline-command.md @@ -14,7 +14,6 @@ boundary to goga/pipeline is docker. | `goga pipeline --list --info` / `-l -i` | overview: every pipeline as a `* <name>` bullet block with indented `name:`/`description:` field lines | | `goga pipeline NAME --info` / `-i` | card of one pipeline: `name:`/`description:` fields, a `---` separator, then `* <id>:` stage bullets with indented `title:` lines in execution order; nothing runs | | `goga pipeline NAME` | run | -| `goga pipeline -b BRANCH NAME` | prepare the branch, then run on it | `--list` and a name together is an error (mutually exclusive, clean message, exit 1). `--info` is a modifier, not a mode: without a name and without @@ -26,7 +25,7 @@ exit 1). `--info` is a modifier, not a mode: without a name and without |---|---|---| | -l / --list | flag | select the listing forms | | -i / --info | flag | show instead of act (overview with --list, card with NAME) | -| -b / --branch NAME | str | prepare a fresh branch + history topic before the run; run form only | +| -t / --topic ID | str | bring the repository onto the requested work (branch name, topic slug, or prefix) before the run; run form only | | -w / --workflow NAME | str | apply an explicit workflow (run and card); the file must exist (early host validation) | | --no-workflow | flag | disable workflow resolution (run and card) | | -p / --parallel N | int | max concurrently executing stages; run only | @@ -34,54 +33,22 @@ exit 1). `--info` is a modifier, not a mode: without a name and without | -c / --clean | flag | wipe persistent afm state before launch; run only | | -u / --update | flag | refresh the image before the flat list and the run; no-op in the info forms | -## Branch preparation (-b/--branch) - -Run form only. `-l`, `-l -i`, and `NAME -i` silently skip the whole -procedure — passing `-b` there is not an error and does nothing. - -Order: the branch procedure runs after the argument-form validation and -before any docker activity (no image refresh, no first-run build, no -launch). An argument-form error (for example a missing pipeline name) wins: -no branch is created. - -The entered name plays two roles: - -- **branch name** — used exactly as entered when creating and switching - (`git switch -c <name>`; git rejects invalid names itself); -- **history topic slug** — the normalized form that names the topic folder - `.goga/history/<YYYY>/<slug>/`: lowercase, non-ASCII dropped, anything - outside `[a-z0-9]` becomes `-`, repeat hyphens collapse, edge hyphens - trim (`Feature/Foo_Bar` → `feature-foo-bar`, `release/1.3.0` → - `release-1-3-0`). The same topic addressing is available directly via the - `goga history path` / `goga history ensure` commands. - -Occupancy = a local branch with the entered name, OR a remote-tracking -branch with the entered name, OR an existing `.goga/history/<YYYY>/<slug>/` -topic directory for the current year (a stray file named `<slug>` does not -occupy a topic). - -- Interactive terminal: the reason is printed and a new name is prompted - until the name is free (Ctrl-C aborts, nothing is created); a fully - non-ASCII name (empty slug) is treated as invalid input and re-asked the - same way. -- No terminal (CI/scripts): the reason plus the hint to pass another name - via `-b` goes to stderr, exit code is non-zero, the pipeline does not - start. -- Already on the target branch (slug of the entered name equals the slug of - the current branch): nothing happens, the pipeline just runs. - -When the procedure completes (a created-and-switched branch or the -already-on-branch case), goga prints `Pipeline running on branch <name>` -to stdout once, before the launch; the list/info forms print no branch -line. - -After a successful `-b` run you stay on the new branch — goga does not -switch back. +## Continuing existing work + + goga pipeline development --topic history-com + goga pipeline development -t release-1-3-0 + +Brings the repository onto the branch hosting the requested work — an exact +branch name, an exact topic slug, or their prefix — and then launches the +usual run. The switch completes on the host before any docker activity; a +repeated invocation already on the host continues without switching. The +flat list, overview, and card forms silently ignore -t. An unresolved +identifier or a dirty working tree is a clean error before any launch. ## Flag behavior in the list/info forms - Ignored (no-op, no side effects): `-e/--env`, `--proxy`, `-c/--clean`, - `-s/--skip`, `-p/--parallel`, `--add-host`, `-b/--branch`. + `-s/--skip`, `-p/--parallel`, `--add-host`, `-t/--topic`. - `-u/--update`: works in `--list` without `--info`; no-op in both `--info` forms. - `-w/--workflow` and `--no-workflow`: validated as usual (exclusivity and, @@ -109,7 +76,7 @@ The user never authors the docker -p. ## Threading chains goga pipeline NAME → run (full shape) - goga pipeline -b feat/x NAME → ensure branch feat/x → run (full shape) + goga pipeline NAME -t feat/x → switch_topic(feat/x) → run (full shape) goga pipeline --list → minimal shape: list goga pipeline --list --info → minimal shape: list --info goga pipeline NAME --info → minimal shape: run NAME --info [-w WF | --no-workflow] diff --git a/goga/commands/pipeline/CODEMANIFEST b/goga/commands/pipeline/CODEMANIFEST index 1bec44c7..c6891ed1 100644 --- a/goga/commands/pipeline/CODEMANIFEST +++ b/goga/commands/pipeline/CODEMANIFEST @@ -1,11 +1,9 @@ Imports: - Types: - - normalize_topic_slug - - resolve_current_branch_name - - topic_exists + - switch_topic Usages: - - topic-paths - From: goga/history + - switching + From: goga/topics - Types: - ProjectConfig - load_project_config @@ -53,9 +51,8 @@ Usages: git: | External git binary invoked via subprocess.run (check=True, capture_output=True). Set GIT_TERMINAL_PROMPT=0 in the env to suppress - interactive prompts. Read-only inspection (current branch name, branch ref - existence) plus one host-side mutation (create-and-switch to a new branch). - Mock the subprocess call in tests per `convention`. + interactive prompts. Read-only inspection (git identity for the + container env-file). Mock the subprocess call in tests per `convention`. Annotations: | The `convention` practice is used for: @@ -116,34 +113,31 @@ Annotations: | check; the `docker-image-version` practice covers the image-side version probe. - The optional -b/--branch flag prepares a fresh branch and a fresh history - topic before the run form launches: the entered name is normalized into the - topic slug via `normalize_topic_slug`, occupancy is checked against three - oracles (a local branch, a remote-tracking branch, the history topic folder - via `topic_exists`), and a free name creates the branch on the host and - switches to it. The procedure runs after the argument-form validation and - before any docker activity. The listing and info forms silently skip the - whole branch procedure. - - Use the `topic-paths` practice for the consumer patterns of the history - facade used by the branch procedure — the topic slug, the current branch, - and the topic-existence oracle. - Use the `git` practice for every git invocation of the branch procedure — - read-only inspection and the single create-and-switch mutation. - Use the `click` practice for the -b/--branch option: a long form and a short - alias sharing a single Option, click.prompt for the re-ask cycle, and - exit-code propagation. + The optional -t/--topic flag moves the repository onto the requested work + before the run form launches: the identifier resolves through `switch_topic` + — an exact branch name, an exact topic slug, or their prefix — and the + switch completes before any docker activity. The procedure runs after the + argument-form validation. The listing and info forms silently skip the + whole topic procedure. + + Use the `switching` practice for the consumer patterns of the topics + facade used by the topic procedure — the identifier resolution and the + switch orchestration. + Use the `git` practice for the git identity read into the container + env-file. + Use the `click` practice for the -t/--topic option: a long form and a + short alias sharing a single Option, and exit-code propagation. --- -"pipeline(ctx: click.Context, name: str | None, list_requested: bool, info: bool, branch: str | None, extra_env: tuple[str, ...], proxy: str | None, add_host: tuple[str, ...], clean: bool, update: bool, workflow: str | None, no_workflow: bool, skip: tuple[str, ...], parallel: int | None)": +"pipeline(ctx: click.Context, name: str | None, list_requested: bool, info: bool, topic: str | None, extra_env: tuple[str, ...], proxy: str | None, add_host: tuple[str, ...], clean: bool, update: bool, workflow: str | None, no_workflow: bool, skip: tuple[str, ...], parallel: int | None)": location: pipeline.py annotations: | Single CLI command "goga pipeline" with five explicit forms. Every form launches the goga Docker container and invokes the in-container entrypoint inside it; the host never reads pipeline files directly. The - optional -b/--branch flag prepares a fresh git branch and a fresh - history topic before the run form starts. + optional -t/--topic flag moves the repository onto the requested work + before the run form starts. `ctx`: Click execution context, used to propagate exit codes (per the `click` practice) @@ -154,12 +148,13 @@ Annotations: | `info`: flag from the --info/-i click option — a modifier meaning "show instead of act": with `list_requested` → the overview, with `name` → the card; on its own it selects no form. - `branch`: optional branch name from the -b/--branch option (a long form - and a short alias on a single click option). Run form only: - prepares a fresh branch and a fresh history topic via - `ensure_pipeline_branch` before any docker activity. Silently - ignored in the flat list, overview, and card forms — not an - error. + `topic`: optional identifier from the -t/--topic option (a long form + and a short alias on a single click option) — a branch name, a + topic slug, or their prefix; the year scope is always the + current year. Run form only: brings the repository onto the + hosting branch via `switch_topic` before any docker activity. + Silently ignored in the flat list, overview, and card forms — + not an error. `extra_env`: raw KEY=VALUE strings from the repeatable -e/--env option, forwarded into the container env-file in the run form only. `proxy`: optional HTTP/HTTPS proxy URL from the --proxy option; when @@ -203,7 +198,7 @@ Annotations: | 2.2. `name` absent AND `list_requested` absent → the error 'Missing pipeline name. Use "goga pipeline --list" to list available pipelines, or provide a pipeline name.' to stderr, - exit 1, nothing to stdout, no branch procedure, no image + exit 1, nothing to stdout, no topic procedure, no image refresh and no first-run build 2.3. `workflow` provided AND `no_workflow` set → mutually-exclusive error (exit 1) @@ -213,19 +208,17 @@ Annotations: | resolve into the wider filesystem; then verify <cwd>/.goga/workflows/<workflow>.yml exists; a missing file is a clean error (exit 1) - 3. Branch procedure (run form only — `name` given, `list_requested` - False, `branch` given): bring the project onto a fresh branch via - `ensure_pipeline_branch`. When the procedure completed (a - created-and-switched branch or the already-on-branch case), print - 'Pipeline running on branch <final branch name>' to stdout once, - immediately after the branch procedure and before the step-4 - dispatch; the forms that skip the procedure print no branch line. - Every git action happens on the host before any - docker activity. An input error (an empty slug) or an unresolved - conflict aborts the command with a non-zero exit before any image - refresh, build, or launch. The flat list, overview, and card forms - skip the procedure silently — passing -b there is not an error and - has no effect. + 3. Topic procedure (run form only — `name` given, `list_requested` + False, `topic` given): bring the repository onto the requested work + via `switch_topic`. When the procedure switched — or confirmed the + repository already on the host — echo the single result line to + stdout once, immediately after the topic procedure and before the + step-4 dispatch; the forms that skip the procedure print no topic + line. Every git action happens on the host before any docker + activity. An unresolved identifier or a dirty working tree aborts + the command with a non-zero exit before any image refresh, build, or + launch. The flat list, overview, and card forms skip the procedure + silently — passing -t there is not an error and has no effect. 4. Dispatch by form: - flat list — `run_pipeline_info_container` with name=None, info=False; `update` applies (image refresh before the listing) @@ -244,19 +237,20 @@ Annotations: | Requirements: - Expose -l/--list and -i/--info as click flags alongside the run options; long and short forms behave identically - - Register -b/--branch with both forms on a single click Option — both - bind the `branch` parameter identically - - When the branch procedure ran, print exactly one stdout line - 'Pipeline running on branch <name>' before the launch — in the - created-and-switched and the already-on-branch case alike; no branch - line in the flat list, overview, and card forms + - Register -t/--topic with both forms on a single click Option — both + bind the `topic` parameter identically + - When the topic procedure ran, echo exactly one stdout line — the + result line of `switch_topic` — before the launch; no topic line in + the flat list, overview, and card forms - Every step-2 check runs before any git or docker activity — an - argument-form error never creates a branch, refreshes, builds, or + argument-form error never switches a branch, refreshes, builds, or launches an image - - The branch procedure (step 3) runs before any docker activity; a - branch error never launches an image - - The listing and info forms silently ignore -b/--branch — no message, + - The topic procedure (step 3) runs before any docker activity; a topic + error never launches an image + - The listing and info forms silently ignore -t/--topic — no message, no side effects + - A repeated run already on the host continues without switching — the + call is idempotent - The listing and info forms silently ignore -e/--env, --proxy, -c/--clean, -s/--skip, -p/--parallel, and --add-host — no side effects; --clean deletes nothing @@ -280,104 +274,14 @@ Annotations: | - Do not validate the KEY=VALUE format, the "HOST:IP" format beyond the single-colon split, or the --skip stage names — forwarded as-is - Do not default or validate `parallel` - - Do not validate the branch name at the CLI layer — the branch - procedure and git own that + - Do not manage the stages of the hosting pipeline — goga prints no + stages and generates no skips; continuation belongs to the pipeline + itself - Do not switch back to the previous branch after the launch — the run - finishes on the new branch - - Do not pass the branch name into the container — the container sees + finishes on the host branch + - Do not pass the topic name into the container — the container sees the branch through the mounted project -"check_branch_occupancy(branch_name: str, slug: str) -> conflict: str | None": - location: branch.py - annotations: | - Decide whether the entered branch name and the topic slug are free. - - `branch_name`: branch name as entered (checked against git refs) - `slug`: normalized topic slug (checked against the history folder) - `conflict`: human-readable reason of the first occupied oracle, or None - when everything is free - - Apply the `git` practice for the invocation pattern. - Apply the `topic-paths` practice for the topic-existence oracle contract. - Apply the `convention` practice for docstring style and intra-package - imports. - - Algorithm: - 1. A local branch ref for `branch_name` exists -> return the reason - 2. A remote-tracking ref for `branch_name` exists -> return the reason - (local remote-tracking refs only — no network call) - 3. `topic_exists` with `slug` returns True -> return the reason (the - topic directory of the current year is taken; a stray file named - <slug> does not occupy a topic) - 4. All three oracles are free -> None - - Requirements: - - The git oracles check the name as entered; the history oracle checks - the slug — the two may deliberately differ - - The first occupied oracle wins; remaining oracles are not probed - - Read-only — no ref or folder is created - - No clock — the topic year is resolved inside the history oracle - - Constraints: - - Do not resolve remote state over the network — remote-tracking refs only - - Do not create the history folder or any ref here - -"ensure_pipeline_branch(branch_name: str) -> branch: str": - location: branch.py - annotations: | - Bring the project onto a fresh branch with a fresh history topic before - a pipeline run. - - `branch_name`: branch name as entered by the user - `branch`: the final branch name — the entered one or the re-asked one; - the current branch name for the already-on-branch case - - Apply the `click` practice for click.prompt and exit-code propagation. - Apply the `git` practice for every git invocation. - Apply the `topic-paths` practice for the consumer patterns of the history - facade — the topic slug, the current branch, and the topic-existence - oracle. - Apply the `convention` practice for docstring style and intra-package - imports. - - Algorithm: - 1. Normalize `branch_name` via `normalize_topic_slug` and read the - current branch via `resolve_current_branch_name` - 2. Empty slug -> input error: - - interactive terminal: print the reason, prompt for a new name via - click.prompt, restart from step 1 with it - - no terminal: print the reason and the hint to pass another name - via -b to stderr, fail with a non-zero exit - 3. The current branch is known and its slug equals the entered slug -> - return the current branch name; no git action, no occupancy check - (a branch does not conflict with itself) - 4. `check_branch_occupancy` with the entered name and the slug returns - a reason -> conflict: - - interactive terminal: print the reason, prompt for a new name, - restart from step 1 with it - - no terminal: print the reason and the hint to stderr, fail with a - non-zero exit - 5. Free name -> create the branch named exactly as entered on the host - and switch to it (git owns the name-validity error) - 6. Return the final branch name - - Requirements: - - The branch is created with the name exactly as entered; the history - topic name is the slug — the two may deliberately differ - - The whole procedure runs on the host, before any docker activity - - A re-ask cycle abort (Ctrl-C or closed input) leaves the repository - untouched — no branch is created, no switch happens - - After a successful create-and-switch the caller stays on the new - branch — no switch back - - Constraints: - - Do not validate branch-name characters — git rejects invalid names - itself - - Do not auto-pick suffixed names on a conflict — the user re-asks or - aborts - - Do not touch the runtime-segment branch grammar — the runtime paths - keep their own normalization and are not unified with the slug - "run_pipeline_container(name: str, config: ProjectConfig, extra_env: tuple[str, ...], proxy: str | None, hosts: dict[str, str], clean: bool, update: bool, workflow: str | None, no_workflow: bool, skip: tuple[str, ...], parallel: int | None) -> exit_code: int": location: run_pipeline_container.py annotations: | @@ -857,6 +761,7 @@ Description: | Host-side CLI wrapper cell for the single pipeline command. Every form — the flat list, the overview, the card, and the run — launches the goga Docker container and invokes the in-container pipeline entrypoint inside - it; the run form can first prepare a fresh branch and history topic on - the host. The runtime boundary to the in-container pipeline is docker — - this cell has no Python Type Imports from it. + it; the run form can first bring the repository onto the requested work + (the -t/--topic switch) on the host. The runtime boundary to the + in-container pipeline is docker — this cell has no Python Type Imports + from it. diff --git a/goga/commands/tool/CODEMANIFEST b/goga/commands/tool/CODEMANIFEST index 77491985..ca5c54f4 100644 --- a/goga/commands/tool/CODEMANIFEST +++ b/goga/commands/tool/CODEMANIFEST @@ -4,6 +4,9 @@ Imports: Usages: - loading From: goga/ast + - Usages: + - registering-statuses + From: goga/history Usages: click: .goga/usages/cooks/click.md @@ -13,6 +16,8 @@ Annotations: | The `conventions` practice governs codebase navigation, the REPL development cycle, debugging and testing, test infrastructure, and project-wide development principles. Apply `click` to implement the CLI command. The dispatcher resolves the tool package and forwards captured arguments together with the optional injections the tool entry point declares. + Use the `registering-statuses` practice for the topic-status registration + callback a tool package may expose alongside its entry point. --- diff --git a/goga/commands/topics/.usages/topics-command.md b/goga/commands/topics/.usages/topics-command.md new file mode 100644 index 00000000..ca4fed72 --- /dev/null +++ b/goga/commands/topics/.usages/topics-command.md @@ -0,0 +1,48 @@ +# commands/topics — the topics command group + +Consumer scenarios of the `goga topics` command group. For users who +manage work as topics: boarding, creating, and switching; for the command +facade that registers the group. + +The group scopes every subcommand to one year (--year/-y, default the +current year); the status subcommand reads remote-tracking refs with +--remote/-r. + +## Boarding all work + + goga topics status + goga topics --year 2025 status + goga topics status --remote + +Prints a three-column table — topic, branch, statuses — with column and row +separators fitted to the terminal width. The current branch row carries `*`; +remote hosts keep their remote prefix. A topic's statuses are all its +maximal statuses, wrapped onto continuation lines. An empty board prints +nothing and exits 0. + +## Creating fresh work + + goga topics create Feature/Foo_Bar + goga topics --year 2025 create Feature/Foo_Bar + +Creates the branch with the name as entered, switches to it, and creates +the topic directory of the scoped year. The current branch already hosting +the same slug is an idempotent success. Occupied names and empty slugs +trigger a re-ask on an interactive terminal, or a clean error with a hint +otherwise. + +## Switching to existing work + + goga topics switch history-com + goga topics --year 2025 switch release-1-3-0 + +Resolves the identifier — exact branch name, then exact topic slug, then +prefixes — and switches. Several candidates offer a numbered list with +statuses; without interactive input the command fails with the list. Already +being on the host is an idempotent success. A dirty working tree is a clean +error when a mutation is needed. Switching is always local. + +## Exit codes + +Every subcommand exits 0 on success (an empty board included) and 1 on +error, with the error on stderr and no traceback. diff --git a/goga/commands/topics/CODEMANIFEST b/goga/commands/topics/CODEMANIFEST new file mode 100644 index 00000000..d5d6bba8 --- /dev/null +++ b/goga/commands/topics/CODEMANIFEST @@ -0,0 +1,169 @@ +Imports: + - Types: + - BoardRecord + - collect_topic_board + - switch_topic + - create_topic + Usages: + - topic-board + - switching + - creating + From: goga/topics + +Usages: + convention: .goga/usages/conventions.md + click: .goga/usages/cooks/click.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + Use the `click` practice to build the topics command group: the group + decorator with its own option, the subcommand registration, the flag, + arguments, and options of each subcommand, echo, and exit-code + propagation. + + This cell is the CLI surface of the topics domain: a thin wrapper that + resolves inputs, delegates every computation to the domain routines, and + renders the board. No inventory walking, no switch resolution, no git + access live here. Domain errors surface as clean CLI errors (stderr, + non-zero exit, no traceback). Use relative imports. + +--- + +"topics(year: str | None)": + location: topics.py + annotations: | + The goga topics command group — a click.Group container for the topics + subcommands, exported via __all__ and registered in the root application + group. The group carries the year scope every subcommand shares. + + `year`: the --year/-y group option — exactly one year as four digits; + None means the current year; a search across years does not + exist + + Use the `click` practice for the group decorator, the group option, and + the subcommand registration. + + Subcommand surfaces: + - status — a --remote/-r flag + - create — a NAME positional + - switch — an IDENTIFIER positional + + Apply the `convention` CLI command docstring rule for the --help text + (rendered verbatim by Click; omit Args/Returns/Raises). + methods: + "status(remote: bool = False) -> exit_code: int": | + Subcommand goga topics status: print the board — the cross-branch + topic inventory of the scoped year as a three-column table. + + `remote`: the --remote/-r flag — read remote-tracking refs instead of + local branches + `exit_code`: 0 on success (an empty board included), 1 on error + + Apply the `topic-board` practice for the board contract of the + domain. + Apply the `click` practice for the flag, echo, and the color rules. + + Algorithm: + 1. Collect the board via `collect_topic_board` with the scoped year + and `remote` + 2. Measure the terminal width + 3. Render the table via `render_topic_board` + 4. An empty board renders nothing — exit 0 + + Requirements: + - Read-only — nothing is created, written, or switched + + Constraints: + - Do not print the year, the artifacts, or a header — the table + carries topic, branch, and statuses only + "create(branch_name: str) -> exit_code: int": | + Subcommand goga topics create: create fresh work — a branch with the + name as entered and its topic directory of the scoped year. + + `branch_name`: NAME positional — the branch name as entered + `exit_code`: 0 on success, 1 on error + + Apply the `creating` practice for the creation contract of the + domain. + Apply the `click` practice for exit-code propagation. + + Algorithm: + 1. Delegate to `create_topic` with `branch_name` and the scoped year + 2. Echo the single result line + 3. Propagate the exit code + + Constraints: + - Do not validate the name at the CLI layer — the domain and git own + that + "switch(identifier: str) -> exit_code: int": | + Subcommand goga topics switch: bring the repository onto the branch + hosting the requested work. + + `identifier`: IDENTIFIER positional — a branch name, a topic slug, or + their prefix + `exit_code`: 0 on success, 1 on error + + Apply the `switching` practice for the switching contract of the + domain. + Apply the `click` practice for exit-code propagation. + + Algorithm: + 1. Delegate to `switch_topic` with `identifier` and the scoped year + 2. Echo the single result line + 3. Propagate the exit code + + Constraints: + - Do not launch any pipeline — continuation is a separate command + +"render_topic_board(records: list[BoardRecord], width: int)": + location: render.py + annotations: | + Render the board as a three-column table: topic, branch, statuses. + + `records`: the collected board records — already sorted by the domain + `width`: the measured terminal width in columns + + Apply the `click` practice for echo. + + Algorithm: + 1. Compute the column widths from `width` and the record content per + the width rule of the requirements + 2. Print one header row and one separator row with column and row + dividers + 3. Print each record: the topic truncated with an ellipsis when it + exceeds its column, the branch truncated the same way, and the + statuses wrapped onto continuation lines without affecting the + column widths + 4. Mark the record hosting the current branch with an asterisk; keep + the remote prefix of a remote host visible in the branch column + 5. An empty `records` prints nothing + + Requirements: + - Column widths: topic and branch get an equal share first, statuses + take the remainder — each of topic and branch is capped at one third + of `width` minus the dividers, statuses receives what is left, and + every column keeps a minimum of 8 columns before truncation applies + - The truncation marker is a single ellipsis character + - An overlong status is truncated like the other columns + - The table never exceeds `width`, with one documented exception: when + `width` is below 33, every column keeps its minimum of 8 and the table + may exceed `width` — minimum readability wins over the width cap on + ultra-narrow terminals + + Constraints: + - Read-only on `records` — do not mutate, do not re-sort, do not filter + - Do not print the year or the artifacts + +--- + +Author: Goga +CreatedAt: 29/08/26 +Description: | + The goga topics command group with the status, create, and switch + subcommands over the topics domain. diff --git a/goga/history/.usages/registering-statuses.md b/goga/history/.usages/registering-statuses.md new file mode 100644 index 00000000..c3b8e3e8 --- /dev/null +++ b/goga/history/.usages/registering-statuses.md @@ -0,0 +1,38 @@ +# history — registering topic statuses + +How a `goga_tool_*` package attaches its own statuses to the topic status +scale. For tool package authors; no goga code changes are needed. + +goga calls `register_topic_statuses(statuses)` in your package at every +command start that computes a topic status. The `statuses` object is a +controlled registration surface scoped to your package: every name you +register is stored qualified with your tool prefix, so registrations from +different tools never collide and a topic can carry several statuses at +once. + +## The callback + +```python +# inside the goga_tool_<tool> package +def register_topic_statuses(statuses): + statuses.register("published", "mkdocs/published.md", after="planned") +``` + +- `name` — the status name as your tool defines it; shown as + `<tool>.<name>`. +- `filepath` — the artifact path relative to the topic directory; nested + paths are allowed. +- `before` / `after` — anchors: qualified names of statuses this one + precedes or follows, controlling where it sits in the scale. At least one + anchor is required; both given define a placement range. + +## Rules and failure behavior + +- The built-in statuses are immutable — registration is add-only. +- A registration missing an anchor, carrying empty values, an unresolvable + anchor, or an invalid range is skipped with a stderr warning; it never + aborts the command and never cancels other registrations. +- Two tools may reference the same artifact path — both statuses apply + independently. +- A package import failure is the only fatal case: a clean error naming the + package. diff --git a/goga/history/.usages/topic-paths.md b/goga/history/.usages/topic-paths.md index 72afdaea..cddfb175 100644 --- a/goga/history/.usages/topic-paths.md +++ b/goga/history/.usages/topic-paths.md @@ -58,9 +58,12 @@ from goga.history import ensure_topic_dir topic_dir = ensure_topic_dir("Feature/Foo_Bar") # -> .goga/history/2026/feature-foo-bar (now existing) + +topic_dir = ensure_topic_dir("Feature/Foo_Bar", year="2025") +# -> .goga/history/2025/feature-foo-bar (now existing) ``` -- Always for the current year. +- The year defaults to the current year (four digits, local time). - Idempotent: an existing topic directory is a success, not a conflict. Decide occupancy *before* creating (via `topic_exists`) when the distinction matters. @@ -94,3 +97,16 @@ topic_dir = resolve_topic_dir(branch) - Returns the raw branch name, or None when it cannot be determined; the error policy belongs to the caller. + +## Reading the history root + +```python +from goga.history import resolve_history_root + +root = resolve_history_root() # .goga/history/ +``` + +- The single source of the tree location — the prefix for reading topic + trees out of git refs. +- Pure: nothing is created. + diff --git a/goga/history/.usages/topic-statuses.md b/goga/history/.usages/topic-statuses.md index 25a2d2b1..c3dc22af 100644 --- a/goga/history/.usages/topic-statuses.md +++ b/goga/history/.usages/topic-statuses.md @@ -1,56 +1,61 @@ # history — topic statuses -How to read the status of history topics with the `goga.history` facade. For -consumers that report progress: CLI status output, reviews, dashboards. - -A topic's status is the process stage reached by its deepest present -artifact: - -| Status | Deepest artifact present | -|---|---| -| empty | none | -| defined | prd.md | -| discovered | adr.md | -| backlog | task.md | -| designed | arch.md | -| specified | design.md | -| planned | plan.md | -| done | completed/plan.md | +How to read the statuses of history topics with the `goga.history` facade. +For consumers that report progress: CLI status output, boards, reviews, +dashboards. + +A topic's status is the set of its maximal present statuses on the topic +status scale. The built-in axis is fixed — empty, defined, discovered, +backlog, designed, specified, planned, done, marked by the artifacts prd.md, +adr.md, task.md, arch.md, design.md, plan.md, completed/plan.md inside the +topic directory. Tool packages extend the scale with qualified statuses +`<tool>.<name>`, so one topic can carry several statuses at once — all of +them are shown. ## Listing a year with statuses ```python -from goga.history import collect_topic_statuses +from goga.history import assemble_status_scale, collect_topic_statuses -records = collect_topic_statuses() # current year -records = collect_topic_statuses(year="2025") # explicit year +records = collect_topic_statuses() # current year, scale assembled here +scale = assemble_status_scale() +records = collect_topic_statuses(year="2025", scale=scale) # reuse one scale for record in records: - print(record.topic, record.status.value) + print(record.topic, " ".join(f"[{s}]" for s in record.statuses)) ``` -- One `TopicRecord` per topic, sorted alphabetically by topic. -- An absent year or a year without topics yields an empty list — not an error. -- Filtering (by status name or topic substring) belongs to the consumer: the - facade returns the full year. +- One `TopicRecord` per topic, sorted alphabetically by topic; the record + carries every maximal status name in scale order. +- Pass an assembled `scale` to reuse one assembly across calls; None + assembles it once inside. +- An absent year or a year without topics yields an empty list — not an + error. -## Resolving one topic's status +## Resolving one topic's statuses ```python -from goga.history import resolve_topic_dir, resolve_topic_status - -status = resolve_topic_status(resolve_topic_dir("history-commands")) +from goga.history import ( + assemble_status_scale, + resolve_topic_dir, + resolve_topic_status, +) + +scale = assemble_status_scale() +statuses = resolve_topic_status(resolve_topic_dir("history-commands"), scale) ``` -- `completed/plan.md` wins over every flat artifact when present. +- Nested artifact paths are honored (completed/plan.md counts). - Read-only. ## Validating status names ```python -from goga.history import TopicStatus +from goga.history import assemble_status_scale -TopicStatus("planned") # -> TopicStatus.planned; ValueError for unknown names +scale = assemble_status_scale() +stage = scale.resolve_status("mkdocs.published") # unknown name -> clean error ``` - Use this to validate user-supplied status filters before matching records: - the member set is fixed, and `record.status.value` carries the display name. + the member set is the assembled scale, and `stage.name` carries the + display name. diff --git a/goga/history/CODEMANIFEST b/goga/history/CODEMANIFEST index 6a58fe87..18e0dfcd 100644 --- a/goga/history/CODEMANIFEST +++ b/goga/history/CODEMANIFEST @@ -2,6 +2,12 @@ Imports: - Types: - resolve_current_branch_name From: goga/history/git + - Types: + - StatusScale + - Stage + - StatusRegistry + - assemble_status_scale + From: goga/history/statuses Usages: convention: .goga/usages/conventions.md @@ -14,20 +20,25 @@ Annotations: | - Organizing the test infrastructure - Understanding the general principles and rules of development and testing in the project - This cell is the single owner of the .goga/history/ tree: topic identity (the - slug grammar and the current year), topic addressing (directory and artifact - file paths, existence, creation), the topic status model, and tree traversal. - Artifact files are written by their producers — this cell computes paths and - creates topic directories only; it never writes artifact content. Pure - filesystem and grammar logic: no git access (the branch reader lives in - goga/history/git and is re-exported on this facade), no CLI, no output - rendering. Every topic value received on the input is normalized — a branch - name and an already-normalized slug are both accepted, identically and - idempotently. Use relative imports. + This cell is the single owner of the .goga/history/ tree: topic identity + (the slug grammar and the current year), topic addressing (the tree root, + directory and artifact file paths, existence, creation), the topic status + listing, and tree traversal. The status scale is provided by the statuses + subcell and re-exported on this facade. Artifact files are written by + their producers — this cell computes paths and creates topic directories + only; it never writes artifact content. Pure filesystem and grammar + logic: no git access beyond the branch reader re-export, no CLI, no + output rendering. Every topic value received on the input is normalized — + a branch name and an already-normalized slug are both accepted, + identically and idempotently. Use relative imports. --- ->resolve_current_branch_name: {} +->StatusScale: {} +->Stage: {} +->StatusRegistry: {} +->assemble_status_scale: {} "normalize_topic_slug(name: str) -> slug: str": location: naming.py @@ -80,6 +91,28 @@ Annotations: | - Do not accept a timezone or an override value — callers needing a different year pass it explicitly to the path routines +"resolve_history_root() -> root: Path": + location: paths.py + annotations: | + Compute the root path of the history tree. + + `root`: the history tree path .goga/history/ + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Compose .goga/history/ at the caller's working directory and return + it + + Requirements: + - The single source of the tree location for every consumer reading + the topic tree of a git ref + - Pure with respect to the filesystem — nothing is created or checked + + Constraints: + - Do not create the directory — creation belongs to `ensure_topic_dir` + "resolve_topic_dir(topic: str, year: str | None = None) -> topic_dir: Path": location: paths.py annotations: | @@ -158,19 +191,20 @@ Annotations: | Constraints: - Do not raise on an absent history root — a missing tree is simply "no" -"ensure_topic_dir(name: str) -> topic_dir: Path": +"ensure_topic_dir(name: str, year: str | None = None) -> topic_dir: Path": location: paths.py annotations: | - Create the directory of a history topic for the current year. + Create the directory of a history topic of a year. `name`: topic input — a branch name or an already-normalized slug + `year`: optional year as four digits; None means the current year `topic_dir`: the topic directory path that now exists Apply the `convention` practice for docstring style and intra-package imports. Algorithm: - 1. Compose the topic directory via `resolve_topic_dir` with `name` and the - current year + 1. Compose the topic directory via `resolve_topic_dir` with `name` and + `year` 2. Create the directory including missing parents 3. Return the path @@ -183,100 +217,76 @@ Annotations: | belongs to the caller - Do not create or touch artifact files inside the directory -"TopicStatus()": +"TopicRecord(topic: str, statuses: list[str])": location: status.py annotations: | - Fixed value set of a history topic status. Each member names the process - stage reached by the topic's deepest present artifact. - - Apply the `convention` practice for the value-set implementation and - intra-package imports. - - Requirements: - - Members carry their display names verbatim — consumers filter and - render by them - - Constraints: - - Fixed value set (implement as enum.Enum); no derived or combined members - properties: - "empty -> str": | - "empty" — no artifact is present in the topic directory. - "defined -> str": | - "defined" — prd.md is the deepest present artifact. - "discovered -> str": | - "discovered" — adr.md is the deepest present artifact. - "backlog -> str": | - "backlog" — task.md is the deepest present artifact. - "designed -> str": | - "designed" — arch.md is the deepest present artifact. - "specified -> str": | - "specified" — design.md is the deepest present artifact. - "planned -> str": | - "planned" — plan.md is the deepest present artifact. - "done -> str": | - "done" — completed/plan.md is the deepest present artifact. - -"TopicRecord(topic: str, status: TopicStatus)": - location: status.py - annotations: | - One topic of a year paired with its resolved status — a single record of - the status listing. + One topic of a year paired with its maximal present statuses — a single + record of the status listing. `topic`: the topic slug - `status`: the topic status + `statuses`: the qualified names of the maximal present statuses, in + scale order - Apply the `convention` practice for the data-model rules and intra-package - imports. + Apply the `convention` practice for the data-model rules and + intra-package imports. properties: "topic -> str": | The topic slug — the directory name of the topic. - "status -> TopicStatus": | - The status resolved for the topic. + "statuses -> list[str]": | + The maximal present status names of the topic, in scale order. -"resolve_topic_status(topic_dir: Path) -> status: TopicStatus": +"resolve_topic_status(topic_dir: Path, scale: StatusScale) -> statuses: list[str]": location: status.py annotations: | - Resolve the status of one topic from its directory content. + Resolve the maximal present statuses of one topic from its directory + content. `topic_dir`: the topic directory path - `status`: the status of the topic + `scale`: the assembled status scale + `statuses`: the qualified names of the maximal present statuses, in + scale order - Apply the `convention` practice for docstring style and intra-package imports. + Apply the `convention` practice for docstring style and intra-package + imports. Algorithm: - 1. Probe the artifacts of the progression in deepening order: prd.md, - adr.md, task.md, arch.md, design.md, plan.md, completed/plan.md - 2. Return the member mapped to the deepest artifact present as a file - 3. No artifact present -> empty + 1. List the artifact paths present in the directory, relative to it + 2. Compute the maximal present statuses via `scale` + 3. No artifact present yields the single built-in name empty Requirements: - - completed/plan.md is the deepest artifact — its presence wins over every - flat artifact - - Read-only — the directory content is probed, never changed + - Read-only — the directory content is read, never changed + - Nested artifact paths are honored — a status artifact may sit in a + subdirectory of the topic directory Constraints: - - Do not invent intermediate statuses — the member set is fixed - - Do not consider files outside the progression + - Do not assemble the scale here — the caller owns the single assembly + per command run + - Do not consider files outside the scale -"collect_topic_statuses(year: str | None = None) -> records: list[TopicRecord]": +"collect_topic_statuses(year: str | None = None, scale: StatusScale | None = None) -> records: list[TopicRecord]": location: status.py annotations: | - Collect every topic of one year with its resolved status. + Collect every topic of one year with its maximal present statuses. `year`: optional year as four digits; None means the current year + `scale`: optional assembled status scale; None assembles it once here `records`: one `TopicRecord` per topic, sorted alphabetically by topic - Apply the `convention` practice for docstring style and intra-package imports. + Apply the `convention` practice for docstring style and intra-package + imports. Algorithm: - 1. Resolve the year — `year` when given, otherwise `current_year` - 2. List the topic directories of that year; an absent year yields no records - 3. Resolve the status of each topic via `resolve_topic_status` - 4. Assemble the records sorted alphabetically by topic and return them + 1. Resolve the year — `year` when given, otherwise the current year + 2. Resolve the scale — `scale` when given, otherwise assemble it once + 3. List the topic directories of that year; an absent year yields no + records + 4. Resolve the statuses of each topic via `resolve_topic_status` + 5. Assemble the records sorted alphabetically by topic and return them Requirements: - - Only directories count as topics — stray files in the year directory are - ignored + - Only directories count as topics — stray files in the year directory + are ignored - An absent year yields an empty list — not an error Constraints: @@ -331,5 +341,6 @@ Annotations: | Author: Goga CreatedAt: 28/08/26 Description: | - Owner of the .goga/history/ tree — topic identity, addressing, statuses, and - traversal. Re-exports the git branch reader on its facade. + Owner of the .goga/history/ tree — topic identity, addressing, status + listing, and traversal. Re-exports the branch reader and the status scale + on its facade. diff --git a/goga/history/statuses/CODEMANIFEST b/goga/history/statuses/CODEMANIFEST new file mode 100644 index 00000000..50ea5942 --- /dev/null +++ b/goga/history/statuses/CODEMANIFEST @@ -0,0 +1,226 @@ +Usages: + convention: .goga/usages/conventions.md + registration: | + The tool-package registration contract. An installed goga_tool_* package + may expose a module-level register_topic_statuses(statuses) callable; + when present, goga calls it at every scale assembly with a + StatusRegistry scoped to the package. The callable registers tool + statuses via statuses.register(name, filepath, before=..., after=...); + names are stored qualified <tool>.<name>. A package without the + callable is a normal condition, not an error. + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + Use the `registration` practice for the tool-package registration + contract behind the scale assembly. + + This cell owns the topic status scale: the built-in artifact axis, the + registration of tool statuses over installed goga_tool_* packages, and the + computation of a topic's maximal present statuses. Pure scale logic — no + filesystem probing of topic directories, no git access, no CLI, no output + rendering. The built-in axis is immutable; tool extensions are add-only. + Package traversal is deterministic — installed goga_tool_* packages in + alphabetical order of top-level module name. Use relative imports. + +--- + +"StatusScale(stages: list[Stage])": + location: scale.py + annotations: | + The assembled partially ordered scale of topic statuses — the single + source of scale order and maximal-status computation. + + `stages`: the scale content in scale order + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The built-in axis is ordered empty, defined, discovered, backlog, + designed, specified, planned, done by the artifacts prd.md, adr.md, + task.md, arch.md, design.md, plan.md, completed/plan.md + - A tool status never reorders or replaces a built-in one + properties: + "stages -> list[Stage]": | + The scale content in scale order. + methods: + "maximal_present(paths: list[str]) -> statuses: list[str]": | + Compute the maximal present statuses of one topic. + + `paths`: the artifact paths present in a topic directory, relative to + it + `statuses`: the qualified names of the maximal present statuses, in + scale order + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Mark every scale entry whose artifact path is present + 2. Drop every marked entry strictly below another marked entry + 3. Return the names of the surviving entries in scale order + + Requirements: + - Every maximal entry is returned — a present entry outranked by no + other present entry stays visible + - A topic with no artifact present yields the single built-in name + empty + + Constraints: + - Do not probe the filesystem — presence is decided by the caller's + `paths` input alone + "resolve_status(name: str) -> stage: Stage": | + Resolve a qualified status name for filter validation. + + `name`: a status name as entered by a consumer — a built-in name or a + qualified tool name + `stage`: the scale entry carrying the name + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Find the scale entry whose qualified name equals `name` exactly + 2. An unknown name raises a clean error + + Constraints: + - Do not fuzzy-match or fall back — the exact qualified name is the + contract + +"Stage(name: str, filepath: str, before: str | None = None, after: str | None = None)": + location: scale.py + annotations: | + One entry of the scale — a named position anchored to the artifact that + marks it. + + `name`: the qualified name — bare for built-in entries, <tool>.<name> + for tool entries + `filepath`: the artifact path relative to the topic directory; nested + paths allowed + `before`: the qualified name of the entry this one precedes; None for + built-in entries + `after`: the qualified name of the entry this one follows; None for + built-in entries + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - A tool entry carries at least one anchor; a built-in entry carries + none — its position is the axis order + properties: + "name -> str": | + The qualified status name. + "filepath -> str": | + The artifact path relative to the topic directory. + "before -> str | None": | + The qualified name of the entry this one precedes, or None. + "after -> str | None": | + The qualified name of the entry this one follows, or None. + +"StatusRegistry(builtin_stages: list[Stage], tool_prefix: str)": + location: registry.py + annotations: | + The controlled registration surface handed to a tool package — the only + way a tool status enters the scale. + + `builtin_stages`: the immutable built-in axis the registry extends + `tool_prefix`: the qualifier applied to every name registered through + this registry — derived from the package name + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Registration is add-only — a built-in entry is never modified, + removed, or re-anchored + properties: + "stages -> list[Stage]": | + The built-in axis plus every accepted tool entry. + methods: + "register(name: str, filepath: str, before: str | None = None, after: str | None = None)": | + Register one tool status. + + `name`: the status name as the tool defines it — stored qualified as + <tool_prefix>.<name> + `filepath`: the artifact path relative to the topic directory + `before`: optional anchor — the qualified name of an entry this one + precedes + `after`: optional anchor — the qualified name of an entry this one + follows + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Qualify `name` with the registry's tool prefix + 2. Validate the content: a non-empty name, a non-empty `filepath`, + and at least one anchor present + 3. Append the entry to the registry content + + Requirements: + - A structural violation raises a clean registration error naming the + entry + - Both anchors given define a placement range; anchor resolution and + range validity are decided at scale assembly + + Constraints: + - Do not resolve anchors here — a tool may anchor to an entry + registered by another tool + - Do not modify built-in entries + +"assemble_status_scale() -> scale: StatusScale": + location: assembly.py + annotations: | + Assemble the full status scale — the built-in axis extended by every + installed tool package. + + `scale`: the assembled scale + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Build the built-in axis of eight entries + 2. Enumerate the installed goga_tool_* packages in alphabetical order + of package name + 3. Import each package — a broken import is a clean error naming the + package + 4. A package without the callback of the `registration` practice is + skipped silently + 5. Call the callback of the `registration` practice with a registry + scoped to the package + 6. Any exception from the callback — a registration content error or a + crashed callback — skips that registration with a warning to stderr; + the package import failure of step 3 remains the only fatal case + 7. Resolve anchors and validate placement ranges; an unresolvable + anchor or an invalid range skips the registration with a warning to + stderr + 8. Assemble and return the scale + + Requirements: + - The assembly runs at every command start that needs the scale — + before any output and before any mutation + - The scale assembles from the surviving registrations alone — one + broken registration never cancels the rest + - Package enumeration mirrors goga/connect: importlib.metadata + .packages_distributions() filtered to top-level module names starting + with goga_tool_, sorted alphabetically by top-level module name + + Constraints: + - Do not cache the scale across command runs + - Do not let a registration problem abort the command + +--- + +Author: Goga +CreatedAt: 29/08/26 +Description: | + Owner of the topic status scale — the built-in artifact axis, tool-status + registration, and maximal-status computation. diff --git a/goga/topics/.usages/creating.md b/goga/topics/.usages/creating.md new file mode 100644 index 00000000..5c7fbb20 --- /dev/null +++ b/goga/topics/.usages/creating.md @@ -0,0 +1,30 @@ +# topics — creating fresh work + +How to create a new branch with its topic directory using the `goga.topics` +facade. For consumers that start new work: the topics create command. + +`create_topic` takes the branch name as entered. The branch keeps the name +verbatim; the topic directory takes the normalized slug of the year — the +two may deliberately differ (Feature/Foo_Bar branches into the +feature-foo-bar topic). + +## Creating + +```python +from goga.topics import create_topic + +result = create_topic("Feature/Foo_Bar") # current year +result = create_topic("Feature/Foo_Bar", year="2025") +print(result) # one line describing what was created +``` + +- A free name creates the branch, switches to it, and creates the topic + directory of the year. +- The current branch already hosting the same slug is an idempotent + success — no mutation. +- An occupied name or an empty slug triggers a re-ask on an interactive + terminal, or a clean error with the reason and a hint otherwise. +- Occupancy oracles: a local branch ref, a remote-tracking ref, and the + topic directory of the year — exposed as `check_branch_occupancy`. +- No artifact files are written inside the topic directory — artifacts + belong to their producers. diff --git a/goga/topics/.usages/switching.md b/goga/topics/.usages/switching.md new file mode 100644 index 00000000..91e41b45 --- /dev/null +++ b/goga/topics/.usages/switching.md @@ -0,0 +1,43 @@ +# topics — switching and continuation + +How to move the repository onto existing work with the `goga.topics` +facade. For consumers that resume work: the topics switch command, the +pipeline run form. + +`switch_topic` resolves the identifier, chooses among candidates, and +performs the switch. Resolution tries three tiers in order — exact branch +name, then exact topic slug (a local branch beats its remote twin), then +prefix matches on branch names and slugs — and takes the first non-empty +tier: an exact match excludes prefix candidates. A branch without a topic is +a valid target. + +## Switching + +```python +from goga.topics import switch_topic + +result = switch_topic("history-com") # prefix match, one candidate +print(result) # one line — the outcome +``` + +- Zero candidates -> a clean error with a hint to the board. +- Several candidates -> a numbered list with statuses and a number prompt; + without interactive input the call fails with the list. +- Already on the hosting branch -> idempotent success, no mutation. +- A dirty working tree is a clean error — probed only when a mutation is + needed. +- Mutations are local-only: checkout of a local branch, or creation of a + local branch from a remote-tracking ref (no network). + +## Resolving candidates without switching + +```python +from goga.topics import resolve_switch_candidates + +candidates = resolve_switch_candidates("release-1-3-0", year="2026") +for candidate in candidates: + print(candidate.branch, candidate.topic, candidate.statuses) +``` + +- Read-only; build custom selection UIs on top of the same resolution + order. diff --git a/goga/topics/.usages/topic-board.md b/goga/topics/.usages/topic-board.md new file mode 100644 index 00000000..b5566615 --- /dev/null +++ b/goga/topics/.usages/topic-board.md @@ -0,0 +1,31 @@ +# topics — the topic board + +How to collect the cross-branch topic inventory with the `goga.topics` +facade. For consumers that show all work of a repository: CLI boards, +reviews, overviews. + +The board sees one year at a time. Local mode reads the full branch inventory +and the current branch from the working copy — uncommitted progress is +visible, and a local branch absorbs its remote twin into one row. Remote mode +lists remote-tracking refs instead; the current branch +shows through its remote twin. No checkout happens: every ref is read +through git plumbing, so the working copy and .git stay untouched. + +## Collecting the board + +```python +from goga.topics import collect_topic_board + +records = collect_topic_board() # current year, local +records = collect_topic_board(year="2025", remote=True) # remote-tracking refs +for record in records: + print(record.topic, record.branch, record.statuses, record.current) +``` + +- One `BoardRecord` per hosted topic: the slug, the hosting branch display + name, the maximal status names in scale order, and the current marker. +- A local branch and its remote twin collapse to one row — the local branch + wins. Two different branches hosting one slug stay two rows. +- Sorting: scale order of the first maximal status, then topic alphabet. +- A year without topics yields an empty list — not an error. +- Strictly read-only. diff --git a/goga/topics/CODEMANIFEST b/goga/topics/CODEMANIFEST new file mode 100644 index 00000000..38890bd6 --- /dev/null +++ b/goga/topics/CODEMANIFEST @@ -0,0 +1,328 @@ +Imports: + - Types: + - normalize_topic_slug + - resolve_current_branch_name + - topic_exists + - ensure_topic_dir + - resolve_history_root + - resolve_topic_status + - StatusScale + - assemble_status_scale + Usages: + - topic-paths + - topic-statuses + From: goga/history + - Types: + - BranchRef + - list_branch_refs + - read_ref_tree_paths + - checkout_local_branch + - create_branch_from_remote_tracking + - create_and_switch_branch + - is_working_tree_clean + Usages: + - refs-and-switching + From: goga/topics/git + +Usages: + convention: .goga/usages/conventions.md + click: .goga/usages/cooks/click.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + Use the `click` practice for the interactive moments of switching and + creation: click.prompt for the numbered candidate selection and the re-ask + cycle, and the non-interactive detection with its clean error. + + Use the `topic-paths` practice for the consumer patterns of the history + facade — the topic slug, the topic directory of a year, the existence + oracle, and the current branch. + Use the `topic-statuses` practice for the status scale patterns of the + history facade — scale assembly and maximal-status computation. + Use the `refs-and-switching` practice for the git patterns of the topics + git cell — the branch inventory, ref tree reading, and the bounded switch + mutations. + + This cell owns the topics domain — the work-tracker view of the history + tree: the cross-branch topic inventory of one year with per-topic + statuses, the switch-identifier resolution and switching orchestration, + and the fresh-work creation procedure. Topic identity, addressing, and + statuses belong to the history facade; git access belongs to the topics + git cell. Mutations are local-only and happen strictly after every + decision is made. Use relative imports. + +--- + +"BoardRecord(topic: str, branch: str, statuses: list[str], current: bool, remote: bool)": + location: board.py + annotations: | + One row of the topic board — a topic hosted by one branch. + + `topic`: the topic slug + `branch`: the display name of the hosting branch + `statuses`: the qualified names of the maximal present statuses, in + scale order + `current`: True when the row hosts the current working branch + `remote`: True when the hosting ref is remote-tracking + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "topic -> str": | + The topic slug — the directory name of the topic. + "branch -> str": | + The display name of the hosting branch. + "statuses -> list[str]": | + The maximal present status names of the topic, in scale order. + "current -> bool": | + True when the row hosts the current working branch. + "remote -> bool": | + True when the hosting ref is remote-tracking. + +"collect_topic_board(year: str | None = None, remote: bool = False) -> records: list[BoardRecord]": + location: board.py + annotations: | + Collect the cross-branch topic inventory of one year — every topic with + its hosting branch and statuses. + + `year`: optional year as four digits; None means the current year + `remote`: True reads remote-tracking refs instead of local branches + `records`: one `BoardRecord` per hosted topic, sorted by scale order of + the first maximal status, then alphabetically by topic + + Apply the `topic-paths` practice for the year and tree-root patterns. + Apply the `topic-statuses` practice for the scale assembly and + maximal-status computation. + Apply the `refs-and-switching` practice for the inventory and + tree-reading patterns. + + Algorithm: + 1. Resolve the year — `year` when given, otherwise the current year + 2. Assemble the status scale via `assemble_status_scale` once + 3. Local mode enumerates local branches via `list_branch_refs` and reads + the current branch from the working copy via + `resolve_current_branch_name`; remote mode enumerates the + remote-tracking `BranchRef` entries of the same inventory only. + Local mode takes the full inventory of `list_branch_refs` — a topic + hosted only by a remote-tracking ref keeps its row with the remote + marker + 4. Read the topic tree of every ref under the root resolved via + `resolve_history_root` with `read_ref_tree_paths`, without checkout + 5. For every ref, take the topics of the resolved year with their + artifact paths and compute the maximal statuses — the working copy + via `resolve_topic_status`, every other ref via the `StatusScale` + 6. Collapse a local branch and its remote twin into one row — the local + branch wins; different branches hosting one slug stay separate rows + 7. Mark the row hosting the current branch + 8. Sort by scale order of the first maximal status, then alphabetically + by topic, and return the records + + Requirements: + - The current branch is read from the working copy — uncommitted + progress is visible; remote mode shows it through its remote twin + - Read-only — no checkout, no worktree, no mutation of any kind + - A year without topics yields an empty list — not an error + + Constraints: + - Do not render — output shaping belongs to the consumer + - Do not cross the year boundary — other years are invisible here + +"SwitchCandidate(branch: str, topic: str | None, statuses: list[str], current: bool, remote: bool)": + location: switching.py + annotations: | + One candidate of a switch-identifier resolution — a branch that may + host the requested work. + + `branch`: the display name of the candidate branch + `topic`: the topic slug the branch hosts, or None for a branch without + a topic + `statuses`: the qualified names of the maximal present statuses, in + scale order + `current`: True for the current branch + `remote`: True when the candidate ref is remote-tracking + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "branch -> str": | + The display name of the candidate branch. + "topic -> str | None": | + The topic slug the branch hosts, or None for a branch without a + topic. + "statuses -> list[str]": | + The maximal present status names of the candidate's topic, in scale + order — empty for a branch without a topic. + "current -> bool": | + True for the current branch. + "remote -> bool": | + True when the candidate ref is remote-tracking. + +"resolve_switch_candidates(identifier: str, year: str | None = None) -> candidates: list[SwitchCandidate]": + location: switching.py + annotations: | + Resolve a switch identifier into its candidate branches. + + `identifier`: the user input — a branch name, a topic slug, or their + prefix + `year`: optional year as four digits; None means the current year + `candidates`: the matching candidates, exact matches first, then prefix + matches + + Apply the `topic-paths` practice for the slug normalization and current + branch patterns. + Apply the `refs-and-switching` practice for the inventory and + tree-reading patterns. + + Algorithm: + 1. Normalize `identifier` into a slug via `normalize_topic_slug` + 2. Collect the branch inventory and the topics of the resolved year + 3. Exact branch name match -> the candidates hosting that name + 4. Exact slug match otherwise -> the branches hosting the slug, local + branches first + 5. Prefix matches otherwise -> the branches whose name or hosted slug + starts with the input + 6. Return the candidates with their statuses + + Requirements: + - Exact matches always precede prefix matches + - A branch without a topic is a valid candidate + - Read-only — no mutation before a choice + + Constraints: + - Do not choose among multiple candidates — selection belongs to the + caller + +"switch_topic(identifier: str, year: str | None = None) -> result: str": + location: switching.py + annotations: | + Bring the repository onto the branch hosting the requested work. + + `identifier`: the user input — a branch name, a topic slug, or their + prefix + `year`: optional year as four digits; None means the current year + `result`: one line describing the outcome + + Apply the `click` practice for the numbered selection prompt and the + non-interactive detection. + Apply the `refs-and-switching` practice for the checkout and + remote-tracking branch patterns. + + Algorithm: + 1. Resolve the candidates via `resolve_switch_candidates` + 2. No candidate -> clean error with a hint to the board + 3. One candidate -> take it; several -> print the numbered list with + statuses and prompt for a number, or fail with the list when no + interactive input is available + 4. Already on the hosting branch -> idempotent success, no mutation, no + cleanliness probe + 5. A mutation is needed -> probe the working tree cleanliness first via + `is_working_tree_clean`; a dirty tree is a clean error naming the + reason and the next step — commit or stash the working copy before + switching + 6. Local host -> check out the branch via `checkout_local_branch`; + remote-only host -> create the local branch from the remote-tracking + ref via `create_branch_from_remote_tracking` + 7. Return the single result line + + Requirements: + - Every mutation is local — no network, no fetch, no push + - Nothing is mutated before the candidate choice is complete + - The result is exactly one line + + Constraints: + - Do not manage the stages of the hosting pipeline — continuation + belongs to the pipeline itself + - Do not return to the previous branch — the switch is the outcome + +"create_topic(branch_name: str, year: str | None = None) -> result: str": + location: creation.py + annotations: | + Create fresh work — a branch with the name as entered and its topic + directory of the year. + + `branch_name`: the branch name as entered by the user + `year`: optional year as four digits; None means the current year + `result`: one line describing the outcome + + Apply the `click` practice for the re-ask prompt and the non-interactive + detection. + Apply the `topic-paths` practice for the slug, existence, and directory + creation patterns. + Apply the `refs-and-switching` practice for the create-and-switch + pattern. + + Algorithm: + 1. Normalize `branch_name` into a slug via `normalize_topic_slug` + 2. Empty slug -> input error: print the reason, prompt for a new name + on an interactive terminal and restart, or fail with the hint + otherwise + 3. The current branch — read via `resolve_current_branch_name` — hosts + the same slug -> idempotent success, no mutation, no occupancy + check + 4. `check_branch_occupancy` reports a conflict -> print the reason with + a hint to the board, prompt for a new name on an interactive terminal + and restart, or fail otherwise + 5. Free name -> create the branch named exactly as entered and switch + to it via `create_and_switch_branch`, and create the topic directory + via `ensure_topic_dir` of the year + 6. Return the single result line + + Requirements: + - The branch keeps the name as entered; the topic directory takes the + slug — the two may deliberately differ + - An aborted re-ask leaves the repository untouched + - The caller stays on the new branch + + Constraints: + - Do not validate branch-name characters — git owns name validity + - Do not auto-pick suffixed names on a conflict — the user re-asks or + aborts + - Do not write artifact files inside the topic directory + +"check_branch_occupancy(branch_name: str, slug: str, year: str | None = None) -> conflict: str | None": + location: creation.py + annotations: | + Decide whether the entered branch name and the topic slug are free. + + `branch_name`: branch name as entered — checked against the branch + inventory + `slug`: normalized topic slug — checked against the topic directory of + the year + `year`: optional year as four digits; None means the current year + `conflict`: human-readable reason of the first occupied oracle, or None + when everything is free + + Apply the `topic-paths` practice for the topic-existence oracle + contract. + Apply the `refs-and-switching` practice for the inventory pattern. + + Algorithm: + 1. A local `BranchRef` named `branch_name` exists in the inventory of + `list_branch_refs` -> return the reason + 2. A remote-tracking `BranchRef` whose short name (the part after the + first "/") equals `branch_name` exists -> return the reason + 3. The topic directory of `slug` in the year exists via `topic_exists` + -> return the reason + 4. All three oracles are free -> None + + Requirements: + - The first occupied oracle wins; remaining oracles are not probed + - Read-only — no ref or directory is created + + Constraints: + - Do not resolve remote state over the network — the local inventory + only + +--- + +Author: Goga +CreatedAt: 29/08/26 +Description: | + The topics domain — the cross-branch topic inventory, switch resolution + and orchestration, and fresh-work creation. diff --git a/goga/topics/git/.usages/refs-and-switching.md b/goga/topics/git/.usages/refs-and-switching.md new file mode 100644 index 00000000..a7ca2133 --- /dev/null +++ b/goga/topics/git/.usages/refs-and-switching.md @@ -0,0 +1,60 @@ +# topics/git — refs and branch switching + +How to enumerate branch refs, read ref trees, and switch branches with the +`goga.topics.git` facade. For consumers building inventories and switching +work: the topics domain, higher-level orchestration. + +The cell is pure git access — read-only inventory plus the bounded local +mutation set. Every policy decision (when to check cleanliness, which branch +wins a duplicate, what a conflict means) belongs to the caller. + +## Enumerating branch refs + +```python +from goga.topics.git import list_branch_refs + +refs = list_branch_refs() +for ref in refs: + print(ref.name, "remote" if ref.remote else "local") +``` + +- Local branches and remote-tracking refs come back in one list, sorted by + display name; a local branch and its remote twin are two distinct refs. +- Read-only, no network. + +## Reading a ref tree + +```python +from goga.history import resolve_history_root +from goga.topics.git import read_ref_tree_paths + +prefix = f"{resolve_history_root()}/" +paths = read_ref_tree_paths("feature-foo", prefix) +``` + +- Paths come back relative to the repository root; no checkout, no + worktree, no temp directory — the working copy stays untouched. +- One git invocation per ref; a ref or prefix without matches yields an + empty list. + +## Switching branches + +```python +from goga.topics.git import ( + checkout_local_branch, + create_branch_from_remote_tracking, + is_working_tree_clean, +) + +if is_working_tree_clean(): + checkout_local_branch("feature-foo") # existing local branch + create_branch_from_remote_tracking(remote_ref) # remote-only host +``` + +- `checkout_local_branch` takes a short local branch name; the branch must + exist. +- `create_branch_from_remote_tracking` takes a remote `BranchRef` obtained + from `list_branch_refs` and creates a local branch with its short name at + the ref's commit — no network. +- Probe cleanliness with `is_working_tree_clean` before any mutation — the + cell never decides on its own. diff --git a/goga/topics/git/CODEMANIFEST b/goga/topics/git/CODEMANIFEST new file mode 100644 index 00000000..f9ef8774 --- /dev/null +++ b/goga/topics/git/CODEMANIFEST @@ -0,0 +1,206 @@ +Usages: + convention: .goga/usages/conventions.md + git: | + External git binary invoked via subprocess.run (check=True, capture_output=True). + Set GIT_TERMINAL_PROMPT=0 in the env to suppress interactive prompts. + Read-only inspection (branch refs, ref trees, working tree state) plus + host-side mutations (checkout of a local branch, creating a local branch + from a remote-tracking ref, create-and-switch to a new branch). Mock the + subprocess call in tests per `convention`. + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns git access for the topics domain: enumerating branch refs, + reading the file paths of a ref tree, and the bounded set of host-side + branch mutations — checking out a local branch, creating a local branch + from a remote-tracking ref, creating and switching to a new branch, and + the working-tree cleanliness probe. It is environment access, not topic + logic — every decision belongs to the caller. All git access flows through + the `git` practice; mock the subprocess call in tests per `convention`. + Use relative imports. + +--- + +"BranchRef(name: str, remote: bool)": + location: refs.py + annotations: | + One branch ref of the repository inventory — a local branch or a + remote-tracking ref. + + `name`: the display name — the short branch name for a local ref, + <remote>/<branch> for a remote-tracking ref + `remote`: True when the ref is remote-tracking + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The display name is the identity used by consumers — no reshortening, + no normalization + properties: + "name -> str": | + The display branch name of the ref. + "remote -> bool": | + True when the ref is a remote-tracking ref. + +"list_branch_refs() -> refs: list[BranchRef]": + location: refs.py + annotations: | + Enumerate the branch refs of the repository — local branches and + remote-tracking refs together. + + `refs`: every branch ref, sorted alphabetically by display name + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git for the local branch refs + 2. Ask git for the remote-tracking refs + 3. Merge both into one inventory sorted alphabetically by display name + + Requirements: + - Read-only — no ref is created, moved, or deleted + - No network — remote-tracking refs as they exist locally + + Constraints: + - Do not deduplicate — a local branch and its remote twin are two + distinct refs here; collapsing them belongs to the caller + +"read_ref_tree_paths(ref: str, prefix: str) -> paths: list[str]": + location: trees.py + annotations: | + Read the file paths of one ref tree under a path prefix — without + checkout, worktree, or temporary directories. + + `ref`: the ref to read — a display branch name as carried by + `BranchRef` + `prefix`: the path prefix to read under, relative to the repository + root + `paths`: every file path under the prefix, relative to the repository + root + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git for the recursive file listing of the `ref` tree + 2. Keep the paths that sit under `prefix` + 3. Return them in the order git reports + + Requirements: + - One git invocation per ref + - Read-only — the working copy, the index, and .git stay untouched + - A ref or prefix without matches yields an empty list — not an error + + Constraints: + - Do not materialize the tree — no checkout, no worktree, no temp + directory + - Do not inspect file contents — paths only + +"checkout_local_branch(branch: str)": + location: switch.py + annotations: | + Switch the working copy to an existing local branch. + + `branch`: the short name of the local branch + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git to check out the branch + 2. A git failure surfaces as a clean error + + Requirements: + - The mutation is local — no network, no push, no fetch + + Constraints: + - Do not create the branch — it must exist + - Do not decide when a switch is allowed — the caller owns the + cleanliness policy + +"create_branch_from_remote_tracking(ref: BranchRef)": + location: switch.py + annotations: | + Create a local branch from a remote-tracking ref and switch to it. + + `ref`: the remote-tracking ref to branch from — the local branch takes + its short name + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git to create a local branch named after the short name of `ref` + at the ref's commit and switch to it + 2. A git failure surfaces as a clean error + + Requirements: + - The mutation is local — the remote-tracking ref as it exists locally, + no network + + Constraints: + - Do not update the remote-tracking ref — no fetch + +"create_and_switch_branch(branch_name: str)": + location: switch.py + annotations: | + Create a branch with the name exactly as entered and switch to it. + + `branch_name`: the branch name as entered by the user + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git to create the branch named exactly `branch_name` and switch + to it + 2. A name git rejects surfaces as a clean error + + Requirements: + - The name is taken verbatim — no normalization, no suffixing + - The mutation is local + + Constraints: + - Do not validate the name characters — git owns name validity + +"is_working_tree_clean() -> clean: bool": + location: switch.py + annotations: | + Probe whether the working copy carries uncommitted changes. + + `clean`: True when the working tree and the index match the branch head + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git for the working tree state + 2. Report the answer as a plain boolean + + Requirements: + - Read-only — nothing is staged, committed, or reset + + Constraints: + - Do not act on a dirty tree — the caller owns the policy + +--- + +Author: Goga +CreatedAt: 29/08/26 +Description: | + Git access for the topics domain — branch refs, ref tree paths, and the + bounded host-side branch mutations. From ae1749948a139c7d025a5ec7caa56baf90a26ea0 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 17:06:43 +0000 Subject: [PATCH 075/229] feat: scaffold goga/history/statuses cell package and test layout --- goga/history/statuses/__init__.py | 9 +++++++++ tests/history/statuses/__init__.py | 0 tests/history/statuses/conftest.py | 27 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 goga/history/statuses/__init__.py create mode 100644 tests/history/statuses/__init__.py create mode 100644 tests/history/statuses/conftest.py diff --git a/goga/history/statuses/__init__.py b/goga/history/statuses/__init__.py new file mode 100644 index 00000000..f7dbcc38 --- /dev/null +++ b/goga/history/statuses/__init__.py @@ -0,0 +1,9 @@ +"""Status scale cell — the owner of the topic status scale. + +The built-in artifact axis, the registration of tool statuses over installed +``goga_tool_*`` packages, and the computation of a topic's maximal present +statuses. Pure scale logic — no filesystem probing of topic directories, no +git access, no CLI, no output rendering. +""" + +__all__: list[str] = [] diff --git a/tests/history/statuses/__init__.py b/tests/history/statuses/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/history/statuses/conftest.py b/tests/history/statuses/conftest.py new file mode 100644 index 00000000..0dbd42c3 --- /dev/null +++ b/tests/history/statuses/conftest.py @@ -0,0 +1,27 @@ +"""Local fixtures of the status scale cell tests.""" + +from __future__ import annotations + +import pytest +from goga.history.statuses import Stage, StatusScale + + +@pytest.fixture +def builtin_scale() -> StatusScale: + """Deterministic built-in scale — eight entries with the contract artifacts. + + The deepening order is the contract: empty, defined, discovered, backlog, + designed, specified, planned, done. + """ + return StatusScale( + stages=[ + Stage(name="empty", filepath=""), + Stage(name="defined", filepath="prd.md"), + Stage(name="discovered", filepath="adr.md"), + Stage(name="backlog", filepath="task.md"), + Stage(name="designed", filepath="arch.md"), + Stage(name="specified", filepath="design.md"), + Stage(name="planned", filepath="plan.md"), + Stage(name="done", filepath="completed/plan.md"), + ] + ) From ef7c91c2596184441180cfcb7a69859004e74ede Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 17:12:38 +0000 Subject: [PATCH 076/229] feat: implement Stage and StatusScale in statuses scale cell --- goga/history/statuses/__init__.py | 4 +- goga/history/statuses/scale.py | 160 +++++++++++++++++++++++++++ tests/history/statuses/test_scale.py | 147 ++++++++++++++++++++++++ 3 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 goga/history/statuses/scale.py create mode 100644 tests/history/statuses/test_scale.py diff --git a/goga/history/statuses/__init__.py b/goga/history/statuses/__init__.py index f7dbcc38..7e1cda06 100644 --- a/goga/history/statuses/__init__.py +++ b/goga/history/statuses/__init__.py @@ -6,4 +6,6 @@ git access, no CLI, no output rendering. """ -__all__: list[str] = [] +from .scale import Stage, StatusScale + +__all__: list[str] = ["Stage", "StatusScale"] diff --git a/goga/history/statuses/scale.py b/goga/history/statuses/scale.py new file mode 100644 index 00000000..b01c960a --- /dev/null +++ b/goga/history/statuses/scale.py @@ -0,0 +1,160 @@ +"""The status scale value model of the statuses cell. + +The entities declared in the cell CODEMANIFEST with ``location: scale.py``: +one entry of the scale — a named position anchored to the artifact that +marks it — and the assembled partially ordered scale, the single source of +scale order and maximal-status computation. Pure scale logic: presence is +decided by the caller's ``paths`` input alone — no filesystem probing, no +git, no CLI. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from itertools import pairwise + + +@dataclass(frozen=True, kw_only=True) +class Stage: + """One entry of the scale — a named position anchored to the artifact that marks it. + + Attributes: + name: The qualified name — bare for built-in entries, ``<tool>.<name>`` + for tool entries. + filepath: The artifact path relative to the topic directory; nested + paths allowed. The ``empty`` entry carries the empty string and is + never marked present. + before: The qualified name of the entry this one precedes; ``None`` + for built-in entries. + after: The qualified name of the entry this one follows; ``None`` for + built-in entries. + + Requirements: + A tool entry carries at least one anchor; a built-in entry carries + none — its position is the axis order. + """ + + name: str + filepath: str + before: str | None = None + after: str | None = None + + +@dataclass(kw_only=True) +class StatusScale: + """The assembled partially ordered scale of topic statuses. + + The single source of scale order and maximal-status computation. + + Attributes: + stages: The scale content in scale order. + + Requirements: + The built-in axis is ordered empty, defined, discovered, backlog, + designed, specified, planned, done by the artifacts prd.md, adr.md, + task.md, arch.md, design.md, plan.md, completed/plan.md; a tool + status never reorders or replaces a built-in one. + """ + + stages: list[Stage] + + def maximal_present(self, paths: list[str]) -> list[str]: + """Compute the maximal present statuses of one topic. + + Args: + paths: The artifact paths present in a topic directory, relative + to it. + + Returns: + The qualified names of the maximal present statuses, in scale + order. A topic with no artifact present yields the single + built-in name ``empty``. + + Algorithm: + 1. Mark every scale entry whose artifact path is present + 2. Drop every marked entry strictly below another marked entry + 3. Return the names of the surviving entries in scale order + + Requirements: + Every maximal entry is returned — a present entry outranked by + no other present entry stays visible. + + Constraints: + Do not probe the filesystem — presence is decided by the + caller's ``paths`` input alone. + + ``Strictly below'' follows the scale's partial order: the built-in + axis (the entries carrying no anchor) is a chain in list order, and + every anchor adds one edge — an entry anchored ``after`` another is + above it, one anchored ``before`` another is below it. Two tool + entries sharing an anchor are incomparable, so both stay maximal + when both artifacts are present; ``in scale order`` is the assembled + list order. + """ + present = set(paths) + marked = [stage for stage in self.stages if stage.filepath and stage.filepath in present] + if not marked: + return ["empty"] + above = self._strictly_above() + marked_names = {stage.name for stage in marked} + maximal = [stage for stage in marked if not above[stage.name] & marked_names] + return [stage.name for stage in maximal] + + def resolve_status(self, name: str) -> Stage: + """Resolve a qualified status name for filter validation. + + Args: + name: A status name as entered by a consumer — a built-in name + or a qualified tool name. + + Returns: + The scale entry carrying the name. + + Raises: + ValueError: The name matches no entry of the scale. + + Algorithm: + 1. Find the scale entry whose qualified name equals ``name`` + exactly + 2. An unknown name raises a clean error + + Constraints: + Do not fuzzy-match or fall back — the exact qualified name is + the contract. + """ + for stage in self.stages: + if stage.name == name: + return stage + raise ValueError(f"unknown status name: {name!r}") + + def _strictly_above(self) -> dict[str, set[str]]: + """Map every qualified name to the names of the entries strictly above it. + + The transitive closure of the scale's ``below -> above`` edges: the + anchor-free built-in axis chained in list order, plus one edge per + anchor — ``after=A`` puts the entry above ``A``, ``before=B`` puts it + below ``B``. An anchor naming no entry of the scale adds no edge. + """ + above: dict[str, set[str]] = {stage.name: set() for stage in self.stages} + axis = [stage for stage in self.stages if stage.before is None and stage.after is None] + for below, upper in pairwise(axis): + above[below.name].add(upper.name) + for stage in self.stages: + if stage.after is not None and stage.after in above: + above[stage.after].add(stage.name) + if stage.before is not None and stage.before in above: + above[stage.name].add(stage.before) + # Close transitively by iterating to a fixed point — the scale is + # small, and the loop stays safe even on a cyclic hand-built input. + names = list(above) + changed = True + while changed: + changed = False + for name in names: + uppers = above[name] + inherited = set().union(*(above[upper] for upper in uppers)) if uppers else set() + missing = inherited - uppers + if missing: + above[name] = uppers | missing + changed = True + return above diff --git a/tests/history/statuses/test_scale.py b/tests/history/statuses/test_scale.py new file mode 100644 index 00000000..869d4e9c --- /dev/null +++ b/tests/history/statuses/test_scale.py @@ -0,0 +1,147 @@ +"""Contract and logic tests for the entities declared in +``goga/history/statuses/CODEMANIFEST`` with ``location: scale.py``: + +- ``Stage(name, filepath, before=None, after=None)`` — one scale entry, a + named position anchored to the artifact that marks it +- ``StatusScale(stages)`` — the assembled partially ordered scale, the + single source of scale order and maximal-status computation + +Pure scale logic — no mocks and no filesystem: presence is decided by the +caller's ``paths`` input alone. +""" + +from __future__ import annotations + +import dataclasses +import inspect + +import pytest +from goga.history.statuses import Stage, StatusScale + + +def _tool_extended_scale(builtin_scale: StatusScale) -> StatusScale: + """Builtin axis plus two tool entries anchored after ``planned`` in package order.""" + return StatusScale( + stages=[ + *builtin_scale.stages[:7], # empty .. planned + Stage(name="mkdocs.published", filepath="mkdocs/published.md", after="planned"), + Stage(name="scriba.translated", filepath="scriba/translated.md", after="planned"), + builtin_scale.stages[7], # done + ], + ) + + +# --- Contract tests --- + + +class TestScaleContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """``Stage`` and ``StatusScale`` live on the cell facade and its ``__all__``.""" + import goga.history.statuses as cell + + assert cell.Stage is Stage + assert cell.StatusScale is StatusScale + assert "Stage" in cell.__all__ + assert "StatusScale" in cell.__all__ + + def test_stage_is_a_frozen_kw_only_dataclass(self) -> None: + """``Stage(name, filepath, before=None, after=None)`` — frozen, keyword-only.""" + stage = Stage(name="defined", filepath="prd.md") + + assert stage.name == "defined" + assert stage.filepath == "prd.md" + assert stage.before is None + assert stage.after is None + + with pytest.raises(TypeError): + Stage("defined", "prd.md") # type: ignore[misc] + with pytest.raises(dataclasses.FrozenInstanceError): + stage.name = "renamed" # type: ignore[misc] + + def test_stage_carries_qualified_anchor_names(self) -> None: + """``before``/``after`` hold the qualified names of the neighbouring entries.""" + stage = Stage(name="mkdocs.published", filepath="mkdocs/published.md", after="planned") + + assert stage.after == "planned" + assert stage.before is None + + def test_status_scale_is_a_kw_only_dataclass_over_stages(self) -> None: + """``StatusScale(stages=...)`` — keyword-only; ``stages`` is the scale content.""" + entries = [Stage(name="empty", filepath=""), Stage(name="defined", filepath="prd.md")] + scale = StatusScale(stages=entries) + + assert isinstance(scale.stages, list) + assert scale.stages == entries + + with pytest.raises(TypeError): + StatusScale(entries) # type: ignore[misc] + + def test_maximal_present_takes_paths_and_returns_status_names(self) -> None: + """``maximal_present(paths: list[str]) -> list[str]``.""" + parameters = inspect.signature(StatusScale.maximal_present).parameters + assert list(parameters) == ["self", "paths"] + + scale = StatusScale(stages=[Stage(name="empty", filepath="")]) + result = scale.maximal_present(["notes.txt"]) + + assert isinstance(result, list) + assert all(isinstance(name, str) for name in result) + + def test_resolve_status_returns_the_scale_entry(self) -> None: + """``resolve_status(name: str) -> Stage`` — the entry carries its ``name``.""" + parameters = inspect.signature(StatusScale.resolve_status).parameters + assert list(parameters) == ["self", "name"] + + scale = StatusScale(stages=[Stage(name="empty", filepath=""), Stage(name="done", filepath="completed/plan.md")]) + stage = scale.resolve_status("done") + + assert isinstance(stage, Stage) + assert stage.name == "done" + + +# --- Logic tests — maximal_present --- + + +class TestMaximalPresent: + def test_maximal_present_returns_deepest_artifact_status(self, builtin_scale: StatusScale) -> None: + """The deepest present artifact of the axis wins; files outside the scale mark nothing.""" + paths = ["prd.md", "adr.md", "task.md", "notes.txt"] + + assert builtin_scale.maximal_present(paths) == ["backlog"] + + def test_maximal_present_done_outranks_flat_artifacts(self, builtin_scale: StatusScale) -> None: + """The nested ``completed/plan.md`` outranks the flat ``plan.md``.""" + assert builtin_scale.maximal_present(["plan.md", "completed/plan.md"]) == ["done"] + + def test_maximal_present_empty_when_no_artifacts(self, builtin_scale: StatusScale) -> None: + """No present artifact yields the single built-in name ``empty``.""" + assert builtin_scale.maximal_present([]) == ["empty"] + assert builtin_scale.maximal_present(["notes.txt"]) == ["empty"] + + def test_maximal_present_two_incomparable_tool_statuses(self, builtin_scale: StatusScale) -> None: + """Two tool entries sharing an anchor are incomparable — both stay maximal.""" + scale = _tool_extended_scale(builtin_scale) + paths = ["plan.md", "mkdocs/published.md", "scriba/translated.md"] + + assert scale.maximal_present(paths) == ["mkdocs.published", "scriba.translated"] + + def test_maximal_present_dedupes_repeated_paths(self, builtin_scale: StatusScale) -> None: + """A repeated path marks its entry once.""" + assert builtin_scale.maximal_present(["plan.md", "plan.md"]) == ["planned"] + + +# --- Logic tests — resolve_status --- + + +class TestResolveStatus: + def test_resolve_status_exact_qualified_name(self, builtin_scale: StatusScale) -> None: + """The exact qualified name resolves — tool and built-in alike.""" + scale = _tool_extended_scale(builtin_scale) + + assert scale.resolve_status("mkdocs.published").name == "mkdocs.published" + assert builtin_scale.resolve_status("done").name == "done" + + def test_resolve_status_unknown_name_raises(self, builtin_scale: StatusScale) -> None: + """An unknown name raises a clean error carrying the entered name.""" + with pytest.raises(ValueError, match=r"unknown status name: 'mkdocs\.published'"): + builtin_scale.resolve_status("mkdocs.published") From f963e47cf43c5c48b5ed21ae8a049ff73b37035f Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 17:16:37 +0000 Subject: [PATCH 077/229] feat: implement StatusRegistry in statuses registry cell --- goga/history/statuses/__init__.py | 3 +- goga/history/statuses/registry.py | 93 ++++++++++++++++ tests/history/statuses/test_registry.py | 135 ++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 goga/history/statuses/registry.py create mode 100644 tests/history/statuses/test_registry.py diff --git a/goga/history/statuses/__init__.py b/goga/history/statuses/__init__.py index 7e1cda06..475ab98f 100644 --- a/goga/history/statuses/__init__.py +++ b/goga/history/statuses/__init__.py @@ -6,6 +6,7 @@ git access, no CLI, no output rendering. """ +from .registry import StatusRegistry from .scale import Stage, StatusScale -__all__: list[str] = ["Stage", "StatusScale"] +__all__: list[str] = ["Stage", "StatusRegistry", "StatusScale"] diff --git a/goga/history/statuses/registry.py b/goga/history/statuses/registry.py new file mode 100644 index 00000000..21c79408 --- /dev/null +++ b/goga/history/statuses/registry.py @@ -0,0 +1,93 @@ +"""The tool-status registration surface of the statuses cell. + +The entity declared in the cell CODEMANIFEST with ``location: registry.py``: +the controlled registration surface handed to a tool package — the only way +a tool status enters the scale. Pure registration logic: names are qualified +and the content is validated here, while anchor resolution and placement +stay with the scale assembly — a tool may anchor to an entry registered by +another tool. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .scale import Stage + + +@dataclass(kw_only=True) +class StatusRegistry: + """The controlled registration surface handed to a tool package. + + The only way a tool status enters the scale. Registration is add-only — + a built-in entry is never modified, removed, or re-anchored. + + Attributes: + builtin_stages: The immutable built-in axis the registry extends. + tool_prefix: The qualifier applied to every name registered through + this registry — derived from the package name. + + Requirements: + Registration is add-only — a built-in entry is never modified, + removed, or re-anchored. + """ + + builtin_stages: list[Stage] + tool_prefix: str + _entries: list[Stage] = field(default_factory=list, init=False, repr=False) + + @property + def stages(self) -> list[Stage]: + """The built-in axis plus every accepted tool entry. + + A copy is issued every time — mutating the returned list never + reaches the registry content. + """ + return [*self.builtin_stages, *self._entries] + + def register( + self, + name: str, + filepath: str, + before: str | None = None, + after: str | None = None, + ) -> None: + """Register one tool status. + + Args: + name: The status name as the tool defines it — stored qualified + as ``<tool_prefix>.<name>``. + filepath: The artifact path relative to the topic directory. + before: Optional anchor — the qualified name of an entry this one + precedes. + after: Optional anchor — the qualified name of an entry this one + follows. + + Raises: + ValueError: A structural violation — the message names the entry. + + Algorithm: + 1. Qualify ``name`` with the registry's tool prefix + 2. Validate the content: a non-empty name, a non-empty + ``filepath``, and at least one anchor present + 3. Append the entry to the registry content + + Requirements: + A structural violation raises a clean registration error naming + the entry. Both anchors given define a placement range; anchor + resolution and range validity are decided at scale assembly. + + Constraints: + Do not resolve anchors here — a tool may anchor to an entry + registered by another tool. Do not modify built-in entries. + """ + qualified = f"{self.tool_prefix}.{name}" + if not isinstance(name, str) or not name: + raise ValueError(f"status entry {qualified!r}: name must be a non-empty string") + if not isinstance(filepath, str) or not filepath: + raise ValueError(f"status entry {qualified!r}: filepath must be a non-empty string") + if before is None and after is None: + raise ValueError(f"status entry {qualified!r}: at least one anchor is required") + if any(entry.name == qualified for entry in self._entries): + raise ValueError(f"status entry {qualified!r}: already registered in this registry") + self._entries.append(Stage(name=qualified, filepath=filepath, before=before, after=after)) diff --git a/tests/history/statuses/test_registry.py b/tests/history/statuses/test_registry.py new file mode 100644 index 00000000..035f24dd --- /dev/null +++ b/tests/history/statuses/test_registry.py @@ -0,0 +1,135 @@ +"""Contract and logic tests for the entity declared in +``goga/history/statuses/CODEMANIFEST`` with ``location: registry.py``: + +- ``StatusRegistry(builtin_stages, tool_prefix)`` — the controlled + registration surface handed to a tool package, the only way a tool status + enters the scale + +Pure registration logic — no mocks and no filesystem: anchors are stored +verbatim and resolved at scale assembly, not here. +""" + +from __future__ import annotations + +import inspect + +import pytest +from goga.history.statuses import Stage, StatusRegistry, StatusScale + + +def _registry(builtin_scale: StatusScale, tool_prefix: str = "mkdocs") -> StatusRegistry: + """A registry over the deterministic built-in axis of the cell fixture.""" + return StatusRegistry(builtin_stages=builtin_scale.stages, tool_prefix=tool_prefix) + + +# --- Contract tests --- + + +class TestRegistryContract: + def test_entity_is_importable_from_the_cell_facade(self) -> None: + """``StatusRegistry`` lives on the cell facade and its ``__all__``.""" + import goga.history.statuses as cell + + assert cell.StatusRegistry is StatusRegistry + assert "StatusRegistry" in cell.__all__ + + def test_constructor_is_kw_only(self, builtin_scale: StatusScale) -> None: + """``StatusRegistry(builtin_stages=..., tool_prefix=...)`` — keyword-only.""" + registry = StatusRegistry(builtin_stages=builtin_scale.stages, tool_prefix="mkdocs") + + assert registry.tool_prefix == "mkdocs" + + with pytest.raises(TypeError): + StatusRegistry(builtin_scale.stages, "mkdocs") # type: ignore[misc] + + def test_register_signature(self) -> None: + """``register(name, filepath, before=None, after=None)``.""" + parameters = inspect.signature(StatusRegistry.register).parameters + + assert list(parameters) == ["self", "name", "filepath", "before", "after"] + assert parameters["before"].default is None + assert parameters["after"].default is None + + def test_stages_property_returns_the_built_in_axis_plus_entries(self, builtin_scale: StatusScale) -> None: + """``stages -> list[Stage]`` — the built-in axis plus every accepted entry.""" + registry = _registry(builtin_scale) + + assert isinstance(registry.stages, list) + assert all(isinstance(stage, Stage) for stage in registry.stages) + assert registry.stages == builtin_scale.stages + + def test_registry_is_not_frozen(self, builtin_scale: StatusScale) -> None: + """Registration is add-only state — the registry itself stays mutable.""" + assert not StatusRegistry.__dataclass_params__.frozen + assert StatusRegistry.__dataclass_params__.kw_only + + +# --- Logic tests --- + + +class TestRegister: + def test_register_qualifies_name_and_appends(self, builtin_scale: StatusScale) -> None: + """``register`` stores the entry qualified and appends it after the axis.""" + registry = _registry(builtin_scale) + before = len(registry.stages) + + registry.register("published", "mkdocs/published.md", after="planned") + + assert [s.name for s in registry.stages][-1] == "mkdocs.published" + assert len(registry.stages) == before + 1 + # The built-in part is untouched — same eight names in the same order. + assert [s.name for s in registry.stages[:8]] == [s.name for s in builtin_scale.stages] + + def test_register_stores_anchors_verbatim(self, builtin_scale: StatusScale) -> None: + """Both anchors are carried as given — resolution is not done here.""" + registry = _registry(builtin_scale) + + registry.register("reviewed", "review/reviewed.md", before="done", after="scriba.translated") + + entry = registry.stages[-1] + assert entry.filepath == "review/reviewed.md" + assert entry.before == "done" + assert entry.after == "scriba.translated" + + def test_register_missing_anchor_raises(self, builtin_scale: StatusScale) -> None: + """A tool entry carries at least one anchor — otherwise a clean error.""" + registry = _registry(builtin_scale) + + with pytest.raises(ValueError, match="mkdocs"): + registry.register("x", "x.md") + + assert registry.stages == builtin_scale.stages + + def test_register_duplicate_qualified_name_raises(self, builtin_scale: StatusScale) -> None: + """The same qualified name cannot be registered twice in one registry.""" + registry = _registry(builtin_scale) + registry.register("published", "mkdocs/published.md", after="planned") + + with pytest.raises(ValueError, match=r"mkdocs\.published"): + registry.register("published", "mkdocs/published.md", after="planned") + + assert len(registry.stages) == 9 + + @pytest.mark.parametrize( + ("name", "filepath"), + [("", "x.md"), ("x", "")], + ids=["empty-name", "empty-filepath"], + ) + def test_register_empty_name_or_filepath_raises(self, builtin_scale: StatusScale, name: str, filepath: str) -> None: + """A non-empty name and a non-empty filepath are structural requirements.""" + registry = _registry(builtin_scale) + + with pytest.raises(ValueError, match=r"status entry"): + registry.register(name, filepath, after="planned") + + assert registry.stages == builtin_scale.stages + + def test_stages_returns_a_copy(self, builtin_scale: StatusScale) -> None: + """Mutating the issued list never reaches the registry content.""" + registry = _registry(builtin_scale) + registry.register("published", "mkdocs/published.md", after="planned") + + issued = registry.stages + issued.append(Stage(name="tamper", filepath="tamper.md", after="planned")) + + assert len(registry.stages) == 9 From c848a225d6340e15e7a6644c9d2e0acc5d28e135 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 17:21:57 +0000 Subject: [PATCH 078/229] feat: implement assemble_status_scale in statuses assembly cell --- goga/history/statuses/__init__.py | 8 +- goga/history/statuses/assembly.py | 147 ++++++++++ tests/history/statuses/test_assembly.py | 348 ++++++++++++++++++++++++ 3 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 goga/history/statuses/assembly.py create mode 100644 tests/history/statuses/test_assembly.py diff --git a/goga/history/statuses/__init__.py b/goga/history/statuses/__init__.py index 475ab98f..981b4e49 100644 --- a/goga/history/statuses/__init__.py +++ b/goga/history/statuses/__init__.py @@ -6,7 +6,13 @@ git access, no CLI, no output rendering. """ +from .assembly import assemble_status_scale from .registry import StatusRegistry from .scale import Stage, StatusScale -__all__: list[str] = ["Stage", "StatusRegistry", "StatusScale"] +__all__: list[str] = [ + "Stage", + "StatusRegistry", + "StatusScale", + "assemble_status_scale", +] diff --git a/goga/history/statuses/assembly.py b/goga/history/statuses/assembly.py new file mode 100644 index 00000000..1f5c8867 --- /dev/null +++ b/goga/history/statuses/assembly.py @@ -0,0 +1,147 @@ +"""The scale assembly routine of the statuses cell. + +The routine declared in the cell CODEMANIFEST with ``location: assembly.py``: +the full status scale — the built-in axis extended by every installed tool +package. The assembly runs at every command start that needs the scale; the +scale is never cached across runs. A registration problem never aborts the +command — a package import failure is the only fatal case. +""" + +from __future__ import annotations + +import sys +from importlib import import_module +from importlib.metadata import packages_distributions +from types import ModuleType + +from .registry import StatusRegistry +from .scale import Stage, StatusScale + +_BUILTIN_AXIS: list[Stage] = [ + Stage(name="empty", filepath=""), + Stage(name="defined", filepath="prd.md"), + Stage(name="discovered", filepath="adr.md"), + Stage(name="backlog", filepath="task.md"), + Stage(name="designed", filepath="arch.md"), + Stage(name="specified", filepath="design.md"), + Stage(name="planned", filepath="plan.md"), + Stage(name="done", filepath="completed/plan.md"), +] + + +def assemble_status_scale() -> StatusScale: + """Assemble the full status scale — the built-in axis extended by every installed tool package. + + Returns: + scale: The assembled scale. + + Algorithm: + 1. Build the built-in axis of eight entries + 2. Enumerate the installed goga_tool_* packages in alphabetical + order of package name + 3. Import each package — a broken import is a clean error naming + the package + 4. A package without the callback of the ``registration`` practice + is skipped silently + 5. Call the callback with a registry scoped to the package + 6. Any exception from the callback — a registration content error + or a crashed callback — skips that registration with a warning + to stderr; the package import failure of step 3 remains the + only fatal case + 7. Resolve anchors and validate placement ranges; an unresolvable + anchor or an invalid range skips the registration with a + warning to stderr + 8. Assemble and return the scale + + Requirements: + The scale assembles from the surviving registrations alone — one + broken registration never cancels the rest. Package enumeration + mirrors goga/connect: ``importlib.metadata.packages_distributions()`` + filtered to top-level module names starting with ``goga_tool_``, + sorted alphabetically by top-level module name. + + Constraints: + Do not cache the scale across command runs. Do not let a + registration problem abort the command. + + Placement follows the anchors of each surviving entry, resolved against + the list assembled by the moment the entry is processed — the built-in + axis plus the entries of the earlier packages and the earlier entries of + the current one. Entries sharing an anchor form one continuous block in + registration order: an ``after``-anchored entry lands at the end of its + anchor's block, a ``before``-anchored entry right in front of its anchor, + and both anchors given define a range the entry must fit into. + """ + stages = list(_BUILTIN_AXIS) + for package_name in _tool_packages(): + module = _import_tool_package(package_name) + callback = getattr(module, "register_topic_statuses", None) + if not callable(callback): + continue + registry = StatusRegistry( + builtin_stages=list(_BUILTIN_AXIS), + tool_prefix=package_name.removeprefix("goga_tool_"), + ) + try: + callback(registry) + except Exception as exc: + print(f"Warning: skipping status registration in {package_name}: {exc}", file=sys.stderr) + for entry in registry.stages[len(_BUILTIN_AXIS) :]: + try: + index = _placement_index(stages, entry) + except ValueError as exc: + print(f"Warning: skipping status registration in {package_name}: {exc}", file=sys.stderr) + continue + stages.insert(index, entry) + return StatusScale(stages=stages) + + +def _tool_packages() -> list[str]: + """The installed ``goga_tool_*`` top-level names in alphabetical order.""" + return sorted(name for name in packages_distributions() if name.startswith("goga_tool_")) + + +def _import_tool_package(name: str) -> ModuleType: + """Import one tool package — a broken import is a clean error naming the package. + + Raises: + ImportError: The package failed to import. + """ + try: + return import_module(name) + except Exception as exc: + raise ImportError(f"package {name} failed to import: {exc}") from exc + + +def _placement_index(stages: list[Stage], entry: Stage) -> int: + """Resolve the anchors of one accepted entry to an insertion index. + + Args: + stages: The list assembled by the moment the entry is processed. + entry: The accepted tool entry to place. + + Returns: + The index the entry is inserted at. + + Raises: + ValueError: An anchor names no entry of the list, or the two + anchors define an invalid range. + """ + positions = {stage.name: index for index, stage in enumerate(stages)} + after = entry.after + before = entry.before + if after is not None and after not in positions: + raise ValueError(f"status entry {entry.name!r}: unknown after anchor {after!r}") + if before is not None and before not in positions: + raise ValueError(f"status entry {entry.name!r}: unknown before anchor {before!r}") + if after is None: + return positions[before] + if before is None: + # The end of the anchor's block — every entry already inserted + # with the same ``after`` anchor sits between the anchor and this + # index, keeping the block in registration order. + block = sum(1 for stage in stages if stage.after == after) + return positions[after] + 1 + block + if not positions[after] < positions[before]: + raise ValueError(f"status entry {entry.name!r}: anchor range {after!r}..{before!r} is invalid") + return positions[before] diff --git a/tests/history/statuses/test_assembly.py b/tests/history/statuses/test_assembly.py new file mode 100644 index 00000000..a017e86f --- /dev/null +++ b/tests/history/statuses/test_assembly.py @@ -0,0 +1,348 @@ +"""Contract and logic tests for the routine declared in +``goga/history/statuses/CODEMANIFEST`` with ``location: assembly.py``: + +- ``assemble_status_scale() -> scale`` — the built-in axis extended by every + installed tool package + +The package enumeration and imports are mocked at the point of import; fake +packages are injected through ``sys.modules``. Warnings are checked with +``capsys``. +""" + +from __future__ import annotations + +import inspect +import sys +from collections.abc import Callable +from types import ModuleType +from typing import Any + +import pytest +from goga.history import statuses as cell +from goga.history.statuses import Stage, StatusScale, assemble_status_scale +from goga.history.statuses import assembly as assembly_module + +Registration = Callable[[Any], None] + +_BUILTIN_NAMES = [ + "empty", + "defined", + "discovered", + "backlog", + "designed", + "specified", + "planned", + "done", +] + + +def _install_package(monkeypatch: pytest.MonkeyPatch, name: str, attribute: Any) -> ModuleType: + """Inject a fake ``goga_tool_*`` package into ``sys.modules``. + + ``attribute`` is the value the module carries as + ``register_topic_statuses`` — a callable callback, a non-callable value, + or ``None`` for a package without the attribute. + """ + module = ModuleType(name) + if attribute is not None: + module.register_topic_statuses = attribute + monkeypatch.setitem(sys.modules, name, module) + return module + + +def _registering(*registrations: dict[str, Any]) -> Registration: + """A callback that registers the given entries in order.""" + + def register_topic_statuses(statuses: Any) -> None: + for registration in registrations: + statuses.register(**registration) + + return register_topic_statuses + + +def _packages(monkeypatch: pytest.MonkeyPatch, *names: str) -> None: + """Patch the package enumeration to exactly ``names``.""" + monkeypatch.setattr( + assembly_module, + "packages_distributions", + lambda: {name: [f"dist-{name}"] for name in names}, + ) + + +def _names(scale: StatusScale) -> list[str]: + return [stage.name for stage in scale.stages] + + +# --- Contract tests --- + + +class TestAssemblyContract: + def test_routine_is_importable_from_the_cell_facade(self) -> None: + """``assemble_status_scale`` lives on the cell facade and its ``__all__``.""" + assert cell.assemble_status_scale is assemble_status_scale + assert "assemble_status_scale" in cell.__all__ + + def test_routine_takes_no_arguments(self) -> None: + """``assemble_status_scale()`` — called with no arguments.""" + assert list(inspect.signature(assemble_status_scale).parameters) == [] + + def test_routine_returns_a_status_scale(self, monkeypatch: pytest.MonkeyPatch) -> None: + """``-> scale: StatusScale`` — the return carries a ``stages`` attribute.""" + _packages(monkeypatch) + + scale = assemble_status_scale() + + assert isinstance(scale, StatusScale) + assert isinstance(scale.stages, list) + assert all(isinstance(stage, Stage) for stage in scale.stages) + + def test_routine_does_not_cache_across_runs(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Every call assembles a fresh scale — no caching between runs.""" + _packages(monkeypatch) + first = assemble_status_scale() + _install_package( + monkeypatch, + "goga_tool_a", + _registering({"name": "x", "filepath": "a/x.md", "after": "planned"}), + ) + _packages(monkeypatch, "goga_tool_a") + + second = assemble_status_scale() + + assert _names(first) == _BUILTIN_NAMES + assert _names(second) != _names(first) + assert second.stages is not first.stages + + +# --- Logic tests --- + + +class TestAssembleBuiltinAxis: + def test_assemble_builtin_axis_order(self, monkeypatch: pytest.MonkeyPatch) -> None: + """No tool packages — the pure built-in axis in the contract order.""" + _packages(monkeypatch) + + scale = assemble_status_scale() + + assert _names(scale) == _BUILTIN_NAMES + assert [stage.filepath for stage in scale.stages] == [ + "", + "prd.md", + "adr.md", + "task.md", + "arch.md", + "design.md", + "plan.md", + "completed/plan.md", + ] + + def test_assemble_non_callable_callback_skipped( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """A package whose callback attribute is not callable is skipped silently.""" + _install_package(monkeypatch, "goga_tool_bad", 42) + _packages(monkeypatch, "goga_tool_bad") + + scale = assemble_status_scale() + + assert _names(scale) == _BUILTIN_NAMES + assert capsys.readouterr().err == "" + + +class TestAssemblePlacement: + def test_assemble_places_anchored_statuses(self, monkeypatch: pytest.MonkeyPatch) -> None: + """``after`` lands right after its anchor; ``before`` right before its anchor.""" + _install_package( + monkeypatch, "goga_tool_a", _registering({"name": "x", "filepath": "a/x.md", "after": "planned"}) + ) + _install_package( + monkeypatch, "goga_tool_b", _registering({"name": "y", "filepath": "b/y.md", "before": "done"}) + ) + _packages(monkeypatch, "goga_tool_a", "goga_tool_b") + + scale = assemble_status_scale() + + names = _names(scale) + assert names.index("a.x") == names.index("planned") + 1 + assert names.index("b.y") == names.index("done") - 1 + + def test_assemble_both_anchors_range(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Both anchors define a range — the entry lands inside it.""" + _install_package( + monkeypatch, + "goga_tool_a", + _registering({"name": "x", "filepath": "a/x.md", "after": "defined", "before": "backlog"}), + ) + _packages(monkeypatch, "goga_tool_a") + + scale = assemble_status_scale() + + names = _names(scale) + assert names.index("discovered") < names.index("a.x") < names.index("backlog") + + def test_assemble_invalid_anchor_range_skips_with_warning( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """An inverted range is invalid — the entry is skipped with a warning.""" + _install_package( + monkeypatch, + "goga_tool_a", + _registering({"name": "x", "filepath": "a/x.md", "after": "backlog", "before": "defined"}), + ) + _packages(monkeypatch, "goga_tool_a") + + scale = assemble_status_scale() + + assert "a.x" not in _names(scale) + stderr = capsys.readouterr().err + assert "Warning" in stderr + assert "goga_tool_a" in stderr + + def test_assemble_unresolvable_anchor_skips( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """An anchor naming no entry of the scale skips the registration.""" + _install_package( + monkeypatch, + "goga_tool_a", + _registering({"name": "x", "filepath": "a/x.md", "after": "nonexistent.status"}), + ) + _packages(monkeypatch, "goga_tool_a") + + scale = assemble_status_scale() + + assert "a.x" not in _names(scale) + stderr = capsys.readouterr().err + assert "Warning" in stderr + assert "goga_tool_a" in stderr + assert _names(scale) == _BUILTIN_NAMES + + def test_assemble_same_anchor_block_keeps_registration_order(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Two packages anchoring after the same entry form a block in package order. + + The design-review q1 regression: a bare ``insert(pos(A) + 1)`` would + reverse the block — the alphabetical package order must win. + """ + _install_package( + monkeypatch, "goga_tool_a", _registering({"name": "x", "filepath": "a/x.md", "after": "planned"}) + ) + _install_package( + monkeypatch, "goga_tool_b", _registering({"name": "y", "filepath": "b/y.md", "after": "planned"}) + ) + _packages(monkeypatch, "goga_tool_a", "goga_tool_b") + + scale = assemble_status_scale() + + names = _names(scale) + planned = names.index("planned") + assert names[planned + 1 : planned + 3] == ["a.x", "b.y"] + assert names[planned + 3] == "done" + + def test_assemble_two_entries_of_one_package_same_anchor(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Two entries of one package sharing an anchor also stack in order.""" + _install_package( + monkeypatch, + "goga_tool_a", + _registering( + {"name": "x", "filepath": "a/x.md", "after": "planned"}, + {"name": "z", "filepath": "a/z.md", "after": "planned"}, + ), + ) + _packages(monkeypatch, "goga_tool_a") + + scale = assemble_status_scale() + + names = _names(scale) + planned = names.index("planned") + assert names[planned + 1 : planned + 3] == ["a.x", "a.z"] + + def test_assemble_tool_prefix_strips_package_qualifier(self, monkeypatch: pytest.MonkeyPatch) -> None: + """P1 — the prefix is the top-level name without the ``goga_tool_`` part.""" + _install_package( + monkeypatch, + "goga_tool_hello_world", + _registering({"name": "x", "filepath": "hw/x.md", "after": "planned"}), + ) + _packages(monkeypatch, "goga_tool_hello_world") + + scale = assemble_status_scale() + + assert "hello_world.x" in _names(scale) + + def test_assemble_anchor_to_earlier_tool_entry(self, monkeypatch: pytest.MonkeyPatch) -> None: + """An entry may anchor to a tool entry accepted from an earlier package.""" + _install_package( + monkeypatch, "goga_tool_a", _registering({"name": "x", "filepath": "a/x.md", "after": "planned"}) + ) + _install_package(monkeypatch, "goga_tool_b", _registering({"name": "y", "filepath": "b/y.md", "after": "a.x"})) + _packages(monkeypatch, "goga_tool_a", "goga_tool_b") + + scale = assemble_status_scale() + + names = _names(scale) + assert names.index("a.x") < names.index("b.y") < names.index("done") + + +class TestAssembleFailures: + def test_assemble_broken_import_is_fatal(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A package import failure is the only fatal case — a clean error.""" + _packages(monkeypatch, "goga_tool_bad") + + def _raise(name: str) -> ModuleType: + raise ModuleNotFoundError(f"No module named {name!r}") + + monkeypatch.setattr(assembly_module, "import_module", _raise) + + with pytest.raises(ImportError, match="goga_tool_bad"): + assemble_status_scale() + + def test_assemble_bad_registration_warns_and_continues( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """A second, anchor-less registration skips with a warning; the rest survives.""" + _install_package( + monkeypatch, + "goga_tool_a", + _registering( + {"name": "good", "filepath": "a/good.md", "after": "planned"}, + {"name": "bad", "filepath": "a/bad.md"}, + ), + ) + _install_package( + monkeypatch, "goga_tool_b", _registering({"name": "y", "filepath": "b/y.md", "before": "done"}) + ) + _packages(monkeypatch, "goga_tool_a", "goga_tool_b") + + scale = assemble_status_scale() + + names = _names(scale) + assert "a.good" in names + assert "a.bad" not in names + assert "b.y" in names + stderr = capsys.readouterr().err + assert "Warning: skipping status registration in goga_tool_a" in stderr + + def test_assemble_crashed_callback_warns_and_continues( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """A callback that crashes after its first entry keeps that entry.""" + _install_package(monkeypatch, "goga_tool_a", _crashing_callback) + _install_package( + monkeypatch, "goga_tool_b", _registering({"name": "y", "filepath": "b/y.md", "before": "done"}) + ) + _packages(monkeypatch, "goga_tool_a", "goga_tool_b") + + scale = assemble_status_scale() + + names = _names(scale) + assert "a.first" in names + assert "b.y" in names + stderr = capsys.readouterr().err + assert "Warning: skipping status registration in goga_tool_a" in stderr + assert "boom" in stderr + + +def _crashing_callback(statuses: Any) -> None: + """Register one entry, then crash like a broken third-party callback.""" + statuses.register("first", "a/first.md", after="planned") + raise TypeError("boom") From e8d5ad758f0825fd1a313c3e8a17d52c8d10ad8c Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 17:27:10 +0000 Subject: [PATCH 079/229] feat: add resolve_history_root and year-scoped ensure_topic_dir in history paths --- goga/history/__init__.py | 9 ++++- goga/history/paths.py | 33 ++++++++++++----- tests/history/test_facade.py | 7 ++-- tests/history/test_paths.py | 72 ++++++++++++++++++++++++++++++++---- 4 files changed, 101 insertions(+), 20 deletions(-) diff --git a/goga/history/__init__.py b/goga/history/__init__.py index 3ca1e921..04226e6f 100644 --- a/goga/history/__init__.py +++ b/goga/history/__init__.py @@ -9,7 +9,13 @@ from .git import resolve_current_branch_name from .naming import current_year, normalize_topic_slug -from .paths import ensure_topic_dir, resolve_topic_dir, resolve_topic_file, topic_exists +from .paths import ( + ensure_topic_dir, + resolve_history_root, + resolve_topic_dir, + resolve_topic_file, + topic_exists, +) from .status import TopicRecord, TopicStatus, collect_topic_statuses, resolve_topic_status from .tree import HistoryYear, collect_history_tree @@ -23,6 +29,7 @@ "ensure_topic_dir", "normalize_topic_slug", "resolve_current_branch_name", + "resolve_history_root", "resolve_topic_dir", "resolve_topic_file", "resolve_topic_status", diff --git a/goga/history/paths.py b/goga/history/paths.py index 06eea056..46dfdb8e 100644 --- a/goga/history/paths.py +++ b/goga/history/paths.py @@ -1,10 +1,10 @@ """Topic addressing for the history domain. The routines declared in the cell CODEMANIFEST with ``location: paths.py``: -the private history-root helper shared by the cell's modules, the two pure -path composers (topic directory and artifact file), the read-only occupancy -oracle, and the idempotent directory creator. Composers never touch the -filesystem — creation belongs to ``ensure_topic_dir`` alone. +the public history-root composer (the private helper delegates to it), the +two pure path composers (topic directory and artifact file), the read-only +occupancy oracle, and the idempotent directory creator. Composers never touch +the filesystem — creation belongs to ``ensure_topic_dir`` alone. """ from __future__ import annotations @@ -14,11 +14,25 @@ from .naming import current_year, normalize_topic_slug -def _history_root() -> Path: - """Return the history tree root relative to the caller's working directory.""" +def resolve_history_root() -> Path: + """Return the root path of the history tree. + + The single source of the tree location for every consumer reading the + topic tree of a git ref — the path is composed at the caller's working + directory and nothing is created or checked. + + Returns: + The history tree path ``.goga/history/`` — relative to the caller's + working directory, not created. + """ return Path(".goga") / "history" +def _history_root() -> Path: + """Return the history tree root — delegated to the public composer.""" + return resolve_history_root() + + def resolve_topic_dir(topic: str, year: str | None = None) -> Path: """Compute the directory path of a history topic. @@ -91,8 +105,8 @@ def topic_exists(topic: str, year: str | None = None) -> bool: return resolve_topic_dir(topic, year).is_dir() -def ensure_topic_dir(name: str) -> Path: - """Create the directory of a history topic for the current year. +def ensure_topic_dir(name: str, year: str | None = None) -> Path: + """Create the directory of a history topic of a year. Idempotent: an existing topic directory is a success, not a conflict — deciding whether a topic may be created belongs to the caller. @@ -100,6 +114,7 @@ def ensure_topic_dir(name: str) -> Path: Args: name: Topic input — a branch name or an already-normalized slug. + year: Optional year as four digits; ``None`` means the current year. Returns: The topic directory path that now exists. @@ -109,6 +124,6 @@ def ensure_topic_dir(name: str) -> Path: OSError: Propagated from ``mkdir`` — unexpected OS failures are not swallowed. """ - topic_dir = resolve_topic_dir(name) + topic_dir = resolve_topic_dir(name, year) topic_dir.mkdir(parents=True, exist_ok=True) return topic_dir diff --git a/tests/history/test_facade.py b/tests/history/test_facade.py index 275e452f..0a741a84 100644 --- a/tests/history/test_facade.py +++ b/tests/history/test_facade.py @@ -1,6 +1,6 @@ """Facade contract test for the ``goga/history`` domain cell. -The cell CODEMANIFEST declares thirteen facade names: the twelve domain types +The cell CODEMANIFEST declares fourteen facade names: the thirteen domain types and routines of the ``naming``/``paths``/``status``/``tree`` modules plus the git branch reader embedded from the nested ``goga.history.git`` leaf cell (the ``->resolve_current_branch_name: {}`` re-export). @@ -20,6 +20,7 @@ "ensure_topic_dir", "normalize_topic_slug", "resolve_current_branch_name", + "resolve_history_root", "resolve_topic_dir", "resolve_topic_file", "resolve_topic_status", @@ -28,8 +29,8 @@ class TestHistoryFacade: - def test_history_facade_exports_thirteen_names(self) -> None: - """The facade ``__all__`` is exactly the thirteen contract names, alphabetical.""" + def test_history_facade_exports_fourteen_names(self) -> None: + """The facade ``__all__`` is exactly the fourteen contract names, alphabetical.""" assert goga.history.__all__ == _HISTORY_FACADE_ALL for name in _HISTORY_FACADE_ALL: assert hasattr(goga.history, name), f"{name} is not defined on goga.history" diff --git a/tests/history/test_paths.py b/tests/history/test_paths.py index 6ffd51e7..b3ea6708 100644 --- a/tests/history/test_paths.py +++ b/tests/history/test_paths.py @@ -1,10 +1,11 @@ """Contract and logic tests for the routines declared in ``goga/history/CODEMANIFEST`` with ``location: paths.py``: +- ``resolve_history_root() -> Path`` - ``resolve_topic_dir(topic: str, year: str | None = None) -> Path`` - ``resolve_topic_file(topic: str, filename: str, year: str | None = None) -> Path`` - ``topic_exists(topic: str, year: str | None = None) -> bool`` -- ``ensure_topic_dir(name: str) -> Path`` +- ``ensure_topic_dir(name: str, year: str | None = None) -> Path`` The path composers are pure with respect to the filesystem; ``ensure_topic_dir`` is the only mutating routine. The single mock target is ``naming.datetime`` @@ -24,6 +25,7 @@ from goga.history import naming, paths from goga.history.paths import ( ensure_topic_dir, + resolve_history_root, resolve_topic_dir, resolve_topic_file, topic_exists, @@ -43,11 +45,13 @@ def now() -> datetime: class TestPathsContract: def test_routines_are_importable_from_module_and_callable(self) -> None: - """All four routines are importable from ``goga.history.paths`` and callable.""" + """All five routines are importable from ``goga.history.paths`` and callable.""" + assert callable(resolve_history_root) assert callable(resolve_topic_dir) assert callable(resolve_topic_file) assert callable(topic_exists) assert callable(ensure_topic_dir) + assert paths.resolve_history_root is resolve_history_root assert paths.resolve_topic_dir is resolve_topic_dir assert paths.resolve_topic_file is resolve_topic_file assert paths.topic_exists is topic_exists @@ -57,11 +61,18 @@ def test_facade_reexports_the_paths_names(self) -> None: """The paths routines are importable from the domain facade.""" import goga.history + assert goga.history.resolve_history_root is resolve_history_root assert goga.history.resolve_topic_dir is resolve_topic_dir assert goga.history.resolve_topic_file is resolve_topic_file assert goga.history.topic_exists is topic_exists assert goga.history.ensure_topic_dir is ensure_topic_dir - for name in ("resolve_topic_dir", "resolve_topic_file", "topic_exists", "ensure_topic_dir"): + for name in ( + "resolve_history_root", + "resolve_topic_dir", + "resolve_topic_file", + "topic_exists", + "ensure_topic_dir", + ): assert name in goga.history.__all__ def test_resolve_topic_dir_signature(self) -> None: @@ -100,25 +111,48 @@ def test_topic_exists_signature(self) -> None: hints = typing.get_type_hints(topic_exists) assert hints == {"topic": str, "year": str | None, "return": bool} + def test_resolve_history_root_signature(self) -> None: + """``resolve_history_root() -> Path`` — no parameters at all.""" + signature = inspect.signature(resolve_history_root) + assert list(signature.parameters) == [] + hints = typing.get_type_hints(resolve_history_root) + assert hints == {"return": Path} + def test_ensure_topic_dir_signature(self) -> None: - """``ensure_topic_dir(name: str) -> Path`` — one positional-or-keyword parameter.""" + """``ensure_topic_dir(name: str, year: str | None = None) -> Path`` — year is a kwarg.""" signature = inspect.signature(ensure_topic_dir) - assert list(signature.parameters) == ["name"] + assert list(signature.parameters) == ["name", "year"] assert all( parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) + assert signature.parameters["year"].default is None hints = typing.get_type_hints(ensure_topic_dir) - assert hints == {"name": str, "return": Path} + assert hints == {"name": str, "year": str | None, "return": Path} + bound = inspect.signature(ensure_topic_dir).bind(name="X", year="2025") + assert bound.arguments == {"name": "X", "year": "2025"} def test_history_root_helper_points_at_the_tree(self) -> None: - """The private helper answers the relative history root.""" + """The private helper delegates to the public composer — one source of the root.""" assert paths._history_root() == Path(".goga") / "history" + assert paths._history_root() == resolve_history_root() # --- Logic tests --- +class TestResolveHistoryRoot: + def test_resolve_history_root_composes_path( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The composer answers the relative root — pure, nothing is created.""" + monkeypatch.chdir(tmp_path) + result = resolve_history_root() + assert result == Path(".goga") / "history" + assert not result.exists() + assert not (tmp_path / ".goga").exists() + + class TestResolveTopicDir: def test_resolve_topic_dir_composes_and_normalizes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Branch input normalizes; no year means the current year; nothing is created.""" @@ -197,6 +231,30 @@ def test_topic_exists_absent_root_is_false(self, tmp_path: Path, monkeypatch: py class TestEnsureTopicDir: + def test_ensure_topic_dir_creates_explicit_year( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An explicit year scopes creation to that year — the D1 current-year-only fix.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object(naming, "datetime", _FixedClock): + created = ensure_topic_dir("Feature/Foo_Bar", year="2025") + repeated = ensure_topic_dir("Feature/Foo_Bar", year="2025") + expected = Path(".goga/history/2025/feature-foo-bar") + assert created == expected + assert repeated == expected + assert (tmp_path / ".goga" / "history" / "2025" / "feature-foo-bar").is_dir() + assert not (tmp_path / ".goga" / "history" / "2031").exists() + + def test_ensure_topic_dir_defaults_to_current_year( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Without a year the current year applies — the pre-existing behavior stands.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object(naming, "datetime", _FixedClock): + created = ensure_topic_dir("X") + assert created == Path(".goga/history/2031/x") + assert (tmp_path / ".goga" / "history" / "2031" / "x").is_dir() + def test_ensure_topic_dir_creates_idempotently( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From e2bf3e715b923fdf996809e904062596ee6dc575 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 17:34:46 +0000 Subject: [PATCH 080/229] feat: multi-status model in history status.py with scale facade embeddings --- goga/commands/history/history.py | 12 +- goga/commands/history/render.py | 15 +-- goga/history/__init__.py | 15 ++- goga/history/status.py | 119 ++++++++++---------- tests/history/test_facade.py | 31 +++-- tests/history/test_status.py | 187 +++++++++++++++++++++---------- 6 files changed, 236 insertions(+), 143 deletions(-) diff --git a/goga/commands/history/history.py b/goga/commands/history/history.py index 54f5ce40..060e7603 100644 --- a/goga/commands/history/history.py +++ b/goga/commands/history/history.py @@ -16,7 +16,7 @@ import click from ...history import ( - TopicStatus, + assemble_status_scale, collect_history_tree, collect_topic_statuses, ensure_topic_dir, @@ -87,10 +87,10 @@ def status( empty result prints nothing and exits 0 — it is not an error. The topics come out alphabetically; the domain sorts, this command does not re-sort. """ - resolved: list[TopicStatus] = [] + scale = assemble_status_scale() for name in statuses: try: - resolved.append(TopicStatus(name)) + scale.resolve_status(name) except ValueError as exc: raise click.ClickException(f"unknown status name: {name!r}") from exc @@ -100,12 +100,12 @@ def status( if filter_slug == "": raise click.ClickException(f"topic filter {topic!r} normalizes to an empty topic slug") - records = collect_topic_statuses(year) + records = collect_topic_statuses(year, scale) if topic is not None: records = [record for record in records if filter_slug in record.topic] if statuses: - allowed = set(resolved) - records = [record for record in records if record.status in allowed] + requested = set(statuses) + records = [record for record in records if set(record.statuses) & requested] render_topic_statuses(records) ctx.exit(0) diff --git a/goga/commands/history/render.py b/goga/commands/history/render.py index 078e0471..3a2b5a83 100644 --- a/goga/commands/history/render.py +++ b/goga/commands/history/render.py @@ -31,20 +31,21 @@ def render_history_tree(tree: list[HistoryYear]) -> None: def render_topic_statuses(records: list[TopicRecord]) -> None: - """Render the status view — one flat ``topic [status]`` line per record. + """Render the status view — one flat ``topic [status] …`` line per record. The topic prints plain with a trailing space and no newline; the bracketed - status display name follows as the one colored segment (``cyan``). A - non-empty ``NO_COLOR`` keeps the segment plain — click does not honor the - variable, so it is checked explicitly. An empty input renders nothing. + status names follow, space-separated, as the colored segment + (``cyan``). A non-empty ``NO_COLOR`` keeps the segments plain — click does + not honor the variable, so it is checked explicitly. An empty input + renders nothing. Args: records: The records to print — already filtered by the caller. """ for record in records: click.echo(f"{record.topic} ", nl=False) - status_segment = f"[{record.status.value}]" + status_segments = " ".join(f"[{status_name}]" for status_name in record.statuses) if os.environ.get("NO_COLOR"): - click.echo(status_segment) + click.echo(status_segments) else: - click.secho(status_segment, fg="cyan") + click.secho(status_segments, fg="cyan") diff --git a/goga/history/__init__.py b/goga/history/__init__.py index 04226e6f..f4ff5d3d 100644 --- a/goga/history/__init__.py +++ b/goga/history/__init__.py @@ -2,9 +2,10 @@ Topic identity (the slug grammar and the current year), topic addressing (directory and artifact file paths, existence, creation), the topic status -model, and tree traversal. The git branch reader lives in the nested leaf cell -``goga.history.git`` and is re-exported on this facade — the embedding -declared in ``goga/history/CODEMANIFEST``. +listing, and tree traversal. The git branch reader lives in the nested leaf +cell ``goga.history.git`` and the status scale in the ``goga.history.statuses`` +subcell — both re-exported on this facade, the embeddings declared in +``goga/history/CODEMANIFEST``. """ from .git import resolve_current_branch_name @@ -16,13 +17,17 @@ resolve_topic_file, topic_exists, ) -from .status import TopicRecord, TopicStatus, collect_topic_statuses, resolve_topic_status +from .status import TopicRecord, collect_topic_statuses, resolve_topic_status +from .statuses import Stage, StatusRegistry, StatusScale, assemble_status_scale from .tree import HistoryYear, collect_history_tree __all__: list[str] = [ "HistoryYear", + "Stage", + "StatusRegistry", + "StatusScale", "TopicRecord", - "TopicStatus", + "assemble_status_scale", "collect_history_tree", "collect_topic_statuses", "current_year", diff --git a/goga/history/status.py b/goga/history/status.py index ca9fcb14..3bf6fc80 100644 --- a/goga/history/status.py +++ b/goga/history/status.py @@ -1,103 +1,108 @@ -"""Topic status model for the history domain. +"""Topic status listing for the history domain. The entities declared in the cell CODEMANIFEST with ``location: status.py``: -the fixed eight-member status value set, the per-topic record of the status -listing, the read-only resolver that walks the artifact progression, and the -year collector. Both filesystem routines only probe — nothing is created or -changed; filtering and rendering belong to the consumer. +the per-topic record of the status listing and the two read-only resolvers +that walk a topic directory and a whole year against the caller's assembled +status scale. Both routines only probe — nothing is created or changed; the +scale itself belongs to the statuses subcell and is assembled once per +command run, never per topic. Filtering and rendering belong to the consumer. """ from __future__ import annotations from dataclasses import dataclass -from enum import Enum from pathlib import Path from .naming import current_year from .paths import _history_root - - -class TopicStatus(Enum): - """Fixed value set of a history topic status. - - Each member names the process stage reached by the topic's deepest - present artifact; ``value`` is the display string consumers filter and - render by. - """ - - empty = "empty" - defined = "defined" - discovered = "discovered" - backlog = "backlog" - designed = "designed" - specified = "specified" - planned = "planned" - done = "done" +from .statuses import StatusScale, assemble_status_scale @dataclass(frozen=True, kw_only=True) class TopicRecord: - """One topic of a year paired with its resolved status. + """One topic of a year paired with its maximal present statuses. Attributes: topic: The topic slug — the directory name of the topic. - status: The status resolved for the topic. + statuses: The qualified names of the maximal present statuses, in + scale order. """ topic: str - status: TopicStatus + statuses: list[str] -# Defined after TopicStatus — each row pairs a progression artifact with the -# status its presence reports; the deepening order is the contract. -_ARTIFACT_PROGRESSION: list[tuple[str, TopicStatus]] = [ - ("prd.md", TopicStatus.defined), - ("adr.md", TopicStatus.discovered), - ("task.md", TopicStatus.backlog), - ("arch.md", TopicStatus.designed), - ("design.md", TopicStatus.specified), - ("plan.md", TopicStatus.planned), - ("completed/plan.md", TopicStatus.done), -] - - -def resolve_topic_status(topic_dir: Path) -> TopicStatus: - """Resolve the status of one topic from its directory content. - - The artifacts of the progression are probed in deepening order and the - deepest present one wins — ``completed/plan.md`` is last in the list, so - its presence outranks every flat artifact. Files outside the progression - are ignored; an empty or missing directory resolves to ``empty``. +def resolve_topic_status(topic_dir: Path, scale: StatusScale) -> list[str]: + """Resolve the maximal present statuses of one topic from its directory content. Args: topic_dir: The topic directory path. + scale: The assembled status scale. Returns: - The status of the topic. Read-only — the directory content is probed, - never changed. + The qualified names of the maximal present statuses, in scale order. + Read-only — the directory content is read, never changed. A topic + with no artifact present yields the single built-in name ``empty``. + + Algorithm: + 1. List the artifact paths present in the directory, relative to it + 2. Compute the maximal present statuses via ``scale`` + 3. No artifact present yields the single built-in name empty + + Requirements: + Nested artifact paths are honored — a status artifact may sit in a + subdirectory of the topic directory. + + Constraints: + Do not assemble the scale here — the caller owns the single assembly + per command run. + Do not consider files outside the scale. """ - resolved = TopicStatus.empty - for artifact, artifact_status in _ARTIFACT_PROGRESSION: - if (topic_dir / artifact).is_file(): - resolved = artifact_status - return resolved + paths = [ + path.relative_to(topic_dir).as_posix() + for path in topic_dir.rglob("*") + if path.is_file() + ] + return scale.maximal_present(paths) -def collect_topic_statuses(year: str | None = None) -> list[TopicRecord]: - """Collect every topic of one year with its resolved status. +def collect_topic_statuses( + year: str | None = None, scale: StatusScale | None = None +) -> list[TopicRecord]: + """Collect every topic of one year with its maximal present statuses. Args: year: Optional year as four digits; ``None`` (or the empty string an empty CLI value produces) means the current year. + scale: Optional assembled status scale; ``None`` assembles it once + here. Returns: One ``TopicRecord`` per topic, sorted alphabetically by topic — the full year, unfiltered. An absent year yields an empty list, not an error; stray files in the year directory are not topics. + + Algorithm: + 1. Resolve the year — ``year`` when given, otherwise the current year + 2. Resolve the scale — ``scale`` when given, otherwise assemble it + once + 3. List the topic directories of that year; an absent year yields no + records + 4. Resolve the statuses of each topic via ``resolve_topic_status`` + 5. Assemble the records sorted alphabetically by topic and return + them + + Constraints: + Do not filter — filtering belongs to the consumer. + Do not render — output shaping belongs to the consumer. """ + resolved_scale = scale or assemble_status_scale() resolved_year = year or current_year() year_dir = _history_root() / resolved_year if not year_dir.is_dir(): return [] topics = sorted(path.name for path in year_dir.iterdir() if path.is_dir()) - return [TopicRecord(topic=topic, status=resolve_topic_status(year_dir / topic)) for topic in topics] + return [ + TopicRecord(topic=topic, statuses=resolve_topic_status(year_dir / topic, resolved_scale)) + for topic in topics + ] diff --git a/tests/history/test_facade.py b/tests/history/test_facade.py index 0a741a84..06c5c11b 100644 --- a/tests/history/test_facade.py +++ b/tests/history/test_facade.py @@ -1,9 +1,11 @@ """Facade contract test for the ``goga/history`` domain cell. -The cell CODEMANIFEST declares fourteen facade names: the thirteen domain types -and routines of the ``naming``/``paths``/``status``/``tree`` modules plus the -git branch reader embedded from the nested ``goga.history.git`` leaf cell (the -``->resolve_current_branch_name: {}`` re-export). +The cell CODEMANIFEST declares seventeen facade names: the domain types and +routines of the ``naming``/``paths``/``status``/``tree`` modules, the git +branch reader embedded from the nested ``goga.history.git`` leaf cell, and the +four status scale names embedded from the ``goga.history.statuses`` subcell +(the ``->`` re-exports). The former single-status enum ``TopicStatus`` is +deleted by the contract — the multi-status scale replaces it. """ from __future__ import annotations @@ -12,8 +14,11 @@ _HISTORY_FACADE_ALL = [ "HistoryYear", + "Stage", + "StatusRegistry", + "StatusScale", "TopicRecord", - "TopicStatus", + "assemble_status_scale", "collect_history_tree", "collect_topic_statuses", "current_year", @@ -29,8 +34,8 @@ class TestHistoryFacade: - def test_history_facade_exports_fourteen_names(self) -> None: - """The facade ``__all__`` is exactly the fourteen contract names, alphabetical.""" + def test_history_facade_exports_seventeen_names(self) -> None: + """The facade ``__all__`` is exactly the seventeen contract names, alphabetical.""" assert goga.history.__all__ == _HISTORY_FACADE_ALL for name in _HISTORY_FACADE_ALL: assert hasattr(goga.history, name), f"{name} is not defined on goga.history" @@ -41,3 +46,15 @@ def test_history_facade_embeds_the_git_branch_reader(self) -> None: goga.history.resolve_current_branch_name is goga.history.git.resolve_current_branch_name ) + + def test_history_facade_embeds_the_status_scale(self) -> None: + """The embedded scale names are the statuses subcell's objects, not copies.""" + assert goga.history.StatusScale is goga.history.statuses.StatusScale + assert goga.history.Stage is goga.history.statuses.Stage + assert goga.history.StatusRegistry is goga.history.statuses.StatusRegistry + assert goga.history.assemble_status_scale is goga.history.statuses.assemble_status_scale + + def test_history_facade_dropped_the_single_status_enum(self) -> None: + """``TopicStatus`` is deleted from the facade — the scale replaces it.""" + assert "TopicStatus" not in goga.history.__all__ + assert not hasattr(goga.history, "TopicStatus") diff --git a/tests/history/test_status.py b/tests/history/test_status.py index da70d069..51ea558d 100644 --- a/tests/history/test_status.py +++ b/tests/history/test_status.py @@ -1,15 +1,17 @@ """Contract and logic tests for the entities declared in ``goga/history/CODEMANIFEST`` with ``location: status.py``: -- ``TopicStatus()`` — the fixed eight-member status value set -- ``TopicRecord(topic: str, status: TopicStatus)`` -- ``resolve_topic_status(topic_dir: Path) -> status: TopicStatus`` -- ``collect_topic_statuses(year: str | None = None) -> records: list[TopicRecord]`` - -The resolver and the collector are read-only with respect to the filesystem. -The single mock target is ``naming.datetime`` (the mandated bare-``now()`` -point), patched at the import site; filesystem fixtures use ``tmp_path`` + -``monkeypatch.chdir``. +- ``TopicRecord(topic: str, statuses: list[str])`` +- ``resolve_topic_status(topic_dir: Path, scale: StatusScale) -> statuses: list[str]`` +- ``collect_topic_statuses(year: str | None = None, scale: StatusScale | None = None) -> records: list[TopicRecord]`` + +The resolver and the collector are read-only with respect to the filesystem; +the scale is a parameter assembled once per command run — never here per +topic. The mocks are patched at their import sites: ``naming.datetime`` (the +mandated bare-``now()`` point) and ``status.assemble_status_scale`` (the +assembly reuse counter). Filesystem fixtures use ``tmp_path`` + +``monkeypatch.chdir``; the scale fixtures are hand-assembled lists of +``Stage``, per the design scenarios. """ from __future__ import annotations @@ -25,10 +27,10 @@ from goga.history import naming, status from goga.history.status import ( TopicRecord, - TopicStatus, collect_topic_statuses, resolve_topic_status, ) +from goga.history.statuses import Stage, StatusScale class _FixedClock: @@ -39,13 +41,38 @@ def now() -> datetime: return datetime(2031, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock +def _builtin_scale() -> StatusScale: + """Deterministic built-in scale — eight entries with the contract artifacts.""" + return StatusScale( + stages=[ + Stage(name="empty", filepath=""), + Stage(name="defined", filepath="prd.md"), + Stage(name="discovered", filepath="adr.md"), + Stage(name="backlog", filepath="task.md"), + Stage(name="designed", filepath="arch.md"), + Stage(name="specified", filepath="design.md"), + Stage(name="planned", filepath="plan.md"), + Stage(name="done", filepath="completed/plan.md"), + ] + ) + + +def _tool_scale() -> StatusScale: + """Built-in scale plus one tool entry anchored after ``planned``.""" + return StatusScale( + stages=[ + *_builtin_scale().stages, + Stage(name="mkdocs.published", filepath="mkdocs/published.md", after="planned"), + ] + ) + + # --- Contract tests --- class TestStatusContract: def test_entities_are_importable_from_module_and_callable(self) -> None: - """All four entities are importable from ``goga.history.status``.""" - assert status.TopicStatus is TopicStatus + """All three entities are importable from ``goga.history.status``.""" assert status.TopicRecord is TopicRecord assert callable(resolve_topic_status) assert callable(collect_topic_statuses) @@ -56,65 +83,54 @@ def test_facade_reexports_the_status_names(self) -> None: """The status entities are importable from the domain facade.""" import goga.history - assert goga.history.TopicStatus is TopicStatus assert goga.history.TopicRecord is TopicRecord assert goga.history.resolve_topic_status is resolve_topic_status assert goga.history.collect_topic_statuses is collect_topic_statuses - for name in ("TopicStatus", "TopicRecord", "resolve_topic_status", "collect_topic_statuses"): + for name in ("TopicRecord", "resolve_topic_status", "collect_topic_statuses"): assert name in goga.history.__all__ - def test_topic_status_fixed_value_set(self) -> None: - """Eight members; each value is the display name; lookup by value works.""" - assert [member.value for member in TopicStatus] == [ - "empty", - "defined", - "discovered", - "backlog", - "designed", - "specified", - "planned", - "done", - ] - assert TopicStatus("planned") is TopicStatus.planned - with pytest.raises(ValueError, match="is not a valid TopicStatus"): - TopicStatus("bogus") + def test_single_status_enum_is_deleted(self) -> None: + """``TopicStatus`` and the artifact progression constant are gone from the module.""" + assert not hasattr(status, "TopicStatus") + assert not hasattr(status, "_ARTIFACT_PROGRESSION") def test_topic_record_is_frozen_kw_only_dataclass(self) -> None: - """``@dataclass(frozen=True, kw_only=True)`` with the fields ``topic`` and ``status``.""" + """``@dataclass(frozen=True, kw_only=True)`` with the fields ``topic`` and ``statuses``.""" assert dataclasses.is_dataclass(TopicRecord) assert TopicRecord.__dataclass_params__.frozen is True assert TopicRecord.__dataclass_params__.kw_only is True - assert typing.get_type_hints(TopicRecord) == {"topic": str, "status": TopicStatus} - record = TopicRecord(topic="t", status=TopicStatus.planned) + assert typing.get_type_hints(TopicRecord) == {"topic": str, "statuses": list[str]} + record = TopicRecord(topic="t", statuses=["planned", "mkdocs.published"]) assert record.topic == "t" - assert record.status is TopicStatus.planned + assert record.statuses == ["planned", "mkdocs.published"] with pytest.raises(dataclasses.FrozenInstanceError): record.topic = "other" # type: ignore[misc] with pytest.raises(TypeError): - TopicRecord("t", TopicStatus.planned) # type: ignore[misc] + TopicRecord("t", ["planned"]) # type: ignore[misc] def test_resolve_topic_status_signature(self) -> None: - """``resolve_topic_status(topic_dir: Path) -> TopicStatus`` — one positional-or-keyword parameter.""" + """``resolve_topic_status(topic_dir: Path, scale: StatusScale) -> list[str]``.""" signature = inspect.signature(resolve_topic_status) - assert list(signature.parameters) == ["topic_dir"] + assert list(signature.parameters) == ["topic_dir", "scale"] assert all( parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) hints = typing.get_type_hints(resolve_topic_status) - assert hints == {"topic_dir": Path, "return": TopicStatus} + assert hints == {"topic_dir": Path, "scale": StatusScale, "return": list[str]} def test_collect_topic_statuses_signature(self) -> None: - """``collect_topic_statuses(year: str | None = None) -> list[TopicRecord]``.""" + """``collect_topic_statuses(year=None, scale=None) -> list[TopicRecord]``.""" signature = inspect.signature(collect_topic_statuses) - assert list(signature.parameters) == ["year"] + assert list(signature.parameters) == ["year", "scale"] assert all( parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None + assert signature.parameters["scale"].default is None hints = typing.get_type_hints(collect_topic_statuses) - assert hints == {"year": str | None, "return": list[TopicRecord]} + assert hints == {"year": str | None, "scale": StatusScale | None, "return": list[TopicRecord]} # --- Logic tests --- @@ -124,19 +140,19 @@ class TestResolveTopicStatus: @pytest.mark.parametrize( ("artifact", "expected"), [ - ("prd.md", TopicStatus.defined), - ("adr.md", TopicStatus.discovered), - ("task.md", TopicStatus.backlog), - ("arch.md", TopicStatus.designed), - ("design.md", TopicStatus.specified), - ("plan.md", TopicStatus.planned), - ("completed/plan.md", TopicStatus.done), + ("prd.md", ["defined"]), + ("adr.md", ["discovered"]), + ("task.md", ["backlog"]), + ("arch.md", ["designed"]), + ("design.md", ["specified"]), + ("plan.md", ["planned"]), + ("completed/plan.md", ["done"]), ], ) def test_resolve_topic_status_progression( self, artifact: str, - expected: TopicStatus, + expected: list[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -146,7 +162,7 @@ def test_resolve_topic_status_progression( artifact_path = topic_dir / artifact artifact_path.parent.mkdir(parents=True, exist_ok=True) artifact_path.write_text("artifact", encoding="utf-8") - assert resolve_topic_status(topic_dir) is expected + assert resolve_topic_status(topic_dir, _builtin_scale()) == expected def test_resolve_topic_status_completed_wins_over_flat( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -159,22 +175,38 @@ def test_resolve_topic_status_completed_wins_over_flat( (topic_dir / "plan.md").write_text("flat artifact", encoding="utf-8") (topic_dir / "completed").mkdir() (topic_dir / "completed" / "plan.md").write_text("nested artifact", encoding="utf-8") - assert resolve_topic_status(topic_dir) is TopicStatus.done + assert resolve_topic_status(topic_dir, _builtin_scale()) == ["done"] def test_resolve_topic_status_empty_when_no_artifact_present( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """An empty or absent directory, and files outside the progression, resolve to empty.""" + """An empty or absent directory, and files outside the scale, resolve to empty.""" monkeypatch.chdir(tmp_path) year_dir = tmp_path / ".goga" / "history" / "2026" empty_dir = year_dir / "empty-topic" empty_dir.mkdir(parents=True) - assert resolve_topic_status(empty_dir) is TopicStatus.empty - assert resolve_topic_status(year_dir / "absent-topic") is TopicStatus.empty + assert resolve_topic_status(empty_dir, _builtin_scale()) == ["empty"] + assert resolve_topic_status(year_dir / "absent-topic", _builtin_scale()) == ["empty"] stray_dir = year_dir / "stray-topic" stray_dir.mkdir(parents=True) - (stray_dir / "notes.md").write_text("outside the progression", encoding="utf-8") - assert resolve_topic_status(stray_dir) is TopicStatus.empty + (stray_dir / "notes.md").write_text("outside the scale", encoding="utf-8") + assert resolve_topic_status(stray_dir, _builtin_scale()) == ["empty"] + + def test_resolve_topic_status_multi_statuses( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A tool artifact outranks the built-in entry it is anchored after.""" + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + tool_topic = year_dir / "release-1-3-0" + (tool_topic / "mkdocs").mkdir(parents=True) + (tool_topic / "plan.md").write_text("plan", encoding="utf-8") + (tool_topic / "mkdocs" / "published.md").write_text("published", encoding="utf-8") + assert resolve_topic_status(tool_topic, _tool_scale()) == ["mkdocs.published"] + plain_topic = year_dir / "plain" + plain_topic.mkdir(parents=True) + (plain_topic / "plan.md").write_text("plan", encoding="utf-8") + assert resolve_topic_status(plain_topic, _tool_scale()) == ["planned"] class TestCollectTopicStatuses: @@ -190,18 +222,51 @@ def test_collect_topic_statuses_sorted_records( (year_dir / "mid").mkdir(parents=True) (year_dir / "mid" / "prd.md").write_text("prd", encoding="utf-8") (year_dir / "stray.txt").write_text("not a topic", encoding="utf-8") - records = collect_topic_statuses(year="2026") + records = collect_topic_statuses(year="2026", scale=_builtin_scale()) assert [record.topic for record in records] == ["alpha", "mid", "zeta"] - assert records[0].status is TopicStatus.planned - assert records[1].status is TopicStatus.defined - assert records[2].status is TopicStatus.empty + assert records[0].statuses == ["planned"] + assert records[1].statuses == ["defined"] + assert records[2].statuses == ["empty"] + + def test_collect_topic_statuses_reuses_scale( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A passed scale is used as-is — the assembly is not repeated per run.""" + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "alpha").mkdir(parents=True) + (year_dir / "alpha" / "plan.md").write_text("plan", encoding="utf-8") + (year_dir / "beta").mkdir(parents=True) + assembly = mock.patch.object(status, "assemble_status_scale", wraps=status.assemble_status_scale) + with assembly as assemble: + records = collect_topic_statuses("2026", _builtin_scale()) + assert [record.topic for record in records] == ["alpha", "beta"] + assert records[0].statuses == ["planned"] + assert records[1].statuses == ["empty"] + assert assemble.call_count == 0 + + def test_collect_topic_statuses_assembles_scale_once_when_none( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``scale=None`` assembles the scale exactly once for the whole run.""" + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "alpha").mkdir(parents=True) + (year_dir / "alpha" / "plan.md").write_text("plan", encoding="utf-8") + (year_dir / "beta").mkdir(parents=True) + with mock.patch.object(status, "assemble_status_scale", return_value=_builtin_scale()) as assemble: + records = collect_topic_statuses("2026") + assert [record.topic for record in records] == ["alpha", "beta"] + assert records[0].statuses == ["planned"] + assert records[1].statuses == ["empty"] + assert assemble.call_count == 1 def test_collect_topic_statuses_absent_year_empty( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """An absent year yields an empty list — not an error, and nothing is created.""" monkeypatch.chdir(tmp_path) - assert collect_topic_statuses(year="1999") == [] + assert collect_topic_statuses(year="1999", scale=_builtin_scale()) == [] assert not (tmp_path / ".goga").exists() def test_collect_topic_statuses_empty_year_string_means_current( @@ -212,7 +277,7 @@ def test_collect_topic_statuses_empty_year_string_means_current( (tmp_path / ".goga" / "history" / "2025" / "old-topic").mkdir(parents=True) (tmp_path / ".goga" / "history" / "2031" / "t").mkdir(parents=True) with mock.patch.object(naming, "datetime", _FixedClock): - records = collect_topic_statuses(year="") + records = collect_topic_statuses(year="", scale=_builtin_scale()) assert [record.topic for record in records] == ["t"] assert "2025" not in [record.topic for record in records] assert "2031" not in [record.topic for record in records] From 0140bb2c15b4aa242e652aa7548ae7a18f751fd7 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 17:42:39 +0000 Subject: [PATCH 081/229] feat: multi-status history status command with segment-per-status renderer --- goga/commands/history/history.py | 22 +++-- tests/commands/history/test_history.py | 13 +++ .../commands/history/test_history_command.py | 85 ++++++++++++++++++- tests/commands/history/test_render.py | 34 ++++++-- 4 files changed, 139 insertions(+), 15 deletions(-) diff --git a/goga/commands/history/history.py b/goga/commands/history/history.py index 060e7603..450461ed 100644 --- a/goga/commands/history/history.py +++ b/goga/commands/history/history.py @@ -79,15 +79,21 @@ def status( topic: str | None = None, statuses: tuple[str, ...] = (), ) -> None: - """Print the topics of one year, one 'topic [status]' line each. - - YEAR defaults to the current year and is never printed. -t/--topic keeps - the topics whose slug contains the normalized filter as a substring; - -s/--status keeps the given statuses; both filters combine by AND. An - empty result prints nothing and exits 0 — it is not an error. The topics - come out alphabetically; the domain sorts, this command does not re-sort. + """Print the topics of one year, one 'topic [status] [status] …' line each. + + A topic carries its maximal statuses in scale order — one bracketed + segment per status, tool statuses included. YEAR defaults to the current + year and is never printed. -t/--topic keeps the topics whose slug + contains the normalized filter as a substring; -s/--status keeps the + topics carrying at least one of the requested statuses; both filters + combine by AND. An empty result prints nothing and exits 0 — it is not + an error. The topics come out alphabetically; the domain sorts, this + command does not re-sort. """ - scale = assemble_status_scale() + try: + scale = assemble_status_scale() + except (ValueError, ImportError) as exc: + raise click.ClickException(str(exc)) from exc for name in statuses: try: scale.resolve_status(name) diff --git a/tests/commands/history/test_history.py b/tests/commands/history/test_history.py index 538b244e..d6289219 100644 --- a/tests/commands/history/test_history.py +++ b/tests/commands/history/test_history.py @@ -144,6 +144,19 @@ def test_history_status_unknown_status_name(self) -> None: assert "unknown status name" in result.stderr assert "Traceback" not in result.stderr + def test_history_status_broken_scale_assembly_fails_cleanly(self) -> None: + """A fatal scale assembly error (broken goga_tool_* import) surfaces clean.""" + runner = CliRunner() + with mock.patch.object( + _history_module, + "assemble_status_scale", + side_effect=ImportError("package goga_tool_bad failed to import: boom"), + ): + result = runner.invoke(history, ["status"]) + assert result.exit_code == 1 + assert "goga_tool_bad" in result.stderr + assert "Traceback" not in result.stderr + def test_history_status_empty_topic_filter_is_error(self) -> None: """A -t value normalizing to an empty slug is an error, not match-all.""" result = CliRunner().invoke(history, ["status", "-t", "Релиз"]) diff --git a/tests/commands/history/test_history_command.py b/tests/commands/history/test_history_command.py index b5b24243..580805b1 100644 --- a/tests/commands/history/test_history_command.py +++ b/tests/commands/history/test_history_command.py @@ -15,15 +15,19 @@ from __future__ import annotations +import inspect import sys from datetime import datetime from pathlib import Path +from types import ModuleType +from typing import Any from unittest import mock import pytest from click.testing import CliRunner from goga.commands.history import history from goga.history import naming +from goga.history.statuses import assembly as assembly_module # goga.commands.history.history is shadowed in the package __init__ by the # history click group, so attribute access through the package gives the @@ -39,6 +43,28 @@ def now() -> datetime: return datetime(2031, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock +def _fake_tool_packages(monkeypatch: pytest.MonkeyPatch) -> None: + """Isolate the scale assembly to one fake tool package. + + ``goga_tool_mkdocs`` registers ``published`` anchored after ``planned``, + so a topic carrying both ``plan.md`` and ``mkdocs/published.md`` has the + single maximal status ``mkdocs.published``. The enumeration patch keeps + the real tool packages of the environment out of the assembled scale. + """ + + def register_topic_statuses(statuses: Any) -> None: + statuses.register(name="published", filepath="mkdocs/published.md", after="planned") + + module = ModuleType("goga_tool_mkdocs") + module.register_topic_statuses = register_topic_statuses + monkeypatch.setitem(sys.modules, "goga_tool_mkdocs", module) + monkeypatch.setattr( + assembly_module, + "packages_distributions", + lambda: {"goga_tool_mkdocs": ["goga-tool-mkdocs"]}, + ) + + # --- Cross-entity interactions --- @@ -69,6 +95,52 @@ def test_history_list_renders_tree(self, tmp_path: Path, monkeypatch: pytest.Mon class TestHistoryStatus: + def test_status_signature_defaults(self) -> None: + """``status(year=None, topic=None, statuses=())`` — the declared shape.""" + callback = history.commands["status"].callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["ctx", "year", "topic", "statuses"] + assert signature.parameters["year"].default is None + assert signature.parameters["topic"].default is None + assert signature.parameters["statuses"].default == () + + def test_history_status_multi_status_line_and_filter( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A registered tool status is maximal and filterable — one segment per status.""" + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "release-1-3-0" / "mkdocs").mkdir(parents=True) + (year_dir / "release-1-3-0" / "plan.md").write_text("plan\n", encoding="utf-8") + (year_dir / "release-1-3-0" / "mkdocs" / "published.md").write_text("pub\n", encoding="utf-8") + (year_dir / "alpha").mkdir() + (year_dir / "alpha" / "prd.md").write_text("prd\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + _fake_tool_packages(monkeypatch) + + result = CliRunner().invoke(history, ["status", "2026", "-s", "mkdocs.published"]) + + assert result.exit_code == 0 + assert result.output.splitlines() == ["release-1-3-0 [mkdocs.published]"] + assert "alpha" not in result.output + + def test_history_status_unknown_filter_name_clean_error(self) -> None: + """An unknown -s name fails before any collection — clean, exit 1.""" + with mock.patch.object(_history_module, "collect_topic_statuses") as collect_mock: + result = CliRunner().invoke(history, ["status", "-s", "bogus"]) + + assert result.exit_code == 1 + assert "unknown status name: 'bogus'" in result.stderr + assert "Traceback" not in result.stderr + collect_mock.assert_not_called() + + def test_history_status_empty_topic_filter_rejected(self) -> None: + """A -t value normalizing to an empty slug is rejected, not match-all.""" + result = CliRunner().invoke(history, ["status", "-t", "???"]) + + assert result.exit_code == 1 + assert "empty topic slug" in result.stderr + assert result.stdout == "" + def test_history_status_filters_and(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """-t and -s combine by AND; the year is resolved, never printed.""" year_dir = tmp_path / ".goga" / "history" / "2026" @@ -217,7 +289,18 @@ def test_history_ensure_explicit_name_creates_dir( class TestHistoryEmptyResults: - def test_history_status_empty_result_exit_zero( + def test_history_status_empty_result_prints_nothing_exit_zero( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A year without topics prints nothing and exits 0 — not an error.""" + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["status", "1999"]) + + assert result.exit_code == 0 + assert result.output == "" + + def test_history_status_filter_matching_nothing_exit_zero( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A filter matching nothing prints nothing and exits 0 — not an error.""" diff --git a/tests/commands/history/test_render.py b/tests/commands/history/test_render.py index b2040950..cf87ae3f 100644 --- a/tests/commands/history/test_render.py +++ b/tests/commands/history/test_render.py @@ -19,7 +19,7 @@ import pytest from goga.commands.history import render from goga.commands.history.render import render_history_tree, render_topic_statuses -from goga.history import HistoryYear, TopicRecord, TopicStatus +from goga.history import HistoryYear, TopicRecord # --- Contract tests --- @@ -81,21 +81,43 @@ def test_render_topic_statuses_no_color_plain( ) -> None: """A non-empty NO_COLOR keeps every segment plain — no ANSI escapes.""" monkeypatch.setenv("NO_COLOR", "1") - render_topic_statuses([TopicRecord(topic="t", status=TopicStatus.planned)]) + render_topic_statuses([TopicRecord(topic="t", statuses=["planned"])]) captured = capsys.readouterr() assert captured.out.strip() == "t [planned]" assert "\x1b" not in captured.out + def test_render_topic_statuses_one_segment_per_status( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """Every status of a record prints as its own space-separated segment.""" + monkeypatch.setenv("NO_COLOR", "1") + render_topic_statuses( + [TopicRecord(topic="release-1-3-0", statuses=["done", "mkdocs.published"])] + ) + captured = capsys.readouterr() + assert captured.out == "release-1-3-0 [done] [mkdocs.published]\n" + assert "\x1b" not in captured.out + def test_render_topic_statuses_colors_status_segment( self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: - """One color on the status segment; the topic stays plain with no newline.""" + """One color on the status segments; the topic stays plain with no newline.""" monkeypatch.delenv("NO_COLOR", raising=False) with mock.patch.object(render.click, "secho") as secho_mock: - render_topic_statuses([TopicRecord(topic="t", status=TopicStatus.planned)]) + render_topic_statuses([TopicRecord(topic="t", statuses=["planned"])]) assert secho_mock.call_args == mock.call("[planned]", fg="cyan") assert capsys.readouterr().out == "t " + def test_render_topic_statuses_colors_every_segment( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """The colored call carries the whole segment sequence of the record.""" + monkeypatch.delenv("NO_COLOR", raising=False) + with mock.patch.object(render.click, "secho") as secho_mock: + render_topic_statuses([TopicRecord(topic="t", statuses=["done", "mkdocs.published"])]) + assert secho_mock.call_args == mock.call("[done] [mkdocs.published]", fg="cyan") + assert capsys.readouterr().out == "t " + def test_render_topic_statuses_empty_input_prints_nothing(self, capsys: pytest.CaptureFixture[str]) -> None: """Empty records render not a single line — an empty result is not an error.""" render_topic_statuses([]) @@ -107,8 +129,8 @@ def test_render_topic_statuses_keeps_input_order( """Records print in the given order — the renderer neither sorts nor filters.""" monkeypatch.setenv("NO_COLOR", "1") records = [ - TopicRecord(topic="zeta", status=TopicStatus.empty), - TopicRecord(topic="alpha", status=TopicStatus.done), + TopicRecord(topic="zeta", statuses=["empty"]), + TopicRecord(topic="alpha", statuses=["done"]), ] render_topic_statuses(records) assert capsys.readouterr().out == "zeta [empty]\nalpha [done]\n" From 1280e26ccbe30c8de37e5a430183a8885fa9e258 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 17:45:02 +0000 Subject: [PATCH 082/229] feat: topics git cell package skeleton with test layout --- goga/topics/__init__.py | 11 +++++++++++ goga/topics/git/__init__.py | 10 ++++++++++ tests/topics/__init__.py | 0 tests/topics/git/__init__.py | 0 4 files changed, 21 insertions(+) create mode 100644 goga/topics/__init__.py create mode 100644 goga/topics/git/__init__.py create mode 100644 tests/topics/__init__.py create mode 100644 tests/topics/git/__init__.py diff --git a/goga/topics/__init__.py b/goga/topics/__init__.py new file mode 100644 index 00000000..3f47df1e --- /dev/null +++ b/goga/topics/__init__.py @@ -0,0 +1,11 @@ +"""Topics domain cell — the work-tracker view of the history tree. + +The cross-branch topic inventory of one year with per-topic statuses, the +switch-identifier resolution and switching orchestration, and the +fresh-work creation procedure. Topic identity, addressing, and statuses +belong to the history facade; git access belongs to the nested leaf cell +``goga.topics.git``. Mutations are local-only and happen strictly after +every decision is made. +""" + +__all__: list[str] = [] diff --git a/goga/topics/git/__init__.py b/goga/topics/git/__init__.py new file mode 100644 index 00000000..8a7ad4d6 --- /dev/null +++ b/goga/topics/git/__init__.py @@ -0,0 +1,10 @@ +"""Git-access cell for the topics domain. + +The branch-ref inventory, the file-path reading of a ref tree, and the +bounded set of host-side branch mutations — checking out a local branch, +creating a local branch from a remote-tracking ref, create-and-switch to a +new branch, and the working-tree cleanliness probe. It is environment +access, not topic logic — every decision belongs to the caller. +""" + +__all__: list[str] = [] diff --git a/tests/topics/__init__.py b/tests/topics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/topics/git/__init__.py b/tests/topics/git/__init__.py new file mode 100644 index 00000000..e69de29b From 5fb9ed48f63973735450098d7722bacf23db3195 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 17:48:26 +0000 Subject: [PATCH 083/229] feat: BranchRef and list_branch_refs branch inventory in topics git cell --- goga/topics/git/__init__.py | 4 +- goga/topics/git/refs.py | 98 ++++++++++++++++++++++++ tests/topics/git/test_refs.py | 136 ++++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 goga/topics/git/refs.py create mode 100644 tests/topics/git/test_refs.py diff --git a/goga/topics/git/__init__.py b/goga/topics/git/__init__.py index 8a7ad4d6..70df4805 100644 --- a/goga/topics/git/__init__.py +++ b/goga/topics/git/__init__.py @@ -7,4 +7,6 @@ access, not topic logic — every decision belongs to the caller. """ -__all__: list[str] = [] +from .refs import BranchRef, list_branch_refs + +__all__: list[str] = ["BranchRef", "list_branch_refs"] diff --git a/goga/topics/git/refs.py b/goga/topics/git/refs.py new file mode 100644 index 00000000..220531fe --- /dev/null +++ b/goga/topics/git/refs.py @@ -0,0 +1,98 @@ +"""The branch-ref inventory of the topics-domain git cell. + +The entities declared in the cell CODEMANIFEST with ``location: refs.py``: +one branch ref of the repository inventory — a local branch or a +remote-tracking ref — and the read-only enumerator that merges both kinds +into one alphabetically sorted inventory. Every git invocation follows the +``git`` practice — ``subprocess.run`` with ``check=True``, captured output, +and ``GIT_TERMINAL_PROMPT=0`` in the environment. +""" + +from __future__ import annotations + +import os +import subprocess +from dataclasses import dataclass + + +@dataclass(frozen=True, kw_only=True) +class BranchRef: + """One branch ref of the repository inventory. + + Attributes: + name: The display name — the short branch name for a local ref, + ``<remote>/<branch>`` for a remote-tracking ref. + remote: ``True`` when the ref is remote-tracking. + + Requirements: + The display name is the identity used by consumers — no + reshortening, no normalization. + """ + + name: str + remote: bool + + +def list_branch_refs() -> list[BranchRef]: + """Enumerate the branch refs of the repository. + + Asks git for the local branches and the remote-tracking refs (as they + exist locally — no network), drops the ``*/HEAD`` symrefs, and merges + both answers into one inventory sorted alphabetically by display name. + A local branch and its remote twin stay two distinct refs — collapsing + them belongs to the caller. + + Returns: + Every branch ref, sorted alphabetically by display name. + + Algorithm: + 1. Ask git for the local branch refs + 2. Ask git for the remote-tracking refs + 3. Merge both into one inventory sorted alphabetically by display + name + + Requirements: + Read-only — no ref is created, moved, or deleted. + + No network — remote-tracking refs as they exist locally. + + Constraints: + Do not deduplicate — a local branch and its remote twin are two + distinct refs here; collapsing them belongs to the caller. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + ref listing itself (propagated — the caller wraps it). + OSError: unexpected OS-level failures of the git invocations (e.g. a + missing git binary). + """ + local = _refs_under("refs/heads", remote=False) + tracked = _refs_under("refs/remotes", remote=True) + return sorted([*local, *tracked], key=lambda ref: ref.name) + + +def _refs_under(ref_prefix: str, remote: bool) -> list[BranchRef]: + """Run one ``for-each-ref`` invocation and parse it into branch refs. + + Args: + ref_prefix: The ref namespace to list — ``refs/heads`` or + ``refs/remotes``. + remote: Whether the listed refs are remote-tracking. + + Returns: + The parsed refs of the namespace, in git order. Refs whose display + name ends with ``/HEAD`` (the ``<remote>/HEAD`` symrefs) are not + branches and are dropped. + """ + result = subprocess.run( + ["git", "for-each-ref", "--format=%(refname:short)", ref_prefix], + check=True, + capture_output=True, + text=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) + return [ + BranchRef(name=line, remote=remote) + for line in result.stdout.splitlines() + if line and not line.endswith("/HEAD") + ] diff --git a/tests/topics/git/test_refs.py b/tests/topics/git/test_refs.py new file mode 100644 index 00000000..bf9f5e8b --- /dev/null +++ b/tests/topics/git/test_refs.py @@ -0,0 +1,136 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/git/CODEMANIFEST`` with ``location: refs.py``: + +- ``BranchRef(name, remote)`` — one branch ref of the repository inventory +- ``list_branch_refs()`` — the read-only enumerator merging local branches + and remote-tracking refs into one sorted inventory + +The subprocess call is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched. +""" + +from __future__ import annotations + +import dataclasses +import os +import subprocess +from collections.abc import Callable +from unittest import mock + +import pytest +from goga.topics.git import BranchRef, list_branch_refs + + +def _git_answer(stdout: str) -> subprocess.CompletedProcess[str]: + """A successful ``for-each-ref`` invocation answering ``stdout``.""" + return subprocess.CompletedProcess(args=["git"], returncode=0, stdout=stdout, stderr="") + + +def _answering_run( + heads: str = "", remotes: str = "" +) -> Callable[..., subprocess.CompletedProcess[str]]: + """A ``subprocess.run`` mock answering by the requested ref prefix.""" + + def run(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + outputs = {"refs/heads": heads, "refs/remotes": remotes} + return _git_answer(outputs[command[-1]]) + + return run + + +# --- Contract tests --- + + +class TestRefsContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """``BranchRef`` and ``list_branch_refs`` live on the cell facade.""" + import goga.topics.git as cell + + assert cell.BranchRef is BranchRef + assert cell.list_branch_refs is list_branch_refs + assert "BranchRef" in cell.__all__ + assert "list_branch_refs" in cell.__all__ + + def test_branch_ref_is_a_frozen_kw_only_dataclass(self) -> None: + """``BranchRef(name=..., remote=...)`` — frozen, keyword-only.""" + ref = BranchRef(name="feat/a", remote=False) + + assert ref.name == "feat/a" + assert ref.remote is False + + with pytest.raises(TypeError): + BranchRef("feat/a", False) # type: ignore[misc] + with pytest.raises(dataclasses.FrozenInstanceError): + ref.name = "renamed" # type: ignore[misc] + + def test_branch_ref_declares_name_and_remote_only(self) -> None: + """The record carries exactly the two declared fields.""" + fields = {field.name for field in dataclasses.fields(BranchRef)} + assert fields == {"name", "remote"} + + def test_list_branch_refs_takes_no_arguments_and_returns_refs(self) -> None: + """``list_branch_refs() -> list[BranchRef]`` — no parameters.""" + with mock.patch("goga.topics.git.refs.subprocess.run", side_effect=_answering_run()): + refs = list_branch_refs() + + assert isinstance(refs, list) + assert all(isinstance(ref, BranchRef) for ref in refs) + + def test_git_invocations_follow_the_git_practice(self) -> None: + """Two ``for-each-ref`` calls — check/capture/text and a muted prompt.""" + run = mock.Mock(side_effect=_answering_run()) + with mock.patch("goga.topics.git.refs.subprocess.run", run): + list_branch_refs() + + assert run.call_count == 2 + for call in run.call_args_list: + command = call.args[0] + assert command[:2] == ["git", "for-each-ref"] + assert "--format=%(refname:short)" in command + assert call.kwargs["check"] is True + assert call.kwargs["capture_output"] is True + assert call.kwargs["text"] is True + assert call.kwargs["env"] == {**os.environ, "GIT_TERMINAL_PROMPT": "0"} + prefixes = [call.args[0][-1] for call in run.call_args_list] + assert prefixes == ["refs/heads", "refs/remotes"] + + +# --- Logic tests --- + + +class TestListBranchRefs: + def test_list_branch_refs_merges_and_sorts(self) -> None: + """Local and remote refs merge sorted by display; ``*/HEAD`` dropped.""" + run = mock.Mock( + side_effect=_answering_run( + heads="main\nfeat/a\n", + remotes="origin/HEAD\norigin/feat/a\norigin/feat/b\n", + ) + ) + with mock.patch("goga.topics.git.refs.subprocess.run", run): + refs = list_branch_refs() + + assert [ref.name for ref in refs] == [ + "feat/a", + "main", + "origin/feat/a", + "origin/feat/b", + ] + assert refs[0].remote is False + assert refs[1].remote is False + assert refs[2].remote is True + assert refs[3].remote is True + # No deduplication — the local branch and its remote twin are both kept. + assert "feat/a" in [ref.name for ref in refs] + assert "origin/feat/a" in [ref.name for ref in refs] + # The origin/HEAD symref is not a branch — dropped. + assert "origin/HEAD" not in [ref.name for ref in refs] + + def test_list_branch_refs_empty_repository(self) -> None: + """An empty inventory is the norm, answered by exactly two calls.""" + run = mock.Mock(side_effect=_answering_run()) + with mock.patch("goga.topics.git.refs.subprocess.run", run): + refs = list_branch_refs() + + assert refs == [] + assert run.call_count == 2 From 7b1722423eee51f29fa7a19facf733c7e6abeb30 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 17:52:24 +0000 Subject: [PATCH 084/229] feat: ref tree reading and switch mutations finalizing topics git cell --- goga/topics/git/.usages/refs-and-switching.md | 2 +- goga/topics/git/__init__.py | 17 +- goga/topics/git/switch.py | 146 ++++++++++++++++++ goga/topics/git/trees.py | 62 ++++++++ tests/topics/git/test_refs.py | 4 +- tests/topics/git/test_switch.py | 146 ++++++++++++++++++ tests/topics/git/test_trees.py | 91 +++++++++++ 7 files changed, 463 insertions(+), 5 deletions(-) create mode 100644 goga/topics/git/switch.py create mode 100644 goga/topics/git/trees.py create mode 100644 tests/topics/git/test_switch.py create mode 100644 tests/topics/git/test_trees.py diff --git a/goga/topics/git/.usages/refs-and-switching.md b/goga/topics/git/.usages/refs-and-switching.md index a7ca2133..ee53c19d 100644 --- a/goga/topics/git/.usages/refs-and-switching.md +++ b/goga/topics/git/.usages/refs-and-switching.md @@ -47,7 +47,7 @@ from goga.topics.git import ( ) if is_working_tree_clean(): - checkout_local_branch("feature-foo") # existing local branch + checkout_local_branch("feature-foo") # existing local branch create_branch_from_remote_tracking(remote_ref) # remote-only host ``` diff --git a/goga/topics/git/__init__.py b/goga/topics/git/__init__.py index 70df4805..4a3a9c73 100644 --- a/goga/topics/git/__init__.py +++ b/goga/topics/git/__init__.py @@ -8,5 +8,20 @@ """ from .refs import BranchRef, list_branch_refs +from .switch import ( + checkout_local_branch, + create_and_switch_branch, + create_branch_from_remote_tracking, + is_working_tree_clean, +) +from .trees import read_ref_tree_paths -__all__: list[str] = ["BranchRef", "list_branch_refs"] +__all__: list[str] = [ + "BranchRef", + "checkout_local_branch", + "create_and_switch_branch", + "create_branch_from_remote_tracking", + "is_working_tree_clean", + "list_branch_refs", + "read_ref_tree_paths", +] diff --git a/goga/topics/git/switch.py b/goga/topics/git/switch.py new file mode 100644 index 00000000..f60dd975 --- /dev/null +++ b/goga/topics/git/switch.py @@ -0,0 +1,146 @@ +"""The branch mutations of the topics-domain git cell. + +The entities declared in the cell CODEMANIFEST with +``location: switch.py``: checking out an existing local branch, creating +a local branch from a remote-tracking ref, create-and-switch to a new +branch, and the working-tree cleanliness probe. They are bounded +host-side git actions — when a switch is allowed stays with the caller. +Every git invocation follows the ``git`` practice. +""" + +from __future__ import annotations + +import os +import subprocess + +from .refs import BranchRef + + +def checkout_local_branch(branch: str) -> None: + """Switch the working copy to an existing local branch. + + Args: + branch: The short name of the local branch. + + Algorithm: + 1. Ask git to check out the branch + 2. A git failure surfaces as a clean error + + Requirements: + The mutation is local — no network, no push, no fetch. + + Constraints: + Do not create the branch — it must exist. + + Do not decide when a switch is allowed — the caller owns the + cleanliness policy. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + checkout itself (propagated — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + _run_git(["git", "switch", branch]) + + +def create_branch_from_remote_tracking(ref: BranchRef) -> None: + """Create a local branch from a remote-tracking ref and switch to it. + + The local branch takes the short name of the ref — the part after the + first slash of its display name. + + Args: + ref: The remote-tracking ref to branch from. + + Algorithm: + 1. Ask git to create a local branch named after the short name of + ``ref`` at the ref's commit and switch to it + 2. A git failure surfaces as a clean error + + Requirements: + The mutation is local — the remote-tracking ref as it exists + locally, no network. + + Constraints: + Do not update the remote-tracking ref — no fetch. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + branch creation itself (propagated — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + short = ref.name.partition("/")[2] + _run_git(["git", "switch", "-c", short, ref.name]) + + +def create_and_switch_branch(branch_name: str) -> None: + """Create a branch with the name exactly as entered and switch to it. + + Args: + branch_name: The branch name as entered by the user. + + Algorithm: + 1. Ask git to create the branch named exactly ``branch_name`` and + switch to it + 2. A name git rejects surfaces as a clean error + + Requirements: + The name is taken verbatim — no normalization, no suffixing. + + The mutation is local. + + Constraints: + Do not validate the name characters — git owns name validity. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + branch creation itself (propagated — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + _run_git(["git", "switch", "-c", branch_name]) + + +def is_working_tree_clean() -> bool: + """Probe whether the working copy carries uncommitted changes. + + Returns: + True when the working tree and the index match the branch head. + + Algorithm: + 1. Ask git for the working tree state + 2. Report the answer as a plain boolean + + Requirements: + Read-only — nothing is staged, committed, or reset. + + Constraints: + Do not act on a dirty tree — the caller owns the policy. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + state probe itself (propagated — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + return _run_git(["git", "status", "--porcelain"]).stdout.strip() == "" + + +def _run_git(command: list[str]) -> subprocess.CompletedProcess[str]: + """Run one git invocation following the ``git`` practice. + + Args: + command: The argv of the invocation, starting with ``git``. + + Returns: + The completed invocation with captured text output. + """ + return subprocess.run( + command, + check=True, + capture_output=True, + text=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) diff --git a/goga/topics/git/trees.py b/goga/topics/git/trees.py new file mode 100644 index 00000000..b62a8dcc --- /dev/null +++ b/goga/topics/git/trees.py @@ -0,0 +1,62 @@ +"""The ref-tree reading of the topics-domain git cell. + +The entity declared in the cell CODEMANIFEST with ``location: trees.py``: +the file paths of one ref tree under a path prefix. ``ls-tree`` walks the +object database of the repository — no checkout, no worktree, no +temporary directory — and every git invocation follows the ``git`` +practice. +""" + +from __future__ import annotations + +import os +import subprocess + + +def read_ref_tree_paths(ref: str, prefix: str) -> list[str]: + """Read the file paths of one ref tree under a path prefix. + + Args: + ref: The ref to read — a display branch name as carried by + :class:`~goga.topics.git.refs.BranchRef`. + prefix: The path prefix to read under, relative to the repository + root. + + Returns: + Every file path under the prefix, relative to the repository root, + in the order git reports. + + Algorithm: + 1. Ask git for the recursive file listing of the ``ref`` tree + 2. Keep the paths that sit under ``prefix`` + 3. Return them in the order git reports + + Requirements: + One git invocation per ref. + + Read-only — the working copy, the index, and ``.git`` stay + untouched. + + A ref or prefix without matches yields an empty list — not an + error. + + Constraints: + Do not materialize the tree — no checkout, no worktree, no temp + directory. + + Do not inspect file contents — paths only. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + tree listing itself (propagated — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + result = subprocess.run( + ["git", "ls-tree", "-r", "--name-only", ref, "--", prefix], + check=True, + capture_output=True, + text=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) + return [path for path in result.stdout.splitlines() if path and path.startswith(prefix)] diff --git a/tests/topics/git/test_refs.py b/tests/topics/git/test_refs.py index bf9f5e8b..e41532ba 100644 --- a/tests/topics/git/test_refs.py +++ b/tests/topics/git/test_refs.py @@ -26,9 +26,7 @@ def _git_answer(stdout: str) -> subprocess.CompletedProcess[str]: return subprocess.CompletedProcess(args=["git"], returncode=0, stdout=stdout, stderr="") -def _answering_run( - heads: str = "", remotes: str = "" -) -> Callable[..., subprocess.CompletedProcess[str]]: +def _answering_run(heads: str = "", remotes: str = "") -> Callable[..., subprocess.CompletedProcess[str]]: """A ``subprocess.run`` mock answering by the requested ref prefix.""" def run(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: diff --git a/tests/topics/git/test_switch.py b/tests/topics/git/test_switch.py new file mode 100644 index 00000000..f3d59d20 --- /dev/null +++ b/tests/topics/git/test_switch.py @@ -0,0 +1,146 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/git/CODEMANIFEST`` with ``location: switch.py``: + +- ``checkout_local_branch(branch)`` — switch the working copy to an + existing local branch +- ``create_branch_from_remote_tracking(ref)`` — create a local branch + from a remote-tracking ref and switch to it +- ``create_and_switch_branch(branch_name)`` — create a branch with the + name exactly as entered and switch to it +- ``is_working_tree_clean()`` — the read-only cleanliness probe + +The subprocess call is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched. +""" + +from __future__ import annotations + +import inspect +import os +import subprocess +from unittest import mock + +from goga.topics.git import ( + BranchRef, + checkout_local_branch, + create_and_switch_branch, + create_branch_from_remote_tracking, + is_working_tree_clean, +) + + +def _git_answer(stdout: str = "") -> subprocess.CompletedProcess[str]: + """A successful git invocation answering ``stdout``.""" + return subprocess.CompletedProcess(args=["git"], returncode=0, stdout=stdout, stderr="") + + +def _commands_of(run: mock.Mock) -> list[list[str]]: + """The argv list of every invocation the mock received.""" + return [call.args[0] for call in run.call_args_list] + + +# --- Contract tests --- + + +class TestSwitchContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """All four switch routines live on the cell facade.""" + import goga.topics.git as cell + + assert cell.checkout_local_branch is checkout_local_branch + assert cell.create_branch_from_remote_tracking is create_branch_from_remote_tracking + assert cell.create_and_switch_branch is create_and_switch_branch + assert cell.is_working_tree_clean is is_working_tree_clean + for name in ( + "checkout_local_branch", + "create_branch_from_remote_tracking", + "create_and_switch_branch", + "is_working_tree_clean", + ): + assert name in cell.__all__ + + def test_declared_signatures(self) -> None: + """The routines take exactly the declared parameters.""" + assert list(inspect.signature(checkout_local_branch).parameters) == ["branch"] + assert list(inspect.signature(create_branch_from_remote_tracking).parameters) == ["ref"] + assert list(inspect.signature(create_and_switch_branch).parameters) == ["branch_name"] + assert list(inspect.signature(is_working_tree_clean).parameters) == [] + + def test_mutations_are_git_switch_invocations(self) -> None: + """The three mutations are bounded host-side ``git switch`` actions.""" + run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.switch.subprocess.run", run): + checkout_local_branch("feat/a") + create_branch_from_remote_tracking(BranchRef(name="origin/feat/b", remote=True)) + create_and_switch_branch("Feature/Foo_Bar") + + assert _commands_of(run) == [ + ["git", "switch", "feat/a"], + ["git", "switch", "-c", "feat/b", "origin/feat/b"], + ["git", "switch", "-c", "Feature/Foo_Bar"], + ] + + def test_cleanliness_probe_is_a_porcelain_invocation(self) -> None: + """The probe reads the working tree state — nothing else.""" + run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.switch.subprocess.run", run): + is_working_tree_clean() + + assert _commands_of(run) == [["git", "status", "--porcelain"]] + + def test_git_invocations_follow_the_git_practice(self) -> None: + """Every call — check/capture/text and a muted prompt.""" + calls = [ + (checkout_local_branch, ("feat/a",)), + (create_branch_from_remote_tracking, (BranchRef(name="origin/feat/b", remote=True),)), + (create_and_switch_branch, ("Feature/Foo_Bar",)), + (is_working_tree_clean, ()), + ] + run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.switch.subprocess.run", run): + for routine, args in calls: + routine(*args) + + assert run.call_count == len(calls) + for call in run.call_args_list: + kwargs = call.kwargs + assert kwargs["check"] is True + assert kwargs["capture_output"] is True + assert kwargs["text"] is True + assert kwargs["env"] == {**os.environ, "GIT_TERMINAL_PROMPT": "0"} + + +# --- Logic tests --- + + +class TestSwitchBehaviour: + def test_is_working_tree_clean_boolean(self) -> None: + """An empty porcelain report is clean; any entry is dirty.""" + with mock.patch("goga.topics.git.switch.subprocess.run", return_value=_git_answer("")): + assert is_working_tree_clean() is True + with mock.patch("goga.topics.git.switch.subprocess.run", return_value=_git_answer(" M x.py\n")): + assert is_working_tree_clean() is False + + def test_create_branch_from_remote_tracking_takes_the_short_name(self) -> None: + """The local branch is named after the part past the first slash.""" + run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.switch.subprocess.run", run): + create_branch_from_remote_tracking(BranchRef(name="origin/feat/b", remote=True)) + + assert _commands_of(run) == [["git", "switch", "-c", "feat/b", "origin/feat/b"]] + + def test_create_and_switch_branch_takes_the_name_verbatim(self) -> None: + """No normalization, no suffixing — the name goes to git as entered.""" + run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.switch.subprocess.run", run): + create_and_switch_branch("Feature/Foo_Bar") + + assert _commands_of(run) == [["git", "switch", "-c", "Feature/Foo_Bar"]] + + def test_checkout_local_branch_switches_without_creating(self) -> None: + """A plain checkout — no ``-c``, the branch must already exist.""" + run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.switch.subprocess.run", run): + checkout_local_branch("feat/a") + + assert _commands_of(run) == [["git", "switch", "feat/a"]] diff --git a/tests/topics/git/test_trees.py b/tests/topics/git/test_trees.py new file mode 100644 index 00000000..b969d419 --- /dev/null +++ b/tests/topics/git/test_trees.py @@ -0,0 +1,91 @@ +"""Contract and logic tests for the entity declared in +``goga/topics/git/CODEMANIFEST`` with ``location: trees.py``: + +- ``read_ref_tree_paths(ref, prefix)`` — the read-only file listing of one + ref tree under a path prefix, without checkout, worktree, or temp + directories + +The subprocess call is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched. +""" + +from __future__ import annotations + +import os +import subprocess +from unittest import mock + +from goga.topics.git import read_ref_tree_paths + + +def _git_answer(stdout: str) -> subprocess.CompletedProcess[str]: + """A successful ``ls-tree`` invocation answering ``stdout``.""" + return subprocess.CompletedProcess(args=["git"], returncode=0, stdout=stdout, stderr="") + + +# --- Contract tests --- + + +class TestTreesContract: + def test_entity_is_importable_from_the_cell_facade(self) -> None: + """``read_ref_tree_paths`` lives on the cell facade.""" + import goga.topics.git as cell + + assert cell.read_ref_tree_paths is read_ref_tree_paths + assert "read_ref_tree_paths" in cell.__all__ + + def test_signature_takes_ref_and_prefix_and_returns_paths(self) -> None: + """``read_ref_tree_paths(ref, prefix) -> list[str]``.""" + with ( + mock.patch( + "goga.topics.git.trees.subprocess.run", + return_value=_git_answer(".goga/history/2026/feat-a/plan.md\n"), + ) as run, + ): + paths = read_ref_tree_paths("feat-a", ".goga/history/") + + assert isinstance(paths, list) + assert all(isinstance(path, str) for path in paths) + assert run.call_count == 1 + + def test_git_invocation_follows_the_git_practice(self) -> None: + """One ``ls-tree -r --name-only`` call — check/capture/text, muted prompt.""" + run = mock.Mock(return_value=_git_answer("")) + with mock.patch("goga.topics.git.trees.subprocess.run", run): + read_ref_tree_paths("feat-a", ".goga/history/") + + assert run.call_count == 1 + command = run.call_args.args[0] + assert command == ["git", "ls-tree", "-r", "--name-only", "feat-a", "--", ".goga/history/"] + kwargs = run.call_args.kwargs + assert kwargs["check"] is True + assert kwargs["capture_output"] is True + assert kwargs["text"] is True + assert kwargs["env"] == {**os.environ, "GIT_TERMINAL_PROMPT": "0"} + + +# --- Logic tests --- + + +class TestReadRefTreePaths: + def test_read_ref_tree_paths_filters_prefix(self) -> None: + """Only paths under the prefix survive — one invocation per ref.""" + run = mock.Mock(return_value=_git_answer(".goga/history/2026/feat-a/plan.md\nREADME.md\n")) + with mock.patch("goga.topics.git.trees.subprocess.run", run): + result = read_ref_tree_paths("feat-a", ".goga/history/") + + assert result == [".goga/history/2026/feat-a/plan.md"] + assert run.call_count == 1 + + def test_read_ref_tree_paths_no_matches_empty(self) -> None: + """A ref or prefix without matches yields an empty list — not an error.""" + with mock.patch("goga.topics.git.trees.subprocess.run", return_value=_git_answer("")): + assert read_ref_tree_paths("feat-a", ".goga/history/") == [] + + def test_read_ref_tree_paths_keeps_git_order(self) -> None: + """Paths return in the order git reports them.""" + stdout = ".goga/history/2026/feat-a/plan.md\n.goga/history/2026/feat-a/prd.md\n" + with mock.patch("goga.topics.git.trees.subprocess.run", return_value=_git_answer(stdout)): + result = read_ref_tree_paths("feat-a", ".goga/history/") + + assert result == [".goga/history/2026/feat-a/plan.md", ".goga/history/2026/feat-a/prd.md"] From 477923bbac470a307e2b54703a909c5436e72d0f Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 18:04:23 +0000 Subject: [PATCH 085/229] feat: topics board cell with BoardRecord and collect_topic_board --- goga/topics/__init__.py | 4 +- goga/topics/board.py | 274 +++++++++++++++++++++++++++++++ tests/topics/conftest.py | 27 +++ tests/topics/test_board.py | 328 +++++++++++++++++++++++++++++++++++++ 4 files changed, 632 insertions(+), 1 deletion(-) create mode 100644 goga/topics/board.py create mode 100644 tests/topics/conftest.py create mode 100644 tests/topics/test_board.py diff --git a/goga/topics/__init__.py b/goga/topics/__init__.py index 3f47df1e..f4d7df19 100644 --- a/goga/topics/__init__.py +++ b/goga/topics/__init__.py @@ -8,4 +8,6 @@ every decision is made. """ -__all__: list[str] = [] +from .board import BoardRecord, collect_topic_board + +__all__: list[str] = ["BoardRecord", "collect_topic_board"] diff --git a/goga/topics/board.py b/goga/topics/board.py new file mode 100644 index 00000000..5a3dd762 --- /dev/null +++ b/goga/topics/board.py @@ -0,0 +1,274 @@ +"""The topic board of the topics domain. + +The entities declared in the cell CODEMANIFEST with ``location: board.py``: +one row of the board — a topic hosted by one branch — and the read-only +collector that merges the branch inventory, the ref trees of one year, and +the working copy of the current branch into the sorted inventory. Git access +follows the ``refs-and-switching`` patterns of the nested git cell; topic +identity, addressing, and statuses belong to the history facade. Git +infrastructure failures and the fatal scale-assembly import failure surface +as ``click.ClickException`` — the clean-error boundary of the domain. +""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass + +import click + +from ..history import ( + StatusScale, + assemble_status_scale, + current_year, + normalize_topic_slug, + resolve_current_branch_name, + resolve_history_root, + resolve_topic_dir, + resolve_topic_status, + topic_exists, +) +from .git import BranchRef, list_branch_refs, read_ref_tree_paths + +# One board row under construction — whether the hosting ref is +# remote-tracking and the row's maximal statuses. +_Row = tuple[bool, list[str]] + +# The minimum part count of a topic path — ``.goga/history/<year>/<slug>/<artifact>``. +_TOPIC_PATH_PARTS = 5 + + +@dataclass(frozen=True, kw_only=True) +class BoardRecord: + """One row of the topic board — a topic hosted by one branch. + + Attributes: + topic: The topic slug — the directory name of the topic. + branch: The display name of the hosting branch. + statuses: The qualified names of the maximal present statuses, in + scale order. + current: ``True`` when the row hosts the current working branch. + remote: ``True`` when the hosting ref is remote-tracking. + """ + + topic: str + branch: str + statuses: list[str] + current: bool + remote: bool + + +def collect_topic_board( + year: str | None = None, remote: bool = False +) -> list[BoardRecord]: + """Collect the cross-branch topic inventory of one year. + + Args: + year: Optional year as four digits; ``None`` means the current year. + remote: ``True`` reads remote-tracking refs instead of local branches. + + Returns: + One ``BoardRecord`` per hosted topic, sorted by scale order of the + first maximal status, then alphabetically by topic. Read-only — no + checkout, no worktree, no mutation of any kind; the working copy is + read but never changed. A year without topics yields an empty list — + not an error. + + Algorithm: + 1. Resolve the year — ``year`` when given, otherwise the current year + 2. Assemble the status scale via ``assemble_status_scale`` once + 3. Local mode enumerates local branches via ``list_branch_refs`` and + reads the current branch from the working copy via + ``resolve_current_branch_name``; remote mode enumerates the + remote-tracking ``BranchRef`` entries of the same inventory only. + Local mode takes the full inventory of ``list_branch_refs`` — a + topic hosted only by a remote-tracking ref keeps its row with the + remote marker + 4. Read the topic tree of every ref under the root resolved via + ``resolve_history_root`` with ``read_ref_tree_paths``, without + checkout + 5. For every ref, take the topics of the resolved year with their + artifact paths and compute the maximal statuses — the working copy + via ``resolve_topic_status``, every other ref via the + ``StatusScale`` + 6. Collapse a local branch and its remote twin into one row — the + local branch wins; different branches hosting one slug stay + separate rows + 7. Mark the row hosting the current branch + 8. Sort by scale order of the first maximal status, then + alphabetically by topic, and return the records + + Requirements: + The current branch is read from the working copy — uncommitted + progress is visible; remote mode shows it through its remote twin. + + Constraints: + Do not render — output shaping belongs to the consumer. + Do not cross the year boundary — other years are invisible here. + + Raises: + click.ClickException: a git infrastructure failure (its stderr when + git reports one, or a missing git binary), or the fatal + ``ImportError`` of the scale assembly — the broken tool package + is named in the message. + """ + try: + return _board_records(year, remote) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + except ImportError as exc: + raise click.ClickException(str(exc)) from exc + + +def _board_records(year: str | None, remote: bool) -> list[BoardRecord]: + """Build the board rows of one year — the traced algorithm, unwrapped. + + Args: + year: Optional year as four digits; ``None`` means the current year. + remote: ``True`` reads remote-tracking refs instead of local branches. + + Returns: + The sorted board records of the resolved year. + """ + resolved_year = year or current_year() + scale = assemble_status_scale() + inventory = list_branch_refs() + current = resolve_current_branch_name() + refs = [ref for ref in inventory if ref.remote] if remote else inventory + topics_by_ref = _year_topics_by_ref(refs, resolved_year) + + rows: dict[tuple[str, str], _Row] = {} + for ref in refs: + if remote or current is None or ref.name != current: + for slug, artifacts in topics_by_ref[ref.name].items(): + rows[(slug, ref.name)] = (ref.remote, scale.maximal_present(artifacts)) + continue + hosted = _current_branch_topic(current, resolved_year, scale) + if hosted is None: + continue + slug, statuses = hosted + rows[(slug, ref.name)] = (False, statuses) + + records = [ + BoardRecord( + topic=slug, + branch=branch, + statuses=statuses, + current=_marks_current(branch, current, remote), + remote=is_remote, + ) + for (slug, branch), (is_remote, statuses) in _collapse_remote_twins(rows).items() + ] + scale_order = {stage.name: index for index, stage in enumerate(scale.stages)} + records.sort(key=lambda record: (scale_order[record.statuses[0]], record.topic)) + return records + + +def _year_topics_by_ref(refs: list[BranchRef], year: str) -> dict[str, dict[str, list[str]]]: + """Read the topics of one year hosted by every given ref. + + One ``read_ref_tree_paths`` invocation per ref under the history root; + the shared entry point of the board and the switch resolution — both + walk the same ref trees without checkout. + + Args: + refs: The refs whose trees are read. + year: The resolved year as four digits. + + Returns: + The topics of the year per ref display name — ``{slug: [artifact, + ...]}`` with the artifact paths relative to the topic directory, + ready for ``StatusScale.maximal_present``. + """ + prefix = f"{resolve_history_root()}/" + return {ref.name: _year_topics(read_ref_tree_paths(ref.name, prefix), year) for ref in refs} + + +def _year_topics(paths: list[str], year: str) -> dict[str, list[str]]: + """Split the ref-tree paths of one year into its topics. + + Args: + paths: The file paths of one ref tree, relative to the repository + root. + year: The resolved year as four digits. + + Returns: + The topics of the year — ``{slug: [artifact, ...]}`` with the + artifact paths relative to the topic directory. + """ + topics: dict[str, list[str]] = {} + for path in paths: + parts = path.split("/") + if len(parts) < _TOPIC_PATH_PARTS or parts[2] != year: + continue + topics.setdefault(parts[3], []).append("/".join(parts[4:])) + return topics + + +def _current_branch_topic( + current: str, year: str, scale: StatusScale +) -> tuple[str, list[str]] | None: + """Read the current branch's own topic from the working copy. + + The slug guard runs first: ``resolve_topic_dir`` and ``topic_exists`` + raise ``ValueError`` on an empty slug before their existence check, and + a fully non-ASCII branch name is a legal input that simply hosts no + topic. + + Args: + current: The current branch name as git reports it. + year: The resolved year as four digits. + scale: The assembled status scale. + + Returns: + The current branch's slug with its maximal statuses, or ``None`` + when the branch hosts no topic of the year. + """ + slug = normalize_topic_slug(current) + if slug == "": + return None + if not topic_exists(current, year): + return None + return slug, resolve_topic_status(resolve_topic_dir(current, year), scale) + + +def _collapse_remote_twins(rows: dict[tuple[str, str], _Row]) -> dict[tuple[str, str], _Row]: + """Drop every remote row whose local twin hosts the same topic. + + Args: + rows: The board rows keyed by ``(slug, branch display name)``. + + Returns: + The rows without the collapsed remote twins — the local branch wins. + """ + local_keys = {key for key, row in rows.items() if not row[0]} + return { + key: row + for key, row in rows.items() + if row[0] is False or (key[0], _short_name(key[1])) not in local_keys + } + + +def _marks_current(branch: str, current: str | None, remote: bool) -> bool: + """Decide whether a row's branch hosts the current work in this mode. + + Args: + branch: The row's branch display name. + current: The current branch name, or ``None`` when there is none. + remote: Whether the board runs in remote mode. + + Returns: + ``True`` when the row hosts the current branch — by display name in + local mode, through the remote twin's short name in remote mode. + """ + if current is None: + return False + return _short_name(branch) == current if remote else branch == current + + +def _short_name(branch: str) -> str: + """Return the branch part of a display name — after the first ``/``.""" + return branch.partition("/")[2] diff --git a/tests/topics/conftest.py b/tests/topics/conftest.py new file mode 100644 index 00000000..3f8057bd --- /dev/null +++ b/tests/topics/conftest.py @@ -0,0 +1,27 @@ +"""Local fixtures of the topics domain tests.""" + +from __future__ import annotations + +import pytest +from goga.history.statuses import Stage, StatusScale + + +@pytest.fixture +def builtin_scale() -> StatusScale: + """Deterministic built-in scale — eight entries with the contract artifacts. + + The deepening order is the contract: empty, defined, discovered, backlog, + designed, specified, planned, done. + """ + return StatusScale( + stages=[ + Stage(name="empty", filepath=""), + Stage(name="defined", filepath="prd.md"), + Stage(name="discovered", filepath="adr.md"), + Stage(name="backlog", filepath="task.md"), + Stage(name="designed", filepath="arch.md"), + Stage(name="specified", filepath="design.md"), + Stage(name="planned", filepath="plan.md"), + Stage(name="done", filepath="completed/plan.md"), + ] + ) diff --git a/tests/topics/test_board.py b/tests/topics/test_board.py new file mode 100644 index 00000000..c2b1deaa --- /dev/null +++ b/tests/topics/test_board.py @@ -0,0 +1,328 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/CODEMANIFEST`` with ``location: board.py``: + +- ``BoardRecord(topic, branch, statuses, current, remote)`` — one row of the + topic board, a topic hosted by one branch +- ``collect_topic_board(year, remote)`` — the read-only cross-branch topic + inventory of one year + +The git boundary is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched; the working-copy +scenarios use ``tmp_path`` + ``monkeypatch.chdir`` with the real history +path routines, and the scale is the ``builtin_scale`` fixture. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import subprocess +import typing +from collections.abc import Callable +from pathlib import Path +from unittest import mock + +import click +import pytest +from goga.history.statuses import StatusScale +from goga.topics import BoardRecord, board, collect_topic_board +from goga.topics.git import BranchRef + +# --- Shared scenario helpers --- + + +def _trees_reader(trees: dict[str, list[str]]) -> Callable[..., list[str]]: + """A ``read_ref_tree_paths`` stand-in answering by ref display name.""" + + def read(ref: str, prefix: str) -> list[str]: + assert prefix == ".goga/history/", "the board reads under the history root only" + return [path for path in trees.get(ref, []) if path.startswith(prefix)] + + return read + + +def _wire_board( + monkeypatch: pytest.MonkeyPatch, + scale: StatusScale, + inventory: list[BranchRef], + trees: dict[str, list[str]], + current: str | None, +) -> None: + """Patch the board's import points: scale, git inventory, trees, branch.""" + monkeypatch.setattr(board, "assemble_status_scale", lambda: scale) + monkeypatch.setattr(board, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(board, "resolve_current_branch_name", lambda: current) + monkeypatch.setattr(board, "read_ref_tree_paths", _trees_reader(trees)) + + +def _working_copy_topic(cwd: Path, year: str, slug: str, artifacts: list[str]) -> None: + """Create the working-copy topic directory with its artifact files.""" + for artifact in artifacts: + path = cwd / ".goga" / "history" / year / slug / artifact + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("artifact", encoding="utf-8") + + +def _base_inventory() -> list[BranchRef]: + """The design-scenario inventory: two locals, two remote-tracking refs.""" + return [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/feat/a", remote=True), + BranchRef(name="origin/feat/b", remote=True), + BranchRef(name="main", remote=False), + ] + + +def _base_trees() -> dict[str, list[str]]: + """The design-scenario ref trees: one planned topic, one defined topic.""" + return { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "origin/feat/a": [".goga/history/2026/feat-a/plan.md"], + "origin/feat/b": [".goga/history/2026/feat-b/prd.md"], + "main": ["README.md"], + } + + +def _rows(records: list[BoardRecord]) -> list[tuple[str, str, list[str], bool, bool]]: + """The records as plain tuples — topic, branch, statuses, current, remote.""" + return [ + (record.topic, record.branch, record.statuses, record.current, record.remote) + for record in records + ] + + +# --- Contract tests --- + + +class TestBoardContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """``BoardRecord`` and ``collect_topic_board`` live on the cell facade.""" + import goga.topics as cell + + assert cell.BoardRecord is BoardRecord + assert cell.collect_topic_board is collect_topic_board + assert "BoardRecord" in cell.__all__ + assert "collect_topic_board" in cell.__all__ + + def test_board_record_is_a_frozen_kw_only_dataclass(self) -> None: + """``@dataclass(frozen=True, kw_only=True)`` with the five declared fields.""" + assert dataclasses.is_dataclass(BoardRecord) + assert BoardRecord.__dataclass_params__.frozen is True + assert BoardRecord.__dataclass_params__.kw_only is True + assert typing.get_type_hints(BoardRecord) == { + "topic": str, + "branch": str, + "statuses": list[str], + "current": bool, + "remote": bool, + } + record = BoardRecord( + topic="feat-a", branch="feat/a", statuses=["planned"], current=True, remote=False + ) + assert record.topic == "feat-a" + assert record.branch == "feat/a" + assert record.statuses == ["planned"] + assert record.current is True + assert record.remote is False + with pytest.raises(dataclasses.FrozenInstanceError): + record.topic = "other" # type: ignore[misc] + with pytest.raises(TypeError): + BoardRecord("feat-a", "feat/a", ["planned"], True, False) # type: ignore[misc] + + def test_collect_topic_board_signature(self) -> None: + """``collect_topic_board(year=None, remote=False) -> list[BoardRecord]``.""" + signature = inspect.signature(collect_topic_board) + assert list(signature.parameters) == ["year", "remote"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + assert signature.parameters["remote"].default is False + hints = typing.get_type_hints(collect_topic_board) + assert hints == {"year": str | None, "remote": bool, "return": list[BoardRecord]} + + +# --- Logic tests --- + + +class TestCollectTopicBoard: + def test_collect_topic_board_local_collapses_twin_and_marks_current( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Local mode: full inventory, the twin collapses, the host is marked.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + _wire_board(monkeypatch, builtin_scale, _base_inventory(), _base_trees(), "feat/a") + + records = collect_topic_board("2026", remote=False) + + assert _rows(records) == [ + ("feat-b", "origin/feat/b", ["defined"], False, True), + ("feat-a", "feat/a", ["planned"], True, False), + ] + # The remote twin collapsed into the local row — the local branch wins. + assert "origin/feat/a" not in [record.branch for record in records] + # A ref without a topic of the year hosts no row. + assert "main" not in [record.branch for record in records] + # Sorting: feat-b (defined, scale position 1) precedes feat-a (planned, position 6). + assert [record.topic for record in records] == ["feat-b", "feat-a"] + + def test_collect_topic_board_remote_mode_twin_current( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Remote mode: remote-tracking rows only; the twin carries the marker.""" + monkeypatch.chdir(tmp_path) + _wire_board(monkeypatch, builtin_scale, _base_inventory(), _base_trees(), "feat/a") + statuses = mock.Mock() + monkeypatch.setattr(board, "resolve_topic_status", statuses) + + records = collect_topic_board("2026", remote=True) + + assert _rows(records) == [ + ("feat-b", "origin/feat/b", ["defined"], False, True), + ("feat-a", "origin/feat/a", ["planned"], True, True), + ] + assert [record.branch for record in records] == ["origin/feat/b", "origin/feat/a"] + # Remote mode never reads the working copy — the current branch shows + # through its remote twin. + assert statuses.call_count == 0 + + def test_collect_topic_board_year_without_topics_empty( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A year without hosted topics yields an empty list — not an error.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + _wire_board(monkeypatch, builtin_scale, _base_inventory(), _base_trees(), "feat/a") + + assert collect_topic_board("2030") == [] + + def test_current_branch_empty_slug_hosts_no_topic( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A fully non-ASCII current branch hosts nothing — and breaks nothing.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="🚀", remote=False), BranchRef(name="feat/a", remote=False)] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"]} + exists = mock.Mock(return_value=False) + monkeypatch.setattr(board, "topic_exists", exists) + _wire_board(monkeypatch, builtin_scale, inventory, trees, "🚀") + + records = collect_topic_board("2026") + + assert _rows(records) == [("feat-a", "feat/a", ["planned"], False, False)] + # The empty-slug guard runs before the existence oracle — the board is + # never crashed by the branch that cannot host a topic. + assert exists.call_count == 0 + + def test_collect_topic_board_no_current_branch( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """No current branch — no marker, every status from the ref trees.""" + monkeypatch.chdir(tmp_path) + _wire_board(monkeypatch, builtin_scale, _base_inventory(), _base_trees(), None) + statuses = mock.Mock() + monkeypatch.setattr(board, "resolve_topic_status", statuses) + + records = collect_topic_board("2026") + + assert _rows(records) == [ + ("feat-b", "origin/feat/b", ["defined"], False, True), + ("feat-a", "feat/a", ["planned"], False, False), + ] + assert all(not record.current for record in records) + # Without a current branch the working copy is not read at all. + assert statuses.call_count == 0 + + def test_board_sees_only_committed_artifacts_on_other_refs( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The current row sees uncommitted progress; the trees see commits only.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/feat/a", remote=True), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/notes.txt"], + "origin/feat/a": [".goga/history/2026/feat-a/notes.txt"], + } + _wire_board(monkeypatch, builtin_scale, inventory, trees, "feat/a") + + local_rows = _rows(collect_topic_board("2026", remote=False)) + remote_rows = _rows(collect_topic_board("2026", remote=True)) + + # The current branch reads the working copy — the uncommitted plan.md + # is visible. + assert local_rows == [("feat-a", "feat/a", ["planned"], True, False)] + # The same work through its remote twin reads the ref tree — the + # uncommitted artifact is invisible there. + assert remote_rows == [("feat-a", "origin/feat/a", ["empty"], True, True)] + + +class TestBoardInfrastructureBoundary: + def test_git_failure_surfaces_as_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A git infrastructure failure with stderr becomes a ``ClickException``.""" + monkeypatch.chdir(tmp_path) + failure = subprocess.CalledProcessError( + returncode=128, cmd=["git", "for-each-ref"], stderr="fatal: not a git repository" + ) + monkeypatch.setattr(board, "assemble_status_scale", lambda: builtin_scale) + monkeypatch.setattr(board, "list_branch_refs", mock.Mock(side_effect=failure)) + + with pytest.raises(click.ClickException) as raised: + collect_topic_board("2026") + + assert "fatal: not a git repository" in raised.value.message + + def test_missing_git_binary_surfaces_as_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A missing git binary becomes a ``ClickException``.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(board, "assemble_status_scale", lambda: builtin_scale) + monkeypatch.setattr(board, "list_branch_refs", mock.Mock(side_effect=FileNotFoundError("git"))) + + with pytest.raises(click.ClickException): + collect_topic_board("2026") + + def test_broken_tool_package_import_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The fatal scale-assembly ``ImportError`` keeps its package name.""" + monkeypatch.chdir(tmp_path) + broken = ImportError("package goga_tool_bad failed to import: boom") + monkeypatch.setattr(board, "assemble_status_scale", mock.Mock(side_effect=broken)) + + with pytest.raises(click.ClickException) as raised: + collect_topic_board("2026") + + assert raised.value.message == "package goga_tool_bad failed to import: boom" From ab7fa31aa7f44e9d2ef69fa4a79ca17b9b722acf Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 18:12:15 +0000 Subject: [PATCH 086/229] feat: topics switch resolution and switching orchestration in switching.py --- goga/topics/__init__.py | 9 +- goga/topics/switching.py | 333 ++++++++++++++++++++ tests/topics/test_switching.py | 558 +++++++++++++++++++++++++++++++++ 3 files changed, 899 insertions(+), 1 deletion(-) create mode 100644 goga/topics/switching.py create mode 100644 tests/topics/test_switching.py diff --git a/goga/topics/__init__.py b/goga/topics/__init__.py index f4d7df19..ad5006c5 100644 --- a/goga/topics/__init__.py +++ b/goga/topics/__init__.py @@ -9,5 +9,12 @@ """ from .board import BoardRecord, collect_topic_board +from .switching import SwitchCandidate, resolve_switch_candidates, switch_topic -__all__: list[str] = ["BoardRecord", "collect_topic_board"] +__all__: list[str] = [ + "BoardRecord", + "SwitchCandidate", + "collect_topic_board", + "resolve_switch_candidates", + "switch_topic", +] diff --git a/goga/topics/switching.py b/goga/topics/switching.py new file mode 100644 index 00000000..b4c7ac81 --- /dev/null +++ b/goga/topics/switching.py @@ -0,0 +1,333 @@ +"""The switch resolution and orchestration of the topics domain. + +The entities declared in the cell CODEMANIFEST with +``location: switching.py``: one candidate of a switch-identifier +resolution, the read-only resolver walking the same ref trees as the +board, and the orchestrator that brings the repository onto the chosen +host branch. Topic identity and statuses belong to the history facade; +the bounded git mutations belong to the nested git cell. Git +infrastructure failures and the fatal scale-assembly ``ImportError`` +surface as ``click.ClickException`` — the clean-error boundary of the +domain; the interactive moments follow the ``click`` practice. +""" + +from __future__ import annotations + +import subprocess +import sys +from dataclasses import dataclass + +import click + +from ..history import ( + StatusScale, + assemble_status_scale, + current_year, + normalize_topic_slug, + resolve_current_branch_name, +) +from .board import _current_branch_topic, _year_topics_by_ref +from .git import ( + BranchRef, + checkout_local_branch, + create_branch_from_remote_tracking, + is_working_tree_clean, + list_branch_refs, +) + + +@dataclass(frozen=True, kw_only=True) +class SwitchCandidate: + """One candidate of a switch-identifier resolution — a branch that may + host the requested work. + + Attributes: + branch: The display name of the candidate branch. + topic: The topic slug the branch hosts, or ``None`` for a branch + without a topic. + statuses: The qualified names of the maximal present statuses, in + scale order — empty for a branch without a topic. + current: ``True`` for the current branch. + remote: ``True`` when the candidate ref is remote-tracking. + """ + + branch: str + topic: str | None + statuses: list[str] + current: bool + remote: bool + + +def resolve_switch_candidates( + identifier: str, year: str | None = None +) -> list[SwitchCandidate]: + """Resolve a switch identifier into its candidate branches. + + Args: + identifier: The user input — a branch name, a topic slug, or their + prefix. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The matching candidates, exact matches first, then prefix matches — + locals before remote-tracking refs, then by branch, then by topic. + A branch without a topic is a valid candidate with ``topic=None`` + and empty ``statuses``. + + Algorithm: + 1. Normalize ``identifier`` into a slug via ``normalize_topic_slug`` + 2. Collect the branch inventory and the topics of the resolved year + — the same ref-tree walk as the board, with the current branch + read from the working copy + 3. Exact branch name match -> the candidates hosting that name + 4. Exact slug match otherwise -> the branches hosting the slug, + local branches first + 5. Prefix matches otherwise -> the branches whose name or hosted + slug starts with the input + 6. Return the candidates with their statuses + + Requirements: + Exact matches always precede prefix matches — the first non-empty + tier wins and excludes every other tier. + A branch without a topic is a valid candidate. + Read-only — no mutation before a choice. + + Constraints: + Do not choose among multiple candidates — selection belongs to the + caller. + + Raises: + click.ClickException: a git infrastructure failure (its stderr when + git reports one, or a missing git binary), or the fatal + ``ImportError`` of the scale assembly — the broken tool package + is named in the message. + """ + try: + return _resolve_switch_candidates(identifier, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + except ImportError as exc: + raise click.ClickException(str(exc)) from exc + + +def switch_topic(identifier: str, year: str | None = None) -> str: + """Bring the repository onto the branch hosting the requested work. + + Args: + identifier: The user input — a branch name, a topic slug, or their + prefix. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + One line describing the outcome — the idempotent success, the + checkout, or the branch creation. + + Algorithm: + 1. Resolve the candidates via ``resolve_switch_candidates`` + 2. No candidate -> clean error with a hint to the board + 3. One candidate -> take it; several -> print the numbered list with + statuses and prompt for a number, or fail with the list when no + interactive input is available + 4. Already on the hosting branch -> idempotent success, no mutation, + no cleanliness probe + 5. A mutation is needed -> probe the working tree cleanliness first + via ``is_working_tree_clean``; a dirty tree is a clean error + naming the reason and the next step — commit or stash the + working copy before switching + 6. Local host -> check out the branch via ``checkout_local_branch``; + remote-only host -> create the local branch from the + remote-tracking ref via ``create_branch_from_remote_tracking`` + 7. Return the single result line + + Requirements: + Every mutation is local — no network, no fetch, no push. + Nothing is mutated before the candidate choice is complete. + The result is exactly one line. + + Constraints: + Do not manage the stages of the hosting pipeline — continuation + belongs to the pipeline itself. + Do not return to the previous branch — the switch is the outcome. + + Raises: + click.ClickException: no branch hosts the identifier, several + candidates without an interactive terminal, a dirty working + tree, a git infrastructure failure (its stderr when git reports + one, or a missing git binary), or the fatal ``ImportError`` of + the scale assembly. + click.Abort: Ctrl-C or EOF at the selection prompt — the repository + is left untouched. + """ + try: + return _switch_topic(identifier, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + except ImportError as exc: + raise click.ClickException(str(exc)) from exc + + +def _resolve_switch_candidates( + identifier: str, year: str | None +) -> list[SwitchCandidate]: + """Build the candidate inventory and take the first non-empty tier. + + Args: + identifier: The user input as entered. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The candidates of the first non-empty resolution tier — exact + branch, exact slug, prefix. + """ + resolved_year = year or current_year() + scale = assemble_status_scale() + inventory = list_branch_refs() + current = resolve_current_branch_name() + candidates = _hosted_candidates(inventory, current, resolved_year, scale) + + slug = normalize_topic_slug(identifier) + tiers = ( + [candidate for candidate in candidates if candidate.branch == identifier], + [candidate for candidate in candidates if candidate.topic == slug], + [ + candidate + for candidate in candidates + if candidate.branch.startswith(identifier) + or (slug != "" and candidate.topic is not None and candidate.topic.startswith(slug)) + ], + ) + for tier in tiers: + if tier: + return tier + return [] + + +def _hosted_candidates( + refs: list[BranchRef], current: str | None, year: str, scale: StatusScale +) -> list[SwitchCandidate]: + """List every hosted-work candidate of the branch inventory. + + One candidate per ``(branch, hosted slug)`` pair — a branch hosting + several topics of the year yields several candidates, a branch hosting + none yields one ``topic=None`` candidate. The current branch is read + from the working copy exactly like the board row: the shared helper + guards the empty-slug branch name before the path oracles, which raise + on it before their existence check. + + Args: + refs: The full branch inventory. + current: The current branch name, or ``None`` when there is none. + year: The resolved year as four digits. + scale: The assembled status scale. + + Returns: + The candidates ordered local-first, then by branch, then by topic. + """ + topics_by_ref = _year_topics_by_ref(refs, year) + hosted: list[tuple[BranchRef, str | None, list[str]]] = [] + for ref in refs: + if current is not None and not ref.remote and ref.name == current: + working_copy = _current_branch_topic(current, year, scale) + if working_copy is None: + hosted.append((ref, None, [])) + else: + slug, statuses = working_copy + hosted.append((ref, slug, statuses)) + continue + topics = topics_by_ref[ref.name] + if not topics: + hosted.append((ref, None, [])) + continue + for slug, artifacts in topics.items(): + hosted.append((ref, slug, scale.maximal_present(artifacts))) + hosted.sort(key=lambda entry: (entry[0].remote, entry[0].name, entry[1] or "")) + return [ + SwitchCandidate( + branch=ref.name, + topic=slug, + statuses=statuses, + current=current is not None and not ref.remote and ref.name == current, + remote=ref.remote, + ) + for ref, slug, statuses in hosted + ] + + +def _switch_topic(identifier: str, year: str | None) -> str: + """Run the traced switch procedure — the unwrapped orchestration. + + Args: + identifier: The user input as entered. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The single result line of the outcome. + """ + candidates = resolve_switch_candidates(identifier, year) + if not candidates: + raise click.ClickException( + f"no branch hosts {identifier!r} — run 'goga topics status' to see the board" + ) + chosen = candidates[0] if len(candidates) == 1 else _choose_candidate(candidates) + if chosen.current: + return f"Already on branch {chosen.branch}" + if not is_working_tree_clean(): + raise click.ClickException("working tree is dirty — commit or stash before switching") + if not chosen.remote: + checkout_local_branch(chosen.branch) + return f"Switched to branch {chosen.branch}" + create_branch_from_remote_tracking(BranchRef(name=chosen.branch, remote=True)) + short = chosen.branch.partition("/")[2] + return f"Created branch {short} from {chosen.branch}" + + +def _choose_candidate(candidates: list[SwitchCandidate]) -> SwitchCandidate: + """Narrow several candidates to one — the numbered selection. + + Args: + candidates: The candidate list of the resolution — two or more. + + Returns: + The chosen candidate. + + Raises: + click.ClickException: without a terminal — the numbered list goes to + the user as a non-interactive abort. + click.Abort: Ctrl-C or EOF at the prompt. + """ + lines = _numbered_lines(candidates) + if not sys.stdin.isatty(): + raise click.ClickException("\n".join(lines)) + for line in lines: + click.echo(line) + number = click.prompt( + "Select a branch by number", type=click.IntRange(1, len(candidates)) + ) + return candidates[number - 1] + + +def _numbered_lines(candidates: list[SwitchCandidate]) -> list[str]: + """Render the numbered candidate list with the status segments. + + Args: + candidates: The candidate list of the resolution. + + Returns: + One line per candidate — ``N) <branch> (<topic>) [status] ...`` with + the topic and the status segments present only when hosted. + """ + lines = [] + for index, candidate in enumerate(candidates, start=1): + line = f"{index}) {candidate.branch}" + if candidate.topic is not None: + line += f" ({candidate.topic})" + if candidate.statuses: + line += " " + " ".join(f"[{status}]" for status in candidate.statuses) + lines.append(line) + return lines diff --git a/tests/topics/test_switching.py b/tests/topics/test_switching.py new file mode 100644 index 00000000..692f9402 --- /dev/null +++ b/tests/topics/test_switching.py @@ -0,0 +1,558 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/CODEMANIFEST`` with ``location: switching.py``: + +- ``SwitchCandidate(branch, topic, statuses, current, remote)`` — one + candidate of a switch-identifier resolution +- ``resolve_switch_candidates(identifier, year)`` — the read-only resolution +- ``switch_topic(identifier, year)`` — the switching orchestration + +The git boundary is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched. The ref-tree +helper shared with the board is mocked at its owner (``goga.topics.board``); +the working-copy scenarios use ``tmp_path`` + ``monkeypatch.chdir`` with the +real history path routines, and the scale is the ``builtin_scale`` fixture. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import subprocess +import sys +import typing +from collections.abc import Callable +from pathlib import Path +from unittest import mock + +import click +import pytest +from goga.history.statuses import StatusScale +from goga.topics import ( + SwitchCandidate, + board, + resolve_switch_candidates, + switch_topic, + switching, +) +from goga.topics.git import BranchRef + +# --- Shared scenario helpers --- + + +def _trees_reader(trees: dict[str, list[str]]) -> Callable[..., list[str]]: + """A ``read_ref_tree_paths`` stand-in answering by ref display name.""" + + def read(ref: str, prefix: str) -> list[str]: + assert prefix == ".goga/history/", "the resolution reads under the history root only" + return [path for path in trees.get(ref, []) if path.startswith(prefix)] + + return read + + +def _wire_resolution( + monkeypatch: pytest.MonkeyPatch, + scale: StatusScale, + inventory: list[BranchRef], + trees: dict[str, list[str]], + current: str | None, +) -> None: + """Patch the resolution's import points: scale, git inventory, trees, branch.""" + monkeypatch.setattr(switching, "assemble_status_scale", lambda: scale) + monkeypatch.setattr(switching, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(switching, "resolve_current_branch_name", lambda: current) + monkeypatch.setattr(board, "read_ref_tree_paths", _trees_reader(trees)) + + +def _wire_mutations( + monkeypatch: pytest.MonkeyPatch, clean: bool = True +) -> tuple[mock.Mock, mock.Mock, mock.Mock]: + """Patch the switch mutations at their import points. + + Returns: + The cleanliness probe, the local checkout, and the remote-tracking + branch creation — all as recording mocks. + """ + cleanliness = mock.Mock(return_value=clean) + checkout = mock.Mock() + creation = mock.Mock() + monkeypatch.setattr(switching, "is_working_tree_clean", cleanliness) + monkeypatch.setattr(switching, "checkout_local_branch", checkout) + monkeypatch.setattr(switching, "create_branch_from_remote_tracking", creation) + return cleanliness, checkout, creation + + +def _working_copy_topic(cwd: Path, year: str, slug: str, artifacts: list[str]) -> None: + """Create the working-copy topic directory with its artifact files.""" + for artifact in artifacts: + path = cwd / ".goga" / "history" / year / slug / artifact + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("artifact", encoding="utf-8") + + +def _twin_inventory() -> list[BranchRef]: + """The design-scenario inventory: a local branch and its remote twin.""" + return [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/feat/a", remote=True), + ] + + +def _twin_trees() -> dict[str, list[str]]: + """The design-scenario ref trees: one planned topic on both refs.""" + return { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "origin/feat/a": [".goga/history/2026/feat-a/plan.md"], + } + + +# --- Contract tests --- + + +class TestSwitchingContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """``SwitchCandidate`` and both routines live on the cell facade.""" + import goga.topics as cell + + assert cell.SwitchCandidate is SwitchCandidate + assert cell.resolve_switch_candidates is resolve_switch_candidates + assert cell.switch_topic is switch_topic + for name in ("SwitchCandidate", "resolve_switch_candidates", "switch_topic"): + assert name in cell.__all__ + + def test_switch_candidate_is_a_frozen_kw_only_dataclass(self) -> None: + """``@dataclass(frozen=True, kw_only=True)`` with the five declared fields.""" + assert dataclasses.is_dataclass(SwitchCandidate) + assert SwitchCandidate.__dataclass_params__.frozen is True + assert SwitchCandidate.__dataclass_params__.kw_only is True + assert typing.get_type_hints(SwitchCandidate) == { + "branch": str, + "topic": str | None, + "statuses": list[str], + "current": bool, + "remote": bool, + } + candidate = SwitchCandidate( + branch="feat/a", topic="feat-a", statuses=["planned"], current=True, remote=False + ) + assert candidate.branch == "feat/a" + assert candidate.topic == "feat-a" + assert candidate.statuses == ["planned"] + assert candidate.current is True + assert candidate.remote is False + with pytest.raises(dataclasses.FrozenInstanceError): + candidate.branch = "other" # type: ignore[misc] + with pytest.raises(TypeError): + SwitchCandidate("feat/a", "feat-a", ["planned"], True, False) # type: ignore[misc] + + def test_resolve_switch_candidates_signature(self) -> None: + """``resolve_switch_candidates(identifier, year=None) -> list[...]``.""" + signature = inspect.signature(resolve_switch_candidates) + assert list(signature.parameters) == ["identifier", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(resolve_switch_candidates) + assert hints == { + "identifier": str, + "year": str | None, + "return": list[SwitchCandidate], + } + + def test_switch_topic_signature(self) -> None: + """``switch_topic(identifier, year=None) -> str``.""" + signature = inspect.signature(switch_topic) + assert list(signature.parameters) == ["identifier", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(switch_topic) + assert hints == {"identifier": str, "year": str | None, "return": str} + + +# --- Logic tests: resolution --- + + +class TestResolveSwitchCandidates: + def test_resolve_switch_candidates_exact_before_prefix( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An exact tier always excludes the prefix tier of the same input.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="feat/ab", remote=False), + BranchRef(name="main", remote=False), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "feat/ab": [".goga/history/2026/feat-ab/prd.md"], + "main": ["README.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) + + exact_branch = resolve_switch_candidates("feat/a", "2026") + assert [(c.branch, c.topic) for c in exact_branch] == [("feat/a", "feat-a")] + + exact_slug = resolve_switch_candidates("feat-ab", "2026") + assert [(c.branch, c.topic) for c in exact_slug] == [("feat/ab", "feat-ab")] + + prefix = resolve_switch_candidates("feat", "2026") + assert [(c.branch, c.topic) for c in prefix] == [ + ("feat/a", "feat-a"), + ("feat/ab", "feat-ab"), + ] + + def test_resolve_switch_candidates_branch_without_topic( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A branch without a topic is a valid candidate — ``q4:A``.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) + + candidates = resolve_switch_candidates("main", "2026") + + assert len(candidates) == 1 + assert candidates[0].branch == "main" + assert candidates[0].topic is None + assert candidates[0].statuses == [] + + def test_resolve_switch_candidates_empty_slug_identifier( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A non-ASCII identifier resolves by exact branch name only.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="🚀", remote=False), + BranchRef(name="feat/a", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "🚀") + + candidates = resolve_switch_candidates("🚀", "2026") + + assert [(c.branch, c.topic, c.statuses) for c in candidates] == [("🚀", None, [])] + + def test_resolve_switch_candidates_orders_local_before_remote( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Within a tier: locals first, then by branch, then by topic.""" + monkeypatch.chdir(tmp_path) + _wire_resolution(monkeypatch, builtin_scale, _twin_inventory(), _twin_trees(), None) + + candidates = resolve_switch_candidates("feat-a", "2026") + + assert [(c.branch, c.remote, c.statuses) for c in candidates] == [ + ("feat/a", False, ["planned"]), + ("origin/feat/a", True, ["planned"]), + ] + + def test_resolve_switch_candidates_working_copy_of_current_branch( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The current branch's statuses come from the working copy.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + trees = { + "feat/a": [".goga/history/2026/feat-a/notes.txt"], + "origin/feat/a": [".goga/history/2026/feat-a/notes.txt"], + } + _wire_resolution(monkeypatch, builtin_scale, _twin_inventory(), trees, "feat/a") + + candidates = resolve_switch_candidates("feat-a", "2026") + + # The uncommitted plan.md is visible on the current branch; the same + # work through its remote twin reads the ref tree only. + assert [(c.branch, c.statuses, c.current) for c in candidates] == [ + ("feat/a", ["planned"], True), + ("origin/feat/a", ["empty"], False), + ] + + +# --- Logic tests: switching --- + + +class TestSwitchTopic: + def test_switch_topic_single_candidate_switches( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """One local candidate, clean tree: checkout and the result line.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=True) + + result = switch_topic("feat/a", "2026") + + assert result == "Switched to branch feat/a" + checkout.assert_called_once_with("feat/a") + + def test_switch_topic_idempotent_when_already_on_host( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Already on the host: idempotent success, no probe, no mutation.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + _wire_resolution(monkeypatch, builtin_scale, _twin_inventory(), _twin_trees(), "feat/a") + cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) + + result = switch_topic("feat/a") + + assert result == "Already on branch feat/a" + cleanliness.assert_not_called() + checkout.assert_not_called() + creation.assert_not_called() + + def test_switch_topic_dirty_tree_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A dirty working tree is a clean error — and no git switch.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=False) + + with pytest.raises(click.ClickException) as raised: + switch_topic("feat/a", "2026") + + assert raised.value.message == "working tree is dirty — commit or stash before switching" + checkout.assert_not_called() + + def test_switch_topic_remote_only_creates_branch( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A remote-only host: the local branch is created from the ref.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="origin/feat/b", remote=True)] + trees = {"origin/feat/b": [".goga/history/2026/feat-b/prd.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) + _cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) + + result = switch_topic("feat-b", "2026") + + assert result == "Created branch feat/b from origin/feat/b" + checkout.assert_not_called() + creation.assert_called_once_with(BranchRef(name="origin/feat/b", remote=True)) + + def test_switch_topic_multiple_candidates_prompt( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Several candidates: the numbered list, then the number prompt.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="feat/ab", remote=False), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "feat/ab": [".goga/history/2026/feat-ab/prd.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) + _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=True) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + prompt = mock.Mock(return_value=2) + monkeypatch.setattr(click, "prompt", prompt) + + result = switch_topic("feat", "2026") + + assert result == "Switched to branch feat/ab" + checkout.assert_called_once_with("feat/ab") + assert prompt.call_args.args[0] == "Select a branch by number" + prompt_type = prompt.call_args.kwargs["type"] + assert isinstance(prompt_type, click.IntRange) + assert (prompt_type.min, prompt_type.max) == (1, 2) + captured = capsys.readouterr() + assert "1) feat/a (feat-a) [planned]" in captured.out + assert "2) feat/ab (feat-ab) [defined]" in captured.out + + def test_switch_topic_prompt_rejects_out_of_range_input( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """The prompt is bounded by ``IntRange(1, N)`` — a bad answer re-asks.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="feat/ab", remote=False), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "feat/ab": [".goga/history/2026/feat-ab/prd.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) + _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=True) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + answers = iter(["9", "2"]) + monkeypatch.setattr(click.termui, "visible_prompt_func", lambda _text: next(answers)) + + result = switch_topic("feat", "2026") + + assert result == "Switched to branch feat/ab" + checkout.assert_called_once_with("feat/ab") + assert next(answers, None) is None, "both answers were consumed by the re-asking prompt" + captured = capsys.readouterr() + # The out-of-range complaint came through click's own error echo. + assert "not in the range" in captured.out + captured.err + + def test_switch_topic_no_candidates_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An identifier nothing hosts is a clean error with a board hint.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="main", remote=False)] + _wire_resolution(monkeypatch, builtin_scale, inventory, {"main": ["README.md"]}, None) + _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=True) + + with pytest.raises(click.ClickException) as raised: + switch_topic("nope") + + assert raised.value.message == ( + "no branch hosts 'nope' — run 'goga topics status' to see the board" + ) + checkout.assert_not_called() + + def test_switch_topic_non_interactive_multiple_candidates_fails_with_list( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Several candidates without a terminal: the list is the clean error.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="feat/ab", remote=False), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "feat/ab": [".goga/history/2026/feat-ab/prd.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) + cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + + with pytest.raises(click.ClickException) as raised: + switch_topic("feat", "2026") + + assert "feat/a" in raised.value.message + assert "feat/ab" in raised.value.message + assert "1)" in raised.value.message + assert "2)" in raised.value.message + cleanliness.assert_not_called() + checkout.assert_not_called() + creation.assert_not_called() + + +# --- Infrastructure boundary --- + + +class TestSwitchingInfrastructureBoundary: + def test_git_failure_surfaces_as_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A git infrastructure failure with stderr becomes a ``ClickException``.""" + monkeypatch.chdir(tmp_path) + failure = subprocess.CalledProcessError( + returncode=128, cmd=["git", "for-each-ref"], stderr="fatal: not a git repository" + ) + monkeypatch.setattr(switching, "assemble_status_scale", lambda: builtin_scale) + monkeypatch.setattr(switching, "list_branch_refs", mock.Mock(side_effect=failure)) + + with pytest.raises(click.ClickException) as raised: + resolve_switch_candidates("feat/a", "2026") + + assert "fatal: not a git repository" in raised.value.message + + def test_broken_tool_package_import_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The fatal scale-assembly ``ImportError`` keeps its package name.""" + monkeypatch.chdir(tmp_path) + broken = ImportError("package goga_tool_bad failed to import: boom") + monkeypatch.setattr(switching, "assemble_status_scale", mock.Mock(side_effect=broken)) + + with pytest.raises(click.ClickException) as raised: + switch_topic("feat/a", "2026") + + assert raised.value.message == "package goga_tool_bad failed to import: boom" + + def test_switch_mutation_failure_surfaces_as_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A failing checkout mutation becomes a ``ClickException``.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + monkeypatch.setattr(switching, "is_working_tree_clean", mock.Mock(return_value=True)) + failure = subprocess.CalledProcessError( + returncode=1, cmd=["git", "switch", "feat/a"], stderr="error: cannot switch" + ) + monkeypatch.setattr(switching, "checkout_local_branch", mock.Mock(side_effect=failure)) + + with pytest.raises(click.ClickException) as raised: + switch_topic("feat/a", "2026") + + assert "error: cannot switch" in raised.value.message From 269f621c232b35573801fc55c2c16fc13664cade Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 18:16:34 +0000 Subject: [PATCH 087/229] feat: topics fresh-work creation and occupancy oracles in creation.py --- goga/topics/__init__.py | 3 + goga/topics/creation.py | 215 ++++++++++++++++++ tests/topics/test_creation.py | 396 ++++++++++++++++++++++++++++++++++ 3 files changed, 614 insertions(+) create mode 100644 goga/topics/creation.py create mode 100644 tests/topics/test_creation.py diff --git a/goga/topics/__init__.py b/goga/topics/__init__.py index ad5006c5..c4751f52 100644 --- a/goga/topics/__init__.py +++ b/goga/topics/__init__.py @@ -9,12 +9,15 @@ """ from .board import BoardRecord, collect_topic_board +from .creation import check_branch_occupancy, create_topic from .switching import SwitchCandidate, resolve_switch_candidates, switch_topic __all__: list[str] = [ "BoardRecord", "SwitchCandidate", + "check_branch_occupancy", "collect_topic_board", + "create_topic", "resolve_switch_candidates", "switch_topic", ] diff --git a/goga/topics/creation.py b/goga/topics/creation.py new file mode 100644 index 00000000..fcb0da65 --- /dev/null +++ b/goga/topics/creation.py @@ -0,0 +1,215 @@ +"""The fresh-work creation of the topics domain. + +The entities declared in the cell CODEMANIFEST with +``location: creation.py``: the three-oracle occupancy check of a fresh-work +name and the orchestrator that creates the branch — named exactly as entered +— together with its topic directory of the year. Topic identity and +addressing belong to the history facade; the bounded git mutation belongs to +the nested git cell. Git infrastructure failures surface as +``click.ClickException`` — the clean-error boundary of the domain; the +interactive moments follow the ``click`` practice. The status scale is never +assembled here — creation is not a status consumer. +""" + +from __future__ import annotations + +import subprocess +import sys + +import click + +from ..history import ( + current_year, + ensure_topic_dir, + normalize_topic_slug, + resolve_current_branch_name, + topic_exists, +) +from .git import create_and_switch_branch, list_branch_refs + +# The board hint of an occupancy conflict — where the occupied names are +# visible to the user. +_BOARD_HINT = "run 'goga topics status' to see the board" + + +def check_branch_occupancy( + branch_name: str, slug: str, year: str | None = None +) -> str | None: + """Decide whether the entered branch name and the topic slug are free. + + Probes three oracles in order and returns the human-readable reason of + the first occupied one; the remaining oracles are not probed: + + 1. a local ``BranchRef`` of the inventory named exactly ``branch_name``; + 2. a remote-tracking ``BranchRef`` whose short name — the part after the + first slash of its display name — equals ``branch_name`` (the local + inventory only, no network); + 3. the topic directory of ``slug`` in the year via ``topic_exists`` — + only a directory occupies a topic. + + The git oracles check the name as entered; the history oracle checks the + slug — the two may deliberately differ (``Feature/Foo_Bar`` vs + ``feature-foo-bar``). + + Args: + branch_name: Branch name as entered (checked against the inventory). + slug: Normalized topic slug (checked against the topic directory). + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The human-readable reason of the first occupied oracle, or ``None`` + when everything is free. + + Constraints: + Read-only — no ref or directory is created. + Do not resolve remote state over the network — the local inventory + only. + + Raises: + click.ClickException: a git infrastructure failure (its stderr when + git reports one, or a missing git binary). + """ + try: + return _occupancy_conflict(branch_name, slug, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + + +def create_topic(branch_name: str, year: str | None = None) -> str: + """Create fresh work — a branch with the name as entered and its topic + directory of the year. + + Args: + branch_name: Branch name as entered by the user. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + One line describing the outcome — the created work, or the + idempotent success when the current branch already hosts the topic. + + Algorithm: + 1. Normalize ``branch_name`` into a slug via ``normalize_topic_slug`` + 2. Empty slug -> print the reason, prompt for a new name on an + interactive terminal and restart, or fail with the reason + otherwise + 3. The current branch — read via ``resolve_current_branch_name`` — + hosts the same slug -> idempotent success, no mutation, no + occupancy check + 4. ``check_branch_occupancy`` reports a conflict -> print the reason + with a hint to the board, prompt for a new name on an interactive + terminal and restart, or fail otherwise + 5. Free name -> create the branch named exactly as entered and + switch to it via ``create_and_switch_branch``, and create the + topic directory via ``ensure_topic_dir`` of the year + 6. Return the single result line + + Requirements: + The branch keeps the name as entered; the topic directory takes the + slug — the two may deliberately differ. + An aborted re-ask leaves the repository untouched. + The caller stays on the new branch. + + Constraints: + Do not validate branch-name characters — git owns name validity. + Do not auto-pick suffixed names on a conflict — the user re-asks or + aborts. + Do not write artifact files inside the topic directory. + + Raises: + click.ClickException: an unresolved empty slug or occupancy conflict + without a terminal, a git infrastructure failure (its stderr + when git reports one, or a missing git binary). + click.Abort: Ctrl-C or EOF at the re-ask prompt — the repository is + left untouched. + """ + try: + return _create_topic(branch_name, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + + +def _occupancy_conflict( + branch_name: str, slug: str, year: str | None +) -> str | None: + """Probe the three occupancy oracles — the traced algorithm, unwrapped. + + Args: + branch_name: Branch name as entered (checked against the inventory). + slug: Normalized topic slug (checked against the topic directory). + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The reason of the first occupied oracle, or ``None``. + """ + resolved_year = year or current_year() + refs = list_branch_refs() + if any(not ref.remote and ref.name == branch_name for ref in refs): + return f"branch '{branch_name}' already exists" + if any( + ref.remote and ref.name.partition("/")[2] == branch_name for ref in refs + ): + return f"remote-tracking branch '{branch_name}' already exists" + if topic_exists(slug, resolved_year): + return f"history topic '{slug}' already exists for {resolved_year}" + return None + + +def _create_topic(branch_name: str, year: str | None) -> str: + """Run the traced creation procedure — the unwrapped orchestration. + + Args: + branch_name: Branch name as entered by the user. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The single result line of the outcome. + """ + resolved_year = year or current_year() + while True: + slug = normalize_topic_slug(branch_name) + + if slug == "": + reason = f"branch name '{branch_name}' normalizes to an empty topic slug" + branch_name = _reask(reason) + continue + + current = resolve_current_branch_name() + if current is not None and normalize_topic_slug(current) == slug: + return f"Branch {current} already hosts topic {resolved_year}/{slug}" + + conflict = check_branch_occupancy(branch_name, slug, resolved_year) + if conflict is not None: + branch_name = _reask(conflict, _BOARD_HINT) + continue + + create_and_switch_branch(branch_name) + ensure_topic_dir(branch_name, resolved_year) + return f"Created branch {branch_name} and topic {resolved_year}/{slug}" + + +def _reask(reason: str, hint: str = "") -> str: + """Handle an unusable name: re-ask on a terminal, abort otherwise. + + Args: + reason: Human-readable reason the current name cannot be used. + hint: Optional next step appended to the non-terminal error. + + Returns: + The re-asked branch name — the caller restarts the procedure with it. + + Raises: + click.ClickException: without a terminal — the reason (and the hint + when given) go to the user as a non-terminal abort. + click.Abort: Ctrl-C or EOF at the prompt. + """ + if not sys.stdin.isatty(): + message = f"{reason} — {hint}" if hint else reason + raise click.ClickException(message) + click.echo(reason, err=True) + return click.prompt("New branch name") diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py new file mode 100644 index 00000000..8ec9b7cc --- /dev/null +++ b/tests/topics/test_creation.py @@ -0,0 +1,396 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/CODEMANIFEST`` with ``location: creation.py``: + +- ``check_branch_occupancy(branch_name, slug, year)`` — the three-oracle + occupancy check of a fresh-work name +- ``create_topic(branch_name, year)`` — the fresh-work creation procedure + +The git boundary is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched. The filesystem +scenarios (the topic oracle and the created directory) run against ``tmp_path`` +with the real history path routines; the scale is never assembled — creation +is not a status consumer. +""" + +from __future__ import annotations + +import inspect +import subprocess +import sys +import typing +from pathlib import Path +from unittest import mock + +import click +import pytest +from goga.topics import check_branch_occupancy, create_topic, creation +from goga.topics.git import BranchRef + +# --- Shared scenario helpers --- + + +def _wire_inventory( + monkeypatch: pytest.MonkeyPatch, + inventory: list[BranchRef], + current: str | None = None, +) -> mock.Mock: + """Patch creation's import points: the inventory and the create mutation. + + Returns: + The create-and-switch mutation as a recording mock — the only git + mutation of the procedure. + """ + monkeypatch.setattr(creation, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(creation, "resolve_current_branch_name", lambda: current) + create_and_switch = mock.Mock() + monkeypatch.setattr(creation, "create_and_switch_branch", create_and_switch) + return create_and_switch + + +def _non_interactive(monkeypatch: pytest.MonkeyPatch) -> None: + """Make stdin a non-terminal — the re-ask path must abort cleanly.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + + +def _interactive( + monkeypatch: pytest.MonkeyPatch, answers: list[str] +) -> mock.Mock: + """Make stdin a terminal and answer the re-ask prompts in order.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + prompt = mock.Mock(side_effect=answers) + monkeypatch.setattr(click, "prompt", prompt) + return prompt + + +def _topic_dir(cwd: Path, year: str, slug: str) -> Path: + """Create the working-copy topic directory of the oracle scenarios.""" + path = cwd / ".goga" / "history" / year / slug + path.mkdir(parents=True, exist_ok=True) + return path + + +# --- Contract tests --- + + +class TestCreationContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """Both routines live on the cell facade and in ``__all__``.""" + import goga.topics as cell + + assert cell.create_topic is create_topic + assert cell.check_branch_occupancy is check_branch_occupancy + expected = { + "BoardRecord", + "SwitchCandidate", + "check_branch_occupancy", + "collect_topic_board", + "create_topic", + "resolve_switch_candidates", + "switch_topic", + } + assert set(cell.__all__) == expected + + def test_check_branch_occupancy_signature(self) -> None: + """``check_branch_occupancy(branch_name, slug, year=None)``.""" + signature = inspect.signature(check_branch_occupancy) + assert list(signature.parameters) == ["branch_name", "slug", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(check_branch_occupancy) + assert hints == { + "branch_name": str, + "slug": str, + "year": str | None, + "return": str | None, + } + + def test_create_topic_signature(self) -> None: + """``create_topic(branch_name, year=None) -> str``.""" + signature = inspect.signature(create_topic) + assert list(signature.parameters) == ["branch_name", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(create_topic) + assert hints == {"branch_name": str, "year": str | None, "return": str} + + def test_no_cleanliness_probe_in_creation(self) -> None: + """Creation owns no cleanliness policy — no probe is imported.""" + assert not hasattr(creation, "is_working_tree_clean") + + +# --- Logic tests: the occupancy oracles --- + + +class TestCheckBranchOccupancy: + def test_check_branch_occupancy_oracle_order( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The first occupied oracle wins — remote twin before the topic dir.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="origin/feat/x", remote=True)] + _topic_dir(tmp_path, "2026", "feat-x") + _wire_inventory(monkeypatch, inventory) + + conflict = check_branch_occupancy("feat/x", "feat-x", "2026") + + assert conflict == "remote-tracking branch 'feat/x' already exists" + + def test_check_branch_occupancy_local_ref_oracle( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A local branch of the name occupies it — the first oracle.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/x", remote=False), + BranchRef(name="origin/feat/x", remote=True), + ] + _topic_dir(tmp_path, "2026", "feat-x") + _wire_inventory(monkeypatch, inventory) + + conflict = check_branch_occupancy("feat/x", "feat-x", "2026") + + assert conflict == "branch 'feat/x' already exists" + + def test_check_branch_occupancy_topic_dir_oracle( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The topic directory of the year occupies the slug — the last oracle.""" + monkeypatch.chdir(tmp_path) + _wire_inventory(monkeypatch, []) + _topic_dir(tmp_path, "2026", "feat-x") + + conflict = check_branch_occupancy("feat/x", "feat-x", "2026") + + assert conflict == "history topic 'feat-x' already exists for 2026" + + def test_check_branch_occupancy_topic_of_another_year_is_free( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The topic oracle is year-scoped — another year's topic is free.""" + monkeypatch.chdir(tmp_path) + _wire_inventory(monkeypatch, []) + _topic_dir(tmp_path, "2025", "feat-x") + + assert check_branch_occupancy("feat/x", "feat-x", "2026") is None + + def test_check_branch_occupancy_default_year_is_current( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``year=None`` resolves to the current year — the reported one.""" + monkeypatch.chdir(tmp_path) + _wire_inventory(monkeypatch, []) + _topic_dir(tmp_path, "2026", "feat-x") + monkeypatch.setattr(creation, "current_year", lambda: "2026") + + conflict = check_branch_occupancy("feat/x", "feat-x") + + assert conflict == "history topic 'feat-x' already exists for 2026" + + def test_check_branch_occupancy_free_everywhere( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Every oracle free — ``None``, not an error.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="origin/feat/other", remote=True)] + _wire_inventory(monkeypatch, inventory) + + assert check_branch_occupancy("feat/x", "feat-x", "2026") is None + + +# --- Logic tests: the creation procedure --- + + +class TestCreateTopic: + def test_create_topic_creates_branch_and_dir( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A free name: verbatim branch creation plus the slug directory.""" + monkeypatch.chdir(tmp_path) + create_and_switch = _wire_inventory(monkeypatch, [], current="main") + + result = create_topic("Feature/Foo_Bar", year="2025") + + assert result == "Created branch Feature/Foo_Bar and topic 2025/feature-foo-bar" + create_and_switch.assert_called_once_with("Feature/Foo_Bar") + assert (tmp_path / ".goga" / "history" / "2025" / "feature-foo-bar").is_dir() + + def test_create_topic_default_year_is_current( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Without a year the topic directory lands in the current one.""" + monkeypatch.chdir(tmp_path) + create_and_switch = _wire_inventory(monkeypatch, [], current="main") + monkeypatch.setattr(creation, "current_year", lambda: "2026") + + result = create_topic("Feature/Foo_Bar") + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + create_and_switch.assert_called_once_with("Feature/Foo_Bar") + assert (tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar").is_dir() + + def test_create_topic_idempotent_current_host( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The current branch already hosting the slug: success, no mutation.""" + monkeypatch.chdir(tmp_path) + create_and_switch = _wire_inventory( + monkeypatch, [], current="feature-foo-bar" + ) + ensure_dir = mock.Mock( + side_effect=lambda name, _year: _topic_dir(tmp_path, "2026", name.lower()) + ) + monkeypatch.setattr(creation, "ensure_topic_dir", ensure_dir) + monkeypatch.setattr(creation, "current_year", lambda: "2026") + + result = create_topic("feature-foo-bar") + + assert result == "Branch feature-foo-bar already hosts topic 2026/feature-foo-bar" + create_and_switch.assert_not_called() + ensure_dir.assert_not_called() + + def test_create_topic_occupied_non_interactive_clean_error( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An occupancy conflict without a terminal: the reason and the hint.""" + monkeypatch.chdir(tmp_path) + _non_interactive(monkeypatch) + create_and_switch = _wire_inventory(monkeypatch, [], current="main") + inventory = [BranchRef(name="feat/x", remote=False)] + monkeypatch.setattr(creation, "list_branch_refs", lambda: inventory) + + with pytest.raises(click.ClickException) as raised: + create_topic("feat/x") + + assert raised.value.message == ( + "branch 'feat/x' already exists — run 'goga topics status' to see the board" + ) + create_and_switch.assert_not_called() + assert not (tmp_path / ".goga" / "history").exists() + + def test_create_topic_empty_slug_non_interactive_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A name that normalizes to nothing: the reason, no prompt, no work.""" + monkeypatch.chdir(tmp_path) + _non_interactive(monkeypatch) + create_and_switch = _wire_inventory(monkeypatch, [], current="main") + prompt = mock.Mock() + monkeypatch.setattr(click, "prompt", prompt) + + with pytest.raises(click.ClickException) as raised: + create_topic("🚀") + + assert raised.value.message == ( + "branch name '🚀' normalizes to an empty topic slug" + ) + prompt.assert_not_called() + create_and_switch.assert_not_called() + + def test_create_topic_empty_slug_reask( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """An unusable name on a terminal: the cycle restarts until a good one.""" + monkeypatch.chdir(tmp_path) + prompt = _interactive(monkeypatch, ["???", "good-name"]) + create_and_switch = _wire_inventory(monkeypatch, [], current="main") + monkeypatch.setattr(creation, "current_year", lambda: "2026") + + result = create_topic("!!!") + + assert result == "Created branch good-name and topic 2026/good-name" + assert prompt.call_count == 2 + assert prompt.call_args.args[0] == "New branch name" + create_and_switch.assert_called_once_with("good-name") + assert (tmp_path / ".goga" / "history" / "2026" / "good-name").is_dir() + stderr = capsys.readouterr().err + assert "branch name '!!!' normalizes to an empty topic slug" in stderr + assert "branch name '???' normalizes to an empty topic slug" in stderr + + def test_create_topic_occupied_reask_creates_second_name( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """An occupied name on a terminal: the conflict goes to stderr, then re-ask.""" + monkeypatch.chdir(tmp_path) + _interactive(monkeypatch, ["feat/other"]) + inventory = [BranchRef(name="feat/x", remote=False)] + create_and_switch = _wire_inventory( + monkeypatch, inventory, current="main" + ) + monkeypatch.setattr(creation, "current_year", lambda: "2026") + + result = create_topic("feat/x") + + assert result == "Created branch feat/other and topic 2026/feat-other" + create_and_switch.assert_called_once_with("feat/other") + stderr = capsys.readouterr().err + assert "branch 'feat/x' already exists" in stderr + + +# --- Infrastructure boundary --- + + +class TestCreationInfrastructureBoundary: + def test_git_failure_of_the_oracles_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A git infrastructure failure with stderr becomes a ``ClickException``.""" + monkeypatch.chdir(tmp_path) + failure = subprocess.CalledProcessError( + returncode=128, cmd=["git", "for-each-ref"], stderr="fatal: not a git repository" + ) + monkeypatch.setattr(creation, "list_branch_refs", mock.Mock(side_effect=failure)) + + with pytest.raises(click.ClickException) as raised: + check_branch_occupancy("feat/x", "feat-x", "2026") + + assert "fatal: not a git repository" in raised.value.message + + def test_missing_git_binary_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A missing git binary is a clean error on both public entries.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + creation, "list_branch_refs", mock.Mock(side_effect=FileNotFoundError("git")) + ) + + with pytest.raises(click.ClickException) as raised: + check_branch_occupancy("feat/x", "feat-x", "2026") + + assert "git" in raised.value.message + + def test_create_mutation_failure_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A failing create-and-switch becomes a ``ClickException``.""" + monkeypatch.chdir(tmp_path) + failure = subprocess.CalledProcessError( + returncode=128, + cmd=["git", "switch", "-c", "feat/x"], + stderr="fatal: invalid branch name", + ) + monkeypatch.setattr( + creation, "create_and_switch_branch", mock.Mock(side_effect=failure) + ) + monkeypatch.setattr(creation, "list_branch_refs", lambda: []) + monkeypatch.setattr(creation, "resolve_current_branch_name", lambda: "main") + + with pytest.raises(click.ClickException) as raised: + create_topic("feat/x", year="2026") + + assert "fatal: invalid branch name" in raised.value.message + assert not (tmp_path / ".goga" / "history" / "2026" / "feat-x").exists() From a4e19583b2eb1fe5f88e8a7c3f3943a210d21801 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 18:20:56 +0000 Subject: [PATCH 088/229] feat: pipeline -t/--topic topic procedure replacing branch machinery --- goga/commands/pipeline/__init__.py | 3 - goga/commands/pipeline/branch.py | 194 ------- goga/commands/pipeline/pipeline.py | 39 +- tests/commands/pipeline/test_branch.py | 510 ------------------ .../pipeline/test_pipeline_command.py | 54 +- .../pipeline/test_pipeline_dispatch.py | 327 ++++++----- 6 files changed, 251 insertions(+), 876 deletions(-) delete mode 100644 goga/commands/pipeline/branch.py delete mode 100644 tests/commands/pipeline/test_branch.py diff --git a/goga/commands/pipeline/__init__.py b/goga/commands/pipeline/__init__.py index 405eb759..8b46e1bf 100644 --- a/goga/commands/pipeline/__init__.py +++ b/goga/commands/pipeline/__init__.py @@ -1,6 +1,5 @@ """Pipeline command cell — host-side launcher for the single goga pipeline command.""" -from .branch import check_branch_occupancy, ensure_pipeline_branch from .pipeline import pipeline from .run_pipeline_container import ( clean_pipeline_runtime_dir, @@ -10,9 +9,7 @@ from .run_pipeline_info_container import run_pipeline_info_container __all__: list[str] = [ - "check_branch_occupancy", "clean_pipeline_runtime_dir", - "ensure_pipeline_branch", "pipeline", "resolve_pipeline_runtime_dir", "run_pipeline_container", diff --git a/goga/commands/pipeline/branch.py b/goga/commands/pipeline/branch.py deleted file mode 100644 index 5bf71dfa..00000000 --- a/goga/commands/pipeline/branch.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Host-side branch routines for the ``-b/--branch`` procedure of ``goga pipeline``. - -The two branch routines declared in the cell CODEMANIFEST with ``location: -branch.py``: the three-oracle occupancy check and the orchestrator of the -whole branch procedure. The slug transformer and the git current-branch -reader come from the history domain (``goga.history``) via the cell Imports — -this module holds no local copies. Every git invocation follows the ``git`` -practice — ``subprocess.run`` with ``check=True``, captured output, and -``GIT_TERMINAL_PROMPT=0`` in the environment. The oracles are read-only; the -single host-side mutation (create-and-switch) is owned by -``ensure_pipeline_branch``. -""" - -from __future__ import annotations - -import os -import subprocess -import sys - -import click - -from ...history import normalize_topic_slug, resolve_current_branch_name, topic_exists - -_GIT_REQUIRED_MESSAGE = "git is required for -b/--branch: git binary not found" -_REASK_HINT = "Pass another branch name via -b." - - -def _reask_branch_name(reason: str) -> str: - """Handle an unusable branch name: re-ask on a terminal, abort otherwise. - - Args: - reason: Human-readable reason the current name cannot be used. - - Returns: - The re-asked branch name (the caller restarts the procedure with it). - - Raises: - click.ClickException: without a terminal — the reason plus the ``-b`` - hint go to the user as a non-terminal abort. - click.Abort: Ctrl-C or EOF at the prompt. - """ - if not sys.stdin.isatty(): - raise click.ClickException(f"{reason} {_REASK_HINT}") - click.echo(reason, err=True) - return click.prompt("New branch name") - - -def check_branch_occupancy(branch_name: str, slug: str) -> str | None: - """Decide whether the entered branch name and the topic slug are free. - - Probes three oracles in order and returns the human-readable reason of the - first occupied one; remaining oracles are not probed: - - 1. a local branch ref for ``branch_name`` (exact full-ref verification via - ``git show-ref --verify`` — no glob ambiguity for names containing - ``/``); - 2. a remote-tracking ref for ``branch_name`` (local - ``git for-each-ref refs/remotes`` output only — no network call); - 3. the history topic for ``slug`` via the domain oracle ``topic_exists`` - (the current year is resolved inside the domain — this routine owns no - clock; only a DIRECTORY occupies a topic, a stray file named - ``<slug>`` does not). - - The git oracles check the name as entered; the history oracle checks the - slug — the two may deliberately differ (``release/1.3.0`` vs - ``release-1-3-0``). Read-only — no ref or folder is created. Occupancy - answers are not error paths: git infrastructure failures beyond the - occupancy semantics propagate. - - Args: - branch_name: Branch name as entered (checked against git refs). - slug: Normalized topic slug (checked against the history folder). - - Returns: - The human-readable reason of the first occupied oracle, or ``None`` - when everything is free. - - Raises: - subprocess.CalledProcessError: when the remote-tracking-ref listing - itself fails (an infrastructure failure, not an occupancy answer). - OSError: unexpected OS-level failures of the git invocations (e.g. a - missing git binary). - """ - env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"} - - # Oracle 1 — local branch ref. A non-zero exit of the quiet --verify means - # "no such ref": free, not an error. - try: - subprocess.run( - ["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch_name}"], - check=True, - capture_output=True, - text=True, - env=env, - ) - except subprocess.CalledProcessError: - pass - else: - return f"branch '{branch_name}' already exists" - - # Oracle 2 — remote-tracking refs, local refs only (no network). A ref - # refs/remotes/<remote>/<branch> matches when its branch part equals the - # entered name exactly (feat/x must not match feat/xy). - result = subprocess.run( - ["git", "for-each-ref", "--format=%(refname)", "refs/remotes"], - check=True, - capture_output=True, - text=True, - env=env, - ) - for line in result.stdout.splitlines(): - rest = line.removeprefix("refs/remotes/") - _remote, separator, branch = rest.partition("/") - if separator and branch == branch_name: - return f"remote-tracking branch '{branch_name}' already exists" - - # Oracle 3 — history topic, via the domain oracle (the year is resolved - # inside the domain). Only a directory occupies a topic. - if topic_exists(slug): - return f"history topic '{slug}' already exists for the current year" - return None - - -def ensure_pipeline_branch(branch_name: str) -> str: - """Bring the project onto a fresh branch with a fresh history topic. - - Composes the domain primitives (the slug transformer and the git - current-branch reader from ``goga.history``) with the occupancy check - above. A free name is created and switched to on the host exactly as - entered (``git switch -c`` — the single mutation; git owns name validity). - An unusable name (an empty slug or an occupancy conflict) re-asks on a - terminal — the cycle restarts from the top and validates the NEW name - fully — and aborts cleanly without one. The already-on-branch case (the - current branch's slug equals the entered slug) touches nothing and returns - the CURRENT branch name. - - Args: - branch_name: Branch name as entered by the user via ``-b/--branch``. - - Returns: - The final branch name — the entered one or the re-asked one after a - create-and-switch; the current branch name for the already-on-branch - case. - - Raises: - click.ClickException: an empty topic slug or an unresolved occupancy - conflict without a terminal, a failed create-and-switch (carrying - git's stderr), a git infrastructure failure of the occupancy - oracles (carrying git's stderr), or a missing git binary. - click.Abort: Ctrl-C or EOF at the re-ask prompt — the repository is - left untouched. - """ - while True: - slug = normalize_topic_slug(branch_name) - current = resolve_current_branch_name() - - if slug == "": - reason = f"branch name '{branch_name}' normalizes to an empty topic slug" - branch_name = _reask_branch_name(reason) - continue - - if current is not None and normalize_topic_slug(current) == slug: - return current - - try: - conflict = check_branch_occupancy(branch_name, slug) - except FileNotFoundError as exc: - raise click.ClickException(_GIT_REQUIRED_MESSAGE) from exc - except subprocess.CalledProcessError as exc: - # A git infrastructure failure of the oracles themselves (e.g. the - # ref listing exiting 128 outside a repository) — not an occupancy - # answer; surfaced as a clean failure, never a traceback. - stderr = exc.stderr if isinstance(exc.stderr, str) else "" - raise click.ClickException( - f"git failed to check branch occupancy for {branch_name!r}: {stderr.strip()}" - ) from exc - if conflict is not None: - branch_name = _reask_branch_name(conflict) - continue - - try: - subprocess.run( - ["git", "switch", "-c", branch_name], - check=True, - capture_output=True, - text=True, - env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, - ) - except FileNotFoundError as exc: - raise click.ClickException(_GIT_REQUIRED_MESSAGE) from exc - except subprocess.CalledProcessError as exc: - stderr = exc.stderr if isinstance(exc.stderr, str) else "" - raise click.ClickException(f"git failed to create branch {branch_name!r}: {stderr.strip()}") from exc - return branch_name diff --git a/goga/commands/pipeline/pipeline.py b/goga/commands/pipeline/pipeline.py index ffb7eea5..e3840136 100644 --- a/goga/commands/pipeline/pipeline.py +++ b/goga/commands/pipeline/pipeline.py @@ -6,7 +6,7 @@ import yaml from ...config import load_project_config -from .branch import ensure_pipeline_branch +from ...topics import switch_topic from .run_pipeline_container import run_pipeline_container from .run_pipeline_info_container import run_pipeline_info_container @@ -30,12 +30,13 @@ help="Show pipeline descriptions (--list) or a pipeline card (NAME) instead of running", ) @click.option( - "-b", - "--branch", - "branch", + "-t", + "--topic", + "topic", type=str, default=None, - help="Create and switch to a fresh branch before the run (run form only)", + help="Bring the repository onto the requested work before the run " + "(branch name, topic slug, or prefix; run form only)", ) @click.option( "-e", @@ -107,7 +108,7 @@ def pipeline( # noqa: C901, PLR0912, PLR0913, PLR0917 name: str | None, list_requested: bool, info: bool, - branch: str | None, + topic: str | None, extra_env: tuple[str, ...], proxy: str | None, add_host: tuple[str, ...], @@ -129,7 +130,8 @@ def pipeline( # noqa: C901, PLR0912, PLR0913, PLR0917 With NAME and -i/--info: prints the pipeline card (name, description, stages in execution order) without running anything. - With -b/--branch: prepare a fresh git branch and history topic before the run. + With -t/--topic: bring the repository onto the requested work (a branch + name, a topic slug, or their prefix) before the run. All forms launch the goga Docker container and delegate there — the host never reads pipeline files directly. @@ -200,19 +202,20 @@ def pipeline( # noqa: C901, PLR0912, PLR0913, PLR0917 if not workflow_path.exists(): raise click.ClickException(f"workflow '{workflow}' not found at {workflow_path}") - # Step 3 — branch procedure (run form only: `name` given, no --list, no - # --info, and -b/--branch given). Every git action happens here on the + # Step 3 — topic procedure (run form only: `name` given, no --list, no + # --info, and -t/--topic given). Every git action happens here on the # host, AFTER every step-2 form check and BEFORE any docker activity — a - # form error or a branch error never refreshes, builds, or launches an + # form error or a switching error never refreshes, builds, or launches an # image. The flat list, overview, and card forms skip the procedure - # silently: passing -b there is not an error and has no effect. The final - # branch name (the created-and-switched one, or the current one in the - # already-on-branch case) is echoed to stdout exactly once, immediately - # after the procedure and before the dispatch — and never forwarded into - # a launcher: the container sees the branch through the mounted project. - if branch is not None and name is not None and not list_requested and not info: - final_branch = ensure_pipeline_branch(branch) - click.echo(f"Pipeline running on branch {final_branch}") + # silently: passing -t there is not an error and has no effect. The single + # result line of `switch_topic` (a switch, a fresh branch created from a + # remote-tracking ref, or the already-on-host confirmation) is echoed to + # stdout exactly once, immediately after the procedure and before the + # dispatch — and never forwarded into a launcher: the container sees the + # branch through the mounted project. + if topic is not None and name is not None and not list_requested and not info: + line = switch_topic(topic) + click.echo(line) # Step 4 — dispatch. The info forms receive hosts from the config ONLY: # --add-host is a run-form surface (an info container is read-only, so diff --git a/tests/commands/pipeline/test_branch.py b/tests/commands/pipeline/test_branch.py deleted file mode 100644 index d28014b0..00000000 --- a/tests/commands/pipeline/test_branch.py +++ /dev/null @@ -1,510 +0,0 @@ -"""Contract and logic tests for the branch routines declared in -``goga/commands/pipeline/CODEMANIFEST`` with ``location: branch.py``: - -- ``check_branch_occupancy(branch_name, slug) -> str | None`` — three-oracle - occupancy check (local ref, remote-tracking ref, history topic via the - domain oracle ``topic_exists``) -- ``ensure_pipeline_branch(branch_name: str) -> str`` — the branch-procedure - orchestrator (re-ask cycle, non-terminal abort, no-git-host conversion, the - single create-and-switch mutation) - -The slug transformer and the git current-branch reader are NOT local anymore: -they are Imported from the history domain (``goga.history``) and only their -identity with the domain facade is asserted here — their behavior suites live -in ``tests/history/``. - -Git is mocked at the subprocess boundary per the ``git`` practice — one -``run`` dispatcher laid over BOTH invocation points at once -(``goga.history.git.branch`` and this cell's ``branch`` module) via -``contextlib.ExitStack``; mocking only one of them would release real git into -the test. -""" - -from __future__ import annotations - -import contextlib -import subprocess -import typing -from collections.abc import Iterator -from datetime import datetime -from pathlib import Path -from unittest import mock - -import click -import pytest -from click.testing import CliRunner -from goga.commands.pipeline import branch as branch_module -from goga.history import naming as history_naming -from goga.history.git import branch as history_git_branch_module - -# --- Git subprocess mocking helpers (the process boundary only) --- - - -class _GitResult: - """Minimal stand-in for a ``subprocess.CompletedProcess``.""" - - def __init__(self, returncode: int = 0, stdout: str = "", stderr: str = "") -> None: - self.returncode = returncode - self.stdout = stdout - self.stderr = stderr - - -def _git_run_dispatch( - show_current: object = _GitResult(stdout="main\n"), - show_ref: object = subprocess.CalledProcessError(1, "git"), - for_each_ref: object = _GitResult(stdout=""), - switch: object = _GitResult(returncode=0), -) -> mock.Mock: - """Build a ``subprocess.run`` mock dispatching per git subcommand. - - ``show_current`` / ``show_ref`` / ``for_each_ref`` / ``switch`` are either a - ``_GitResult`` (returned) or an exception instance/class (raised). Any of - them may instead be a LIST of such outcomes consumed in call order - (exhausted → AssertionError) — for re-ask sequences where the same command - must answer differently per iteration. - """ - outcomes = { - ("branch", "--show-current"): show_current, - ("show-ref",): show_ref, - ("for-each-ref",): for_each_ref, - ("switch",): switch, - } - queues = {key: (list(value) if isinstance(value, list) else None) for key, value in outcomes.items()} - - def _run(argv: list[str], **_kwargs: object) -> _GitResult: - for key, default_outcome in outcomes.items(): - if tuple(argv[1 : 1 + len(key)]) == key: - outcome = default_outcome - if queues[key] is not None: - if not queues[key]: - raise AssertionError(f"unexpected repeat of git argv in test: {argv!r}") - outcome = queues[key].pop(0) - if isinstance(outcome, BaseException) or ( - isinstance(outcome, type) and issubclass(outcome, BaseException) - ): - raise outcome - return outcome - raise AssertionError(f"unexpected git argv in test: {argv!r}") - - return mock.Mock(side_effect=_run) - - -@contextlib.contextmanager -def _git_on_both_points(run_mock: mock.Mock) -> Iterator[mock.Mock]: - """Lay one ``run`` dispatcher over BOTH git invocation points at once. - - ``ensure_pipeline_branch`` spans two modules: ``--show-current`` runs in - ``goga.history.git.branch`` while ``show-ref``, ``for-each-ref``, and - ``switch`` run in this cell's ``branch`` module. A mock on only one of the - two points would let the other run real git. - """ - with contextlib.ExitStack() as stack: - stack.enter_context(mock.patch.object(history_git_branch_module.subprocess, "run", run_mock)) - stack.enter_context(mock.patch.object(branch_module.subprocess, "run", run_mock)) - yield run_mock - - -class _FixedClock: - """Stand-in for ``datetime`` answering a fixed naive date.""" - - @staticmethod - def now() -> datetime: - return datetime(2031, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock - - -def _switch_argv_calls(run_mock: mock.Mock) -> list[list[str]]: - """The recorded ``git switch`` argvs (usually asserted to be empty).""" - return [call.args[0] for call in run_mock.call_args_list if call.args[0][1] == "switch"] - - -# --- Contract tests --- - - -class TestBranchContract: - def test_branch_routines_exist_and_are_callable(self) -> None: - """The two routines are defined on the branch module and callable.""" - assert callable(branch_module.check_branch_occupancy) - assert callable(branch_module.ensure_pipeline_branch) - - def test_check_branch_occupancy_signature(self) -> None: - """``check_branch_occupancy(branch_name: str, slug: str) -> str | None``.""" - import inspect - - signature = inspect.signature(branch_module.check_branch_occupancy) - assert list(signature.parameters) == ["branch_name", "slug"] - for parameter in signature.parameters.values(): - assert parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - hints = typing.get_type_hints(branch_module.check_branch_occupancy) - assert hints == {"branch_name": str, "slug": str, "return": str | None} - - def test_ensure_pipeline_branch_signature(self) -> None: - """``ensure_pipeline_branch(branch_name: str) -> str``.""" - import inspect - - signature = inspect.signature(branch_module.ensure_pipeline_branch) - assert list(signature.parameters) == ["branch_name"] - assert signature.parameters["branch_name"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - hints = typing.get_type_hints(branch_module.ensure_pipeline_branch) - assert hints == {"branch_name": str, "return": str} - - def test_domain_routines_are_bound_to_the_domain_not_local_copies(self) -> None: - """The domain names resolve to the DOMAIN objects — a local ``def`` copy would differ.""" - import goga.history - - assert branch_module.normalize_topic_slug is goga.history.normalize_topic_slug - assert branch_module.resolve_current_branch_name is goga.history.resolve_current_branch_name - - def test_pipeline_facade_all_excludes_the_domain_routines(self) -> None: - """The package facade exports exactly the seven names — the domain routines live on the history facade.""" - from goga.commands.pipeline import __all__ as facade_all - - assert facade_all == [ - "check_branch_occupancy", - "clean_pipeline_runtime_dir", - "ensure_pipeline_branch", - "pipeline", - "resolve_pipeline_runtime_dir", - "run_pipeline_container", - "run_pipeline_info_container", - ] - - -# --- Logic tests: check_branch_occupancy (three oracles) --- - - -class TestCheckBranchOccupancy: - def test_check_branch_occupancy_local_ref_reports_reason(self) -> None: - """Oracle 1: an existing local branch ref reports the reason; later oracles not probed.""" - run_mock = _git_run_dispatch( - show_current=_GitResult(stdout="main\n"), - show_ref=_GitResult(returncode=0), - for_each_ref=_GitResult(stdout="refs/remotes/origin/feat/x\n"), - ) - with _git_on_both_points(run_mock): - conflict = branch_module.check_branch_occupancy("feat/x", "feat-x") - assert conflict == "branch 'feat/x' already exists" - probed = [call.args[0] for call in run_mock.call_args_list] - assert all(argv[1] != "for-each-ref" for argv in probed) - - def test_check_branch_occupancy_remote_tracking_ref_reports_reason(self) -> None: - """Oracle 2: an existing remote-tracking ref reports the reason (exact branch match).""" - run_mock = _git_run_dispatch( - show_current=_GitResult(stdout="main\n"), - show_ref=subprocess.CalledProcessError(1, "git"), - for_each_ref=_GitResult(stdout="refs/remotes/origin/feat/x\nrefs/remotes/origin/main\n"), - ) - with _git_on_both_points(run_mock): - conflict = branch_module.check_branch_occupancy("feat/x", "feat-x") - assert conflict == "remote-tracking branch 'feat/x' already exists" - - def test_check_branch_occupancy_remote_ref_no_prefix_match( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """``feat/x`` must not match the remote branch ``feat/xy`` — exact equality only.""" - monkeypatch.chdir(tmp_path) - run_mock = _git_run_dispatch( - show_current=_GitResult(stdout="main\n"), - show_ref=subprocess.CalledProcessError(1, "git"), - for_each_ref=_GitResult(stdout="refs/remotes/origin/feat/xy\nrefs/remotes/origin/main\n"), - ) - with _git_on_both_points(run_mock): - conflict = branch_module.check_branch_occupancy("feat/x", "feat-x") - assert conflict is None - - def test_check_branch_occupancy_two_param_topic_oracle( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Oracle 3: the DOMAIN resolves the year — two parameters, no clock here. - - The year 2031 comes from the fixed clock patched at the domain's - ``naming.datetime`` (the mandated bare-``now()`` point); this cell - computes no year of its own. The reason names the slug for the current - year — no hand-built path in the message. - """ - (tmp_path / ".goga" / "history" / "2031" / "feat-x").mkdir(parents=True) - monkeypatch.chdir(tmp_path) - run_mock = _git_run_dispatch( - show_current=_GitResult(stdout="main\n"), - show_ref=subprocess.CalledProcessError(1, "git"), - for_each_ref=_GitResult(stdout=""), - ) - with ( - mock.patch.object(history_naming, "datetime", _FixedClock), - _git_on_both_points(run_mock), - ): - conflict = branch_module.check_branch_occupancy("feat/x", "feat-x") - assert conflict == "history topic 'feat-x' already exists for the current year" - assert _switch_argv_calls(run_mock) == [] - - def test_check_branch_occupancy_stray_file_is_not_topic( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """A stray FILE named <slug> does not occupy a topic — only a directory does.""" - (tmp_path / ".goga" / "history" / "2031" / "feat-x").parent.mkdir(parents=True) - (tmp_path / ".goga" / "history" / "2031" / "feat-x").write_text("stray") - monkeypatch.chdir(tmp_path) - run_mock = _git_run_dispatch( - show_current=_GitResult(stdout="main\n"), - show_ref=subprocess.CalledProcessError(1, "git"), - for_each_ref=_GitResult(stdout=""), - ) - with ( - mock.patch.object(history_naming, "datetime", _FixedClock), - _git_on_both_points(run_mock), - ): - assert branch_module.check_branch_occupancy("feat/x", "feat-x") is None - - def test_check_branch_occupancy_oracle_listing_failure_propagates(self) -> None: - """A git infrastructure failure of oracle 2 itself propagates (not an occupancy answer).""" - run_mock = _git_run_dispatch( - show_current=_GitResult(stdout="main\n"), - show_ref=subprocess.CalledProcessError(1, "git"), - for_each_ref=subprocess.CalledProcessError(128, "git", stderr="fatal: not a git repository"), - ) - with ( - _git_on_both_points(run_mock), - pytest.raises(subprocess.CalledProcessError), - ): - branch_module.check_branch_occupancy("feat/x", "feat-x") - - -# --- Logic tests: ensure_pipeline_branch (the branch-procedure orchestrator) --- - - -class TestEnsurePipelineBranch: - def test_ensure_pipeline_branch_end_to_end_free_name( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """One dispatcher over both points: a free name returns the entered name and switches.""" - monkeypatch.chdir(tmp_path) - run_mock = _git_run_dispatch( - show_current=_GitResult(stdout="main\n"), - show_ref=subprocess.CalledProcessError(1, "git"), - for_each_ref=_GitResult(stdout=""), - switch=_GitResult(returncode=0), - ) - with _git_on_both_points(run_mock): - assert branch_module.ensure_pipeline_branch("feat/x") == "feat/x" - assert run_mock.call_args.args[0] == ["git", "switch", "-c", "feat/x"] - - def test_ensure_pipeline_branch_creates_and_switches_as_entered( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """A free name is created with the ENTERED name (topic is the slug — duality).""" - monkeypatch.chdir(tmp_path) - run_mock = _git_run_dispatch( - show_current=_GitResult(stdout="main\n"), - show_ref=subprocess.CalledProcessError(1, "git"), - for_each_ref=_GitResult(stdout=""), - switch=_GitResult(returncode=0), - ) - with _git_on_both_points(run_mock): - assert branch_module.ensure_pipeline_branch("Feature/X") == "Feature/X" - assert run_mock.call_args.args[0] == ["git", "switch", "-c", "Feature/X"] - - def test_ensure_pipeline_branch_already_on_branch_returns_current_name(self) -> None: - """Slug equality with the current branch → the CURRENT name, one probe, no mutation.""" - run_mock = _git_run_dispatch(show_current=_GitResult(stdout="release/1.3.0\n")) - with _git_on_both_points(run_mock): - assert branch_module.ensure_pipeline_branch("release-1.3.0") == "release/1.3.0" - assert run_mock.call_count == 1 - assert run_mock.call_args.args[0] == ["git", "branch", "--show-current"] - - def test_ensure_pipeline_branch_empty_slug_no_tty_fails_with_hint(self) -> None: - """Empty slug without a terminal → ClickException with the reason and the -b hint.""" - run_mock = _git_run_dispatch() - with ( - _git_on_both_points(run_mock), - mock.patch.object(branch_module.sys.stdin, "isatty", return_value=False), - pytest.raises(click.ClickException) as excinfo, - ): - branch_module.ensure_pipeline_branch("Релиз") - message = str(excinfo.value) - assert "normalizes to an empty topic slug" in message - assert "Pass another branch name via -b." in message - assert _switch_argv_calls(run_mock) == [] - - def test_ensure_pipeline_branch_empty_slug_cli_semantics_stderr_exit_1(self) -> None: - """The ClickException surfaces as stderr + exit 1 through a click command.""" - - @click.command() - def _probe() -> None: - branch_module.ensure_pipeline_branch("Релиз") - - with _git_on_both_points(_git_run_dispatch()): - result = CliRunner().invoke(_probe, []) - assert result.exit_code == 1 - assert "normalizes to an empty topic slug" in result.stderr - assert "Pass another branch name via -b." in result.stderr - - def test_ensure_pipeline_branch_conflict_no_tty_fails_with_reason(self) -> None: - """A conflict without a terminal → ClickException with the oracle reason and hint.""" - run_mock = _git_run_dispatch(show_ref=_GitResult(returncode=0)) - with ( - _git_on_both_points(run_mock), - mock.patch.object(branch_module.sys.stdin, "isatty", return_value=False), - pytest.raises(click.ClickException) as excinfo, - ): - branch_module.ensure_pipeline_branch("feat/x") - message = str(excinfo.value) - assert "branch 'feat/x' already exists" in message - assert "Pass another branch name via -b." in message - assert _switch_argv_calls(run_mock) == [] - - def test_ensure_pipeline_branch_history_topic_conflict_no_tty_fails( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Oracle 3 through the orchestrator: the domain topic oracle wins for the current year. - - The year is pinned at the domain's ``naming.datetime`` — the only - clock left in the procedure. The reason names the slug and the current - year, not a hand-composed path. - """ - (tmp_path / ".goga" / "history" / "2031" / "feat-x").mkdir(parents=True) - monkeypatch.chdir(tmp_path) - run_mock = _git_run_dispatch( - show_current=_GitResult(stdout="main\n"), - show_ref=subprocess.CalledProcessError(1, "git"), - for_each_ref=_GitResult(stdout=""), - ) - with ( - mock.patch.object(history_naming, "datetime", _FixedClock), - _git_on_both_points(run_mock), - mock.patch.object(branch_module.sys.stdin, "isatty", return_value=False), - pytest.raises(click.ClickException) as excinfo, - ): - branch_module.ensure_pipeline_branch("feat/x") - message = str(excinfo.value) - assert "history topic 'feat-x' already exists for the current year" in message - assert "Pass another branch name via -b." in message - assert _switch_argv_calls(run_mock) == [] - - def test_ensure_pipeline_branch_tty_reask_until_free(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """On a terminal an occupied name re-asks; the NEW name runs the FULL procedure.""" - monkeypatch.chdir(tmp_path) - run_mock = _git_run_dispatch( - show_current=_GitResult(stdout="main\n"), - show_ref=[_GitResult(returncode=0), subprocess.CalledProcessError(1, "git")], - for_each_ref=_GitResult(stdout=""), - switch=_GitResult(returncode=0), - ) - with ( - _git_on_both_points(run_mock), - mock.patch.object(branch_module.sys.stdin, "isatty", return_value=True), - mock.patch.object(branch_module.click, "prompt", return_value="feat/two") as prompt_mock, - ): - assert branch_module.ensure_pipeline_branch("feat/one") == "feat/two" - assert prompt_mock.call_count == 1 - assert run_mock.call_args.args[0] == ["git", "switch", "-c", "feat/two"] - - def test_ensure_pipeline_branch_abort_leaves_repository_untouched(self) -> None: - """Ctrl-C at the re-ask prompt propagates as click.Abort — no switch ever ran.""" - run_mock = _git_run_dispatch(show_ref=_GitResult(returncode=0)) - with ( - _git_on_both_points(run_mock), - mock.patch.object(branch_module.sys.stdin, "isatty", return_value=True), - mock.patch.object(branch_module.click, "prompt", side_effect=click.Abort()), - pytest.raises(click.Abort), - ): - branch_module.ensure_pipeline_branch("feat/x") - assert _switch_argv_calls(run_mock) == [] - - def test_ensure_pipeline_branch_git_rejects_invalid_name_surfaces_stderr( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """git owns name validity — its stderr is surfaced in the ClickException.""" - monkeypatch.chdir(tmp_path) - run_mock = _git_run_dispatch( - show_current=_GitResult(stdout="main\n"), - show_ref=subprocess.CalledProcessError(1, "git"), - for_each_ref=_GitResult(stdout=""), - switch=subprocess.CalledProcessError(128, "git", stderr="fatal: 'a b' is not a valid branch name"), - ) - with ( - _git_on_both_points(run_mock), - pytest.raises(click.ClickException) as excinfo, - ): - branch_module.ensure_pipeline_branch("a b") - message = str(excinfo.value) - assert "git failed to create branch" in message - assert "fatal: 'a b' is not a valid branch name" in message - - def test_ensure_pipeline_branch_missing_git_binary_fails_cleanly(self) -> None: - """A no-git host is a clean ClickException — never a traceback (both points).""" - run_mock = mock.Mock(side_effect=FileNotFoundError("git")) - with ( - _git_on_both_points(run_mock), - pytest.raises(click.ClickException) as excinfo, - ): - branch_module.ensure_pipeline_branch("feat/x") - assert str(excinfo.value) == "git is required for -b/--branch: git binary not found" - assert _switch_argv_calls(run_mock) == [] - - def test_ensure_pipeline_branch_ref_listing_failure_fails_cleanly( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """A git infra failure of the oracles (e.g. outside a repository) is a clean error. - - ``git for-each-ref`` exiting non-zero (128 outside a repository) must - surface as a ClickException carrying git's stderr — never a raw - ``CalledProcessError`` traceback out of the CLI. - """ - monkeypatch.chdir(tmp_path) - run_mock = _git_run_dispatch( - show_current=_GitResult(stdout="main\n"), - show_ref=subprocess.CalledProcessError(1, "git"), - for_each_ref=subprocess.CalledProcessError(128, "git", stderr="fatal: not a git repository"), - ) - with ( - _git_on_both_points(run_mock), - pytest.raises(click.ClickException) as excinfo, - ): - branch_module.ensure_pipeline_branch("feat/x") - message = str(excinfo.value) - assert "git failed to check branch occupancy" in message - assert "fatal: not a git repository" in message - assert _switch_argv_calls(run_mock) == [] - - def test_ensure_pipeline_branch_reask_validates_new_name_fully(self) -> None: - """The re-asked name re-runs slug + already-on-branch + occupancy — fully.""" - run_mock = _git_run_dispatch( - show_current=_GitResult(stdout="main\n"), - show_ref=[_GitResult(returncode=0)], - ) - with ( - _git_on_both_points(run_mock), - mock.patch.object(branch_module.sys.stdin, "isatty", return_value=True), - mock.patch.object(branch_module.click, "prompt", return_value="main"), - ): - assert branch_module.ensure_pipeline_branch("feat/one") == "main" - assert _switch_argv_calls(run_mock) == [] - - def test_ensure_pipeline_branch_empty_slug_tty_reasks_new_name( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] - ) -> None: - """An empty slug on a terminal re-asks; the loop restarts with the new name. - - The empty-slug branch shares the re-ask machinery with the conflict - branch, but it fires BEFORE the already-on-branch and occupancy steps — - so the first iteration must not reach a single oracle. The re-asked - name then runs the full procedure: occupancy is free, the branch is - created exactly as entered. - """ - monkeypatch.chdir(tmp_path) - run_mock = _git_run_dispatch( - show_current=_GitResult(stdout="main\n"), - show_ref=subprocess.CalledProcessError(1, "git"), - for_each_ref=_GitResult(stdout=""), - switch=_GitResult(returncode=0), - ) - with ( - _git_on_both_points(run_mock), - mock.patch.object(branch_module.sys.stdin, "isatty", return_value=True), - mock.patch.object(branch_module.click, "prompt", return_value="feat/two") as prompt_mock, - ): - assert branch_module.ensure_pipeline_branch("Релиз/Один") == "feat/two" - assert prompt_mock.call_count == 1 - reason = capsys.readouterr().err - assert "normalizes to an empty topic slug" in reason - assert "Релиз/Один" in reason - assert run_mock.call_args.args[0] == ["git", "switch", "-c", "feat/two"] diff --git a/tests/commands/pipeline/test_pipeline_command.py b/tests/commands/pipeline/test_pipeline_command.py index 38c302b5..2df608c3 100644 --- a/tests/commands/pipeline/test_pipeline_command.py +++ b/tests/commands/pipeline/test_pipeline_command.py @@ -593,15 +593,15 @@ def test_pipeline_info_forms_ignore_run_flags(self, tmp_path: Path, monkeypatch: # --- Facade contract: goga/commands/pipeline exports the full contract API --- -# The seven names declared in the cell CODEMANIFEST — the pipeline command, the -# two container launchers, the two branch routines, and the two runtime-dir -# helpers (declared since the cell existed, exported since release 1.3.0; the -# slug transformer and the current-branch reader belong to goga.history and -# are not re-exported from this facade). +# The five names declared in the cell CODEMANIFEST — the pipeline command, the +# two container launchers, and the two runtime-dir helpers (declared since the +# cell existed, exported since release 1.3.0; the slug transformer and the +# current-branch reader belong to goga.history, and the topic procedure +# delegates to goga.topics.switch_topic — neither is re-exported from this +# facade; the former branch routines moved to the topics domain in release +# 1.4.0 and are gone from this cell entirely). _PIPELINE_FACADE_ALL = [ - "check_branch_occupancy", "clean_pipeline_runtime_dir", - "ensure_pipeline_branch", "pipeline", "resolve_pipeline_runtime_dir", "run_pipeline_container", @@ -611,7 +611,7 @@ def test_pipeline_info_forms_ignore_run_flags(self, tmp_path: Path, monkeypatch: class TestCommandsFacadeExportsInfoLauncher: def test_commands_facade_exports_info_launcher(self) -> None: - """The package facade defines all seven public names and lists them in ``__all__``. + """The package facade defines all five public names and lists them in ``__all__``. ``goga.commands.pipeline`` is shadowed on the ``goga.commands`` package by the pipeline Click command (see the module-level note above), so the @@ -625,7 +625,7 @@ def test_commands_facade_exports_info_launcher(self) -> None: assert name in commands_facade.__all__, f"{name} is missing from goga.commands.pipeline.__all__" def test_commands_facade_all_is_alphabetical_and_complete(self) -> None: - """``__all__`` holds exactly the seven names in alphabetical order.""" + """``__all__`` holds exactly the five names in alphabetical order.""" commands_facade = sys.modules["goga.commands.pipeline"] assert commands_facade.__all__ == _PIPELINE_FACADE_ALL @@ -633,13 +633,11 @@ def test_cell_facades_export_full_contract_api(self) -> None: """Every declared contract name is importable from the cell facade root. The Python facade rule obliges ``goga.commands.pipeline`` to expose the - full contract API: the command, both launchers, the two ``branch.py`` - routines, and the two runtime-dir helpers. + full contract API: the command, both launchers, and the two + runtime-dir helpers. """ from goga.commands.pipeline import ( - check_branch_occupancy, clean_pipeline_runtime_dir, - ensure_pipeline_branch, resolve_pipeline_runtime_dir, run_pipeline_container, run_pipeline_info_container, @@ -653,10 +651,36 @@ def test_cell_facades_export_full_contract_api(self) -> None: assert run_pipeline_info_container is not None assert resolve_pipeline_runtime_dir is not None assert clean_pipeline_runtime_dir is not None - assert check_branch_occupancy is not None - assert ensure_pipeline_branch is not None assert sys.modules["goga.commands.pipeline"].__all__ == _PIPELINE_FACADE_ALL + def test_cell_facade_holds_no_branch_machinery(self) -> None: + """The retired branch routines are gone from the facade and the package. + + The branch procedure was replaced by the topic procedure + (-t/--topic via ``switch_topic`` from the topics domain): neither + ``ensure_pipeline_branch`` nor ``check_branch_occupancy`` is defined + on the facade, listed in ``__all__``, or importable as a module of + this cell. + """ + commands_facade = sys.modules["goga.commands.pipeline"] + + assert not hasattr(commands_facade, "ensure_pipeline_branch") + assert not hasattr(commands_facade, "check_branch_occupancy") + assert "ensure_pipeline_branch" not in commands_facade.__all__ + assert "check_branch_occupancy" not in commands_facade.__all__ + assert "goga.commands.pipeline.branch" not in sys.modules + + def test_cell_facade_topic_procedure_imports_from_topics_domain(self) -> None: + """The command module binds ``switch_topic`` from the topics facade. + + The single identity the topic procedure runs through — the ``from + ...topics import switch_topic`` import-point the command's own + dispatch relies on. + """ + from goga.topics import switch_topic as from_domain + + assert _pipeline_module.switch_topic is from_domain + def test_commands_facade_info_launcher_is_importable_by_name(self) -> None: """The consumer form ``from goga.commands.pipeline import run_pipeline_info_container`` works.""" from goga.commands.pipeline import run_pipeline_info_container as from_facade diff --git a/tests/commands/pipeline/test_pipeline_dispatch.py b/tests/commands/pipeline/test_pipeline_dispatch.py index 2ff0486d..ed6ac531 100644 --- a/tests/commands/pipeline/test_pipeline_dispatch.py +++ b/tests/commands/pipeline/test_pipeline_dispatch.py @@ -5,30 +5,32 @@ ``pipeline(ctx, name, extra_env, proxy, add_host, clean, update)``: - new ``--proxy``, ``--add-host`` (multiple), ``--clean``, ``--update/-u`` options -- new ``-b/--branch`` option (run form only): one Option with both forms - binding the ``branch`` parameter; the guarded branch procedure runs after - the step-2 validation and before any docker activity, prints exactly one - stdout line, and never forwards the branch name into a launcher +- the ``-t/--topic`` option (run form only): one Option with both forms + binding the ``topic`` parameter; the guarded topic procedure runs after + the step-2 validation and before any docker activity, echoes exactly one + stdout line — the result line of ``switch_topic`` — and never forwards + the topic into a launcher - proxy resolution: ``--proxy`` wins over ``config.pipeline.proxy`` - hosts resolution: ``--add-host`` entries merge on top of ``config.pipeline.hosts`` (CLI overrides config on key conflict) -- dispatch semantics: discovery (``name is None``) forces ``clean=False``; - run mode forwards ``clean``; both modes forward ``proxy``/``hosts``/``update`` +- dispatch semantics: the listing forms take hosts from the config only; + the run form forwards ``clean``; both modes forward + ``proxy``/``hosts``/``update`` - exit code propagated via ``ctx.exit`` The dispatch target ``run_pipeline_container`` is mocked so these tests stay focused on the click surface and the host-side resolution logic, with no docker dependency. -The integration block at the bottom drives the REAL ``ensure_pipeline_branch`` -through the real command surface, mocking only the process boundary (git -subprocess and docker launcher) to verify the wiring the unit tests mock away. +The integration block at the bottom drives the REAL ``switch_topic`` from the +topics domain through the real command surface, mocking the domain's git +boundary at its import points (the same wiring the topics cell tests use), to +verify the wiring the unit tests mock away. """ from __future__ import annotations import inspect -import subprocess import sys import typing from pathlib import Path @@ -37,10 +39,13 @@ import click import pytest from click.testing import CliRunner -from goga.commands.pipeline import branch as branch_module from goga.commands.pipeline import pipeline from goga.commands.pipeline.pipeline import pipeline as pipeline_cmd from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.history import current_year +from goga.topics import board as topics_board +from goga.topics import switching as topics_switching +from goga.topics.git import BranchRef # goga.commands.pipeline.pipeline is shadowed in the package __init__ by the # pipeline Click command, so a string-based mock.patch path walking through it @@ -117,39 +122,40 @@ def test_help_lists_new_options(self) -> None: assert "-u" in output -class TestPipelineBranchOptionContract: - def test_pipeline_branch_option_contract_both_forms_one_option(self) -> None: - """-b/--branch is a single Option binding ``branch``; both forms reach the procedure. +class TestPipelineTopicOptionContract: + def test_pipeline_topic_option_contract_both_forms_one_option(self) -> None: + """-t/--topic is a single Option binding ``topic``; both forms reach the procedure. The Option carries both forms (``set(param.opts)`` is exactly the pair), defaults to None, and is a plain string option (click renders the declared ``type=str`` as its canonical STRING param type). The callback - declares ``branch: str | None`` directly after ``info`` (contract - order), and ``--branch x NAME`` / ``-b x NAME`` reach - ``ensure_pipeline_branch`` with the same value. + declares ``topic: str | None`` directly after ``info`` (contract + order), and ``--topic x NAME`` / ``-t x NAME`` reach ``switch_topic`` + with the same value. """ - branch_param = next(p for p in pipeline.params if p.name == "branch") - assert set(branch_param.opts) == {"-b", "--branch"} - assert branch_param.default is None - assert branch_param.type is click.STRING + topic_param = next(p for p in pipeline.params if p.name == "topic") + assert set(topic_param.opts) == {"-t", "--topic"} + assert topic_param.default is None + assert topic_param.type is click.STRING parameters = list(inspect.signature(pipeline_cmd.callback).parameters) - assert parameters.index("branch") == parameters.index("info") + 1 + assert parameters.index("topic") == parameters.index("info") + 1 hints = typing.get_type_hints(pipeline_cmd.callback) - assert hints["branch"] == str | None + assert hints["topic"] == str | None + assert "branch" not in parameters config = _make_config() runner = CliRunner() - for argv in (["--branch", "x", "my-pipeline"], ["-b", "x", "my-pipeline"]): + for argv in (["--topic", "x", "my-pipeline"], ["-t", "x", "my-pipeline"]): with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), - mock.patch.object(_pipeline_module, "ensure_pipeline_branch", return_value="x") as mock_ensure, + mock.patch.object(_pipeline_module, "switch_topic", return_value="Switched to branch x") as mock_switch, mock.patch.object(_pipeline_module, "run_pipeline_container", return_value=0), ): result = runner.invoke(pipeline, argv) assert result.exit_code == 0 - mock_ensure.assert_called_once_with("x") + mock_switch.assert_called_once_with("x") # --- Logic tests (positive) --- @@ -301,201 +307,244 @@ def test_pipeline_propagates_exit_code(self, exit_code: int) -> None: assert result.exit_code == exit_code -class TestPipelineBranchRunForm: - def test_pipeline_run_form_with_branch_prints_line_and_launches(self) -> None: - """The run form runs the procedure, prints the branch line, launches without the name.""" +class TestPipelineTopicRunForm: + def test_pipeline_topic_option_switches_before_docker(self) -> None: + """The topic procedure runs, echoes its one result line, then the container launches.""" config = _make_config() + switch_line = "Switched to branch feat/x" + mock_switch = mock.Mock(return_value=switch_line) + mock_run = mock.Mock(return_value=0) + order = mock.Mock() + order.attach_mock(mock_switch, "switch_topic") + order.attach_mock(mock_run, "run_container") runner = CliRunner() with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), - mock.patch.object(_pipeline_module, "ensure_pipeline_branch", return_value="feat/x") as mock_ensure, - mock.patch.object(_pipeline_module, "run_pipeline_container", return_value=0) as mock_run, + mock.patch.object(_pipeline_module, "switch_topic", mock_switch), + mock.patch.object(_pipeline_module, "run_pipeline_container", mock_run), ): - result = runner.invoke(pipeline, ["-b", "feat/x", "my-pipeline"]) + result = runner.invoke(pipeline, ["-t", "feat/x", "development"]) assert result.exit_code == 0 - assert "Pipeline running on branch feat/x" in result.stdout - mock_ensure.assert_called_once_with("feat/x") - assert mock_run.call_count == 1 - assert mock_run.call_args.kwargs["name"] == "my-pipeline" - # The branch name never crosses the docker boundary — no branch kwarg, - # no value equal to it anywhere in the launcher call. - assert "branch" not in mock_run.call_args.kwargs + # Exactly one topic line on stdout, verbatim from switch_topic. + assert result.stdout.count(switch_line) == 1 + mock_switch.assert_called_once_with("feat/x") + mock_run.assert_called_once() + assert mock_run.call_args.kwargs["name"] == "development" + # The switch precedes the docker activity, and the topic identifier + # never crosses the docker boundary. + assert order.method_calls[0] == mock.call.switch_topic("feat/x") + assert order.method_calls[1][0] == "run_container" + assert "topic" not in mock_run.call_args.kwargs assert "feat/x" not in mock_run.call_args.kwargs.values() @pytest.mark.parametrize( "argv", [ - ["-b", "x", "--list"], - ["-b", "x", "--list", "--info"], - ["-b", "x", "my-pipeline", "--info"], + ["-t", "x", "--list"], + ["-t", "x", "--list", "--info"], + ["-t", "x", "my-pipeline", "--info"], ], ids=["flat-list", "overview", "card"], ) - def test_pipeline_list_and_info_forms_silently_skip_branch(self, argv: list[str]) -> None: - """The flat list, overview, and card forms ignore -b — no procedure, no line.""" + def test_pipeline_topic_ignored_in_list_and_info_forms(self, argv: list[str]) -> None: + """The flat list, overview, and card forms ignore -t — no procedure, no line.""" config = _make_config() runner = CliRunner() with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), - mock.patch.object(_pipeline_module, "ensure_pipeline_branch") as mock_ensure, - mock.patch.object(_pipeline_module, "run_pipeline_info_container", return_value=0), + mock.patch.object(_pipeline_module, "switch_topic") as mock_switch, + mock.patch.object(_pipeline_module, "run_pipeline_info_container", return_value=0) as mock_info, ): result = runner.invoke(pipeline, argv) assert result.exit_code == 0 - mock_ensure.assert_not_called() - assert "Pipeline running on branch" not in result.stdout + mock_switch.assert_not_called() + mock_info.assert_called_once() + assert "Switched to branch" not in result.stdout - def test_pipeline_missing_name_error_precedes_branch_procedure(self) -> None: - """A step-2 form error exits 1 before any git action of the branch procedure.""" + def test_pipeline_missing_name_with_topic_no_switch(self) -> None: + """A step-2 form error exits 1 before any git action of the topic procedure.""" config = _make_config() runner = CliRunner() with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), - mock.patch.object(_pipeline_module, "ensure_pipeline_branch") as mock_ensure, + mock.patch.object(_pipeline_module, "switch_topic") as mock_switch, mock.patch.object(_pipeline_module, "run_pipeline_container") as mock_run, ): - result = runner.invoke(pipeline, ["-b", "feat/x"]) + result = runner.invoke(pipeline, ["-t", "x"]) assert result.exit_code == 1 assert "Missing pipeline name" in result.output - mock_ensure.assert_not_called() + mock_switch.assert_not_called() mock_run.assert_not_called() + def test_pipeline_has_no_branch_option(self) -> None: + """-b is gone from the surface: unknown option, and absent from --help.""" + config = _make_config() + runner = CliRunner() + with ( + mock.patch.object(_pipeline_module, "load_project_config", return_value=config), + mock.patch.object(_pipeline_module, "switch_topic") as mock_switch, + ): + result = runner.invoke(pipeline, ["-b", "x", "dev"]) -# --- Integration tests (the real branch procedure through the real command) --- + assert result.exit_code != 0 + mock_switch.assert_not_called() + help_result = runner.invoke(pipeline, ["--help"]) + assert help_result.exit_code == 0 + assert "-b" not in help_result.output + assert "--branch" not in help_result.output -class _GitResult: - """Minimal stand-in for a ``subprocess.CompletedProcess``.""" - def __init__(self, returncode: int = 0, stdout: str = "", stderr: str = "") -> None: - self.returncode = returncode - self.stdout = stdout - self.stderr = stderr +# --- Integration tests (the real topics-domain switch through the real command) --- -def _git_answers( - show_current: object = _GitResult(stdout="main\n"), - show_ref: object = subprocess.CalledProcessError(1, "git"), - for_each_ref: object = _GitResult(stdout=""), - switch: object = _GitResult(), -) -> mock.Mock: - """Build a ``subprocess.run`` mock answering per git subcommand. +def _trees_reader(trees: dict[str, list[str]]): + """A ``read_ref_tree_paths`` stand-in answering by ref display name.""" - Each answer is either a result object (returned) or an exception instance - (raised); an unexpected argv fails the test loudly. Same process-boundary - doubling as ``test_branch.py`` — git itself is never mocked. - """ - answers = { - ("branch", "--show-current"): show_current, - ("show-ref",): show_ref, - ("for-each-ref",): for_each_ref, - ("switch",): switch, - } + def read(ref: str, prefix: str) -> list[str]: + return [path for path in trees.get(ref, []) if path.startswith(prefix)] - def _run(argv: list[str], **_kwargs: object) -> _GitResult: - for key, answer in answers.items(): - if tuple(argv[1 : 1 + len(key)]) == key: - if isinstance(answer, BaseException): - raise answer - return answer - raise AssertionError(f"unexpected git argv in test: {argv!r}") + return read - return mock.Mock(side_effect=_run) +def _wire_topic_domain( + monkeypatch: pytest.MonkeyPatch, + inventory: list[BranchRef], + trees: dict[str, list[str]], + current: str | None, +) -> tuple[mock.Mock, mock.Mock, mock.Mock]: + """Wire the REAL ``switch_topic`` to a canned git boundary. -def _switch_calls(run_mock: mock.Mock) -> list[list[str]]: - """The recorded ``git switch`` argvs (usually asserted to be empty).""" - return [call.args[0] for call in run_mock.call_args_list if call.args[0][1] == "switch"] + The resolution reads the scale, the ref inventory, the ref trees, and the + current branch at their import points inside the topics domain (the same + points the domain's own tests patch); the mutations are recording mocks. + Only the topics facade stays real — exactly the wiring ``pipeline`` relies + on through ``from ...topics import switch_topic``. + Returns: + The cleanliness probe, the local checkout, and the remote-tracking + branch creation — all as recording mocks. + """ + monkeypatch.setattr(topics_switching, "assemble_status_scale", _builtin_scale) + monkeypatch.setattr(topics_switching, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(topics_switching, "resolve_current_branch_name", lambda: current) + monkeypatch.setattr(topics_board, "read_ref_tree_paths", _trees_reader(trees)) + + cleanliness = mock.Mock(return_value=True) + checkout = mock.Mock() + creation = mock.Mock() + monkeypatch.setattr(topics_switching, "is_working_tree_clean", cleanliness) + monkeypatch.setattr(topics_switching, "checkout_local_branch", checkout) + monkeypatch.setattr(topics_switching, "create_branch_from_remote_tracking", creation) + return cleanliness, checkout, creation + + +def _builtin_scale(): + """The deterministic builtin scale for the resolution (no tool packages).""" + from goga.history.statuses import Stage, StatusScale + + return StatusScale( + stages=[ + Stage(name="empty", filepath=""), + Stage(name="defined", filepath="prd.md"), + Stage(name="discovered", filepath="adr.md"), + Stage(name="backlog", filepath="task.md"), + Stage(name="designed", filepath="arch.md"), + Stage(name="specified", filepath="design.md"), + Stage(name="planned", filepath="plan.md"), + Stage(name="done", filepath="completed/plan.md"), + ] + ) -class TestPipelineBranchIntegration: - """Cross-entity: the real ``ensure_pipeline_branch`` through the real command. - Only the process boundary is mocked (git subprocess calls, docker - launcher), so these tests verify the wiring the unit tests mock away: the - ``from .branch import`` path, the run-form guard, the argument handed to - the procedure, and the ordering guarantee — step-2 validation, branch - procedure, branch line, docker activity. +class TestPipelineTopicIntegration: + """Cross-entity: the real ``switch_topic`` from the topics domain through the real command. + + Only the domain's git boundary is canned, so these tests verify the wiring + the unit tests mock away: the ``from ...topics import`` path, the run-form + guard, the argument handed to the domain, and the ordering guarantee — + step-2 validation, topic procedure, topic line, docker activity. """ - def test_pipeline_branch_flow_a_creates_and_launches(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Flow A happy path: created as entered, line printed, launcher unbranch'd.""" + def test_pipeline_topic_flow_switches_and_launches(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Flow happy path: the domain switches, its line prints, the launcher runs topic-free.""" monkeypatch.chdir(tmp_path) + year = current_year() + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="feat/a", remote=False), + ] + trees = {"feat/a": [f".goga/history/{year}/feat-a/plan.md"]} + _cleanliness, checkout, _creation = _wire_topic_domain(monkeypatch, inventory, trees, "main") + config = _make_config() - run_mock = _git_answers() runner = CliRunner() with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), - mock.patch.object(branch_module.subprocess, "run", run_mock), mock.patch.object(_pipeline_module, "run_pipeline_container", return_value=0) as mock_run, ): - result = runner.invoke(pipeline, ["-b", "Feature/X", "my-pipeline"]) + result = runner.invoke(pipeline, ["-t", "feat/a", "my-pipeline"]) assert result.exit_code == 0 - assert "Pipeline running on branch Feature/X" in result.stdout - assert run_mock.call_args_list[-1].args[0] == ["git", "switch", "-c", "Feature/X"] + assert "Switched to branch feat/a" in result.stdout + checkout.assert_called_once_with("feat/a") assert mock_run.call_count == 1 assert mock_run.call_args.kwargs["name"] == "my-pipeline" - # The branch name never crosses the docker boundary. - assert "branch" not in mock_run.call_args.kwargs - assert "Feature/X" not in mock_run.call_args.kwargs.values() + # The topic identifier never crosses the docker boundary. + assert "topic" not in mock_run.call_args.kwargs + assert "feat/a" not in mock_run.call_args.kwargs.values() - def test_pipeline_branch_line_uses_final_branch_name(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Already on the branch: the line carries the CURRENT name and git is untouched.""" + def test_pipeline_topic_idempotent_host_skips_git_mutations( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Already on the host branch: the confirmation line prints and git is untouched.""" monkeypatch.chdir(tmp_path) + year = current_year() + inventory = [BranchRef(name="feat/a", remote=False)] + trees = {"feat/a": [f".goga/history/{year}/feat-a/plan.md"]} + cleanliness, checkout, creation = _wire_topic_domain(monkeypatch, inventory, trees, "feat/a") + config = _make_config() - run_mock = _git_answers(show_current=_GitResult(stdout="release/1.3.0\n")) runner = CliRunner() with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), - mock.patch.object(branch_module.subprocess, "run", run_mock), mock.patch.object(_pipeline_module, "run_pipeline_container", return_value=0) as mock_run, ): - result = runner.invoke(pipeline, ["-b", "release-1.3.0", "my-pipeline"]) + result = runner.invoke(pipeline, ["-t", "feat/a", "my-pipeline"]) assert result.exit_code == 0 - assert "Pipeline running on branch release/1.3.0" in result.stdout - assert _switch_calls(run_mock) == [] + assert "Already on branch feat/a" in result.stdout + cleanliness.assert_not_called() + checkout.assert_not_called() + creation.assert_not_called() assert mock_run.call_count == 1 - def test_pipeline_branch_no_git_host_fails_cleanly_through_cli(self) -> None: - """A host without git: the clean failure on stderr, exit 1, nothing launches.""" - config = _make_config() - runner = CliRunner() - with ( - mock.patch.object(_pipeline_module, "load_project_config", return_value=config), - mock.patch.object(branch_module.subprocess, "run", side_effect=FileNotFoundError("git")), - mock.patch.object(_pipeline_module, "run_pipeline_container") as mock_run, - ): - result = runner.invoke(pipeline, ["-b", "feat/x", "my-pipeline"]) - - assert result.exit_code == 1 - assert "git is required for -b/--branch: git binary not found" in result.stderr - mock_run.assert_not_called() - - def test_pipeline_branch_conflict_without_tty_fails_through_cli( + def test_pipeline_topic_unresolved_identifier_aborts_before_docker( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A conflict without a terminal: reason and -b hint on stderr, no launch.""" + """An unresolved identifier: clean failure on stderr, exit 1, nothing launches.""" monkeypatch.chdir(tmp_path) + year = current_year() + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": [f".goga/history/{year}/other/prd.md"]} + cleanliness, checkout, _creation = _wire_topic_domain(monkeypatch, inventory, trees, "main") + config = _make_config() - run_mock = _git_answers(show_ref=_GitResult(returncode=0)) runner = CliRunner() with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), - mock.patch.object(branch_module.subprocess, "run", run_mock), - mock.patch.object(branch_module.sys.stdin, "isatty", return_value=False), mock.patch.object(_pipeline_module, "run_pipeline_container") as mock_run, ): - result = runner.invoke(pipeline, ["-b", "feat/x", "my-pipeline"]) + result = runner.invoke(pipeline, ["-t", "nope", "my-pipeline"]) assert result.exit_code == 1 - assert "branch 'feat/x' already exists" in result.stderr - assert "Pass another branch name via -b." in result.stderr - assert _switch_calls(run_mock) == [] + assert "no branch hosts 'nope'" in result.stderr + assert "goga topics status" in result.stderr + cleanliness.assert_not_called() + checkout.assert_not_called() mock_run.assert_not_called() @@ -507,3 +556,9 @@ def test_pipeline_callback_has_new_parameters(self) -> None: assert "add_host" in parameters assert "clean" in parameters assert "update" in parameters + + def test_pipeline_callback_declares_topic_not_branch(self) -> None: + """The callback signature carries ``topic: str | None`` and no ``branch``.""" + hints = typing.get_type_hints(pipeline_cmd.callback) + assert hints["topic"] == str | None + assert "branch" not in hints From 0e066752e7f3fb6477a7be81a05e5c9644f60836 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 18:29:30 +0000 Subject: [PATCH 089/229] feat: topics board renderer render_topic_board in commands/topics cell --- goga/commands/topics/__init__.py | 12 ++ goga/commands/topics/render.py | 199 +++++++++++++++++++++++++++ tests/commands/topics/__init__.py | 0 tests/commands/topics/test_render.py | 145 +++++++++++++++++++ 4 files changed, 356 insertions(+) create mode 100644 goga/commands/topics/__init__.py create mode 100644 goga/commands/topics/render.py create mode 100644 tests/commands/topics/__init__.py create mode 100644 tests/commands/topics/test_render.py diff --git a/goga/commands/topics/__init__.py b/goga/commands/topics/__init__.py new file mode 100644 index 00000000..50d24124 --- /dev/null +++ b/goga/commands/topics/__init__.py @@ -0,0 +1,12 @@ +"""Topics command cell — the CLI surface of the topics domain. + +A thin wrapper: the ``topics`` group resolves the inputs and delegates +every computation to the domain routines of ``goga.topics``, and the board +renderer shapes the collected records into the three-column table. No +inventory walking, no switch resolution, and no git access live here; +domain errors surface as clean CLI errors. +""" + +from .render import render_topic_board + +__all__: list[str] = ["render_topic_board"] diff --git a/goga/commands/topics/render.py b/goga/commands/topics/render.py new file mode 100644 index 00000000..3cd9a688 --- /dev/null +++ b/goga/commands/topics/render.py @@ -0,0 +1,199 @@ +"""Console rendering for the topics command group. + +The entity declared in the cell CODEMANIFEST with ``location: render.py``: +the board renderer — the collected board records as a three-column table of +topic, branch, and statuses. Pure output: the records print as given, never +sorted, filtered, or recomputed; the domain owns the collection and the +ordering. +""" + +from __future__ import annotations + +from typing import NamedTuple + +import click + +from ...topics import BoardRecord + +# The fixed grid overhead — three pipe characters and six padding spaces. +_GRID_OVERHEAD = 9 +# The minimum of every column before truncation applies. +_MIN_COLUMN = 8 +# The usable-content floor of the thirds layout — below it the degenerate +# minimum-width layout wins over the width cap. +_USABLE_FLOOR = 3 * _MIN_COLUMN +# The current-row marker — a prefix inside the topic cell. +_CURRENT_MARKER = "* " +# The truncation marker — a single ellipsis character. +_ELLIPSIS = "…" + + +class _Columns(NamedTuple): + """The caps of the three board columns, in grid order.""" + + topic: int + branch: int + statuses: int + + +def render_topic_board(records: list[BoardRecord], width: int) -> None: + """Render the board as a three-column table: topic, branch, statuses. + + Args: + records: The collected board records — already sorted by the domain. + width: The measured terminal width in columns. + + Algorithm: + 1. Compute the column widths from ``width`` — the width rule of the + requirements + 2. Print one header row and one separator row with column and row + dividers + 3. Print each record: the topic truncated with an ellipsis when it + exceeds its column, the branch truncated the same way, and the + statuses wrapped onto continuation lines without affecting the + column widths + 4. Mark the record hosting the current branch with an asterisk; the + remote prefix of a remote host stays visible in the branch column + 5. An empty ``records`` prints nothing + + Requirements: + Topic and branch get an equal share first — each capped at one third + of ``width`` minus the dividers — and statuses take the remainder; + every column keeps a minimum of 8 columns before truncation applies. + The truncation marker is a single ellipsis character; an overlong + status segment is truncated like the other columns. The table never + exceeds ``width``, with one documented exception: when ``width`` is + below 33, every column keeps its minimum of 8 and the table may + exceed ``width`` — minimum readability wins over the width cap on + ultra-narrow terminals. + + Constraints: + Read-only on ``records`` — do not mutate, do not re-sort, do not + filter. Do not print the year or the artifacts. + """ + if not records: + return + columns = _column_widths(width) + click.echo(_row_line(("Topic", "Branch", "Statuses"), columns)) + click.echo(_separator(columns)) + for record in records: + topic_text = f"{_CURRENT_MARKER}{record.topic}" if record.current else record.topic + segments = [f"[{status}]" for status in record.statuses] + for index, statuses_line in enumerate(_wrap_segments(segments, columns.statuses)): + cells = ( + topic_text if index == 0 else "", + record.branch if index == 0 else "", + statuses_line, + ) + click.echo(_row_line(cells, columns)) + + +def _column_widths(width: int) -> _Columns: + """Resolve the column widths of the grid for one terminal width. + + Args: + width: The measured terminal width in columns. + + Returns: + The caps of the topic, branch, and statuses columns. With at least + 24 usable columns topic and branch take an equal third each and + statuses the remainder; below that every column keeps its minimum + of 8 and the table may exceed ``width``. + """ + usable = width - _GRID_OVERHEAD + if usable < _USABLE_FLOOR: + return _Columns(_MIN_COLUMN, _MIN_COLUMN, _MIN_COLUMN) + topic_cap = usable // 3 + return _Columns(topic_cap, topic_cap, usable - 2 * topic_cap) + + +def _row_line(cells: tuple[str, str, str], columns: _Columns) -> str: + """Build one grid row — every cell fitted to its column. + + The fixed overhead of the grid is three pipes and six padding spaces: + the leading pipe, the two column separators, and the right padding of + the statuses cell — the table closes on the padded column, not on a + trailing pipe. + + Args: + cells: The topic, branch, and statuses cell texts of this grid + line — the continuation lines pass the first two empty. + columns: The caps of the three columns. + + Returns: + The grid line with the cells truncated, padded, and divided. + """ + topic, branch, statuses = cells + return f"| {_fit(topic, columns.topic)} | {_fit(branch, columns.branch)} | {_fit(statuses, columns.statuses)} " + + +def _separator(columns: _Columns) -> str: + """Build the row divider of the grid. + + Args: + columns: The caps of the three columns. + + Returns: + The separator row — one dash run per column under its padding, + joined by the pipes of the grid. + """ + return f"|{'-' * (columns.topic + 2)}|{'-' * (columns.branch + 2)}|{'-' * (columns.statuses + 2)}" + + +def _fit(text: str, cap: int) -> str: + """Fit one cell — truncate an overlong text, then pad to the column. + + Args: + text: The cell text — already carrying the current-row marker when + the row hosts the current branch. + cap: The column cap in columns. + + Returns: + The cell text of exactly ``cap`` columns. + """ + return _truncate(text, cap).ljust(cap) + + +def _truncate(text: str, cap: int) -> str: + """Truncate one text to its column — the single-character ellipsis marker. + + Args: + text: The text to fit. + cap: The column cap in columns. + + Returns: + The text unchanged when it fits; ``text[: cap - 1]`` plus the + ellipsis when it exceeds the cap. + """ + if len(text) > cap: + return f"{text[: cap - 1]}{_ELLIPSIS}" + return text + + +def _wrap_segments(segments: list[str], statuses_w: int) -> list[str]: + """Wrap the status segments onto the continuation lines of the column. + + Args: + segments: The bracketed status names of one record. + statuses_w: The cap of the statuses column. + + Returns: + The statuses cell content per grid line — a greedy fill that keeps + every segment whole; a single segment longer than the column is + truncated with the ellipsis like the other columns. The grid lives + on: the continuation lines carry empty topic and branch cells. + """ + lines: list[str] = [] + current = "" + for segment in segments: + piece = _truncate(segment, statuses_w) + if not current: + current = piece + elif len(current) + 1 + len(piece) <= statuses_w: + current = f"{current} {piece}" + else: + lines.append(current) + current = piece + if current or not lines: + lines.append(current) + return lines diff --git a/tests/commands/topics/__init__.py b/tests/commands/topics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/commands/topics/test_render.py b/tests/commands/topics/test_render.py new file mode 100644 index 00000000..cdffa5a9 --- /dev/null +++ b/tests/commands/topics/test_render.py @@ -0,0 +1,145 @@ +"""Contract and logic tests for the entities declared in +``goga/commands/topics/CODEMANIFEST`` with ``location: render.py``: + +- ``render_topic_board(records: list[BoardRecord], width: int)`` + +The board renderer is pure output: the collected records print as given — +no sorting, no filtering, no mutation — as a three-column table of topic, +branch, and statuses whose widths follow the P9 arithmetic. Output is +captured with ``capsys``. +""" + +from __future__ import annotations + +import inspect +import typing + +import pytest +from goga.commands.topics import render, render_topic_board +from goga.topics import BoardRecord + +# --- Contract tests --- + + +class TestRenderContract: + def test_entity_is_importable_from_facade_and_callable(self) -> None: + """``render_topic_board`` is importable from ``goga.commands.topics``.""" + assert render.render_topic_board is render_topic_board + assert callable(render_topic_board) + + def test_render_topic_board_signature(self) -> None: + """``render_topic_board(records: list[BoardRecord], width: int) -> None``.""" + signature = inspect.signature(render_topic_board) + assert list(signature.parameters) == ["records", "width"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + hints = typing.get_type_hints(render_topic_board) + assert hints == {"records": list[BoardRecord], "width": int, "return": type(None)} + + def test_render_topic_board_empty_input_prints_nothing(self, capsys: pytest.CaptureFixture[str]) -> None: + """An empty board renders not a single line — header included.""" + render_topic_board([], 80) + assert capsys.readouterr().out == "" + + +# --- Logic tests --- + + +class TestRenderTopicBoard: + def test_render_topic_board_widths_and_wrap(self, capsys: pytest.CaptureFixture[str]) -> None: + """Width 60 — thirds of 17, overlong cells truncated, statuses wrapped.""" + records = [ + BoardRecord( + topic="very-long-topic-name-123", + branch="feat/very-long-branch", + statuses=["planned", "mkdocs.published"], + current=False, + remote=False, + ) + ] + render_topic_board(records, 60) + lines = capsys.readouterr().out.splitlines() + # usable = 51, so topic_cap = branch_cap = 17 and statuses_w = 17; + # every grid line stays within the measured width. + assert len(lines) == 4 + assert all(len(line) <= 60 for line in lines) + assert lines[0].startswith("| Topic") + assert "Branch" in lines[0] + assert "Statuses" in lines[0] + assert set(lines[1]) == {"-", "|"} + # The topic and the branch exceed 17 columns — both carry the ellipsis. + assert "…" in lines[2] + # The first segment prints whole; the second one is 18 > 17 and + # therefore appears truncated on the continuation line. + assert "[planned]" in lines[2] + assert "mkdocs.publis" in lines[3] + assert "…" in lines[3] + # The grid survives the wrap — empty topic and branch continuation cells. + assert lines[3].startswith(f"|{' ' * 19}|{' ' * 19}|") + # Read-only — the renderer does not mutate, re-sort, or filter the input. + assert records[0].topic == "very-long-topic-name-123" + assert records[0].statuses == ["planned", "mkdocs.published"] + + def test_render_topic_board_current_asterisk_and_empty(self, capsys: pytest.CaptureFixture[str]) -> None: + """The current row carries the ``* `` marker; an empty board prints nothing.""" + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["planned"], + current=True, + remote=False, + ) + ] + render_topic_board(records, 80) + captured = capsys.readouterr() + assert any(line.startswith("| * feat-a") for line in captured.out.splitlines()) + assert "[planned]" in captured.out + render_topic_board([], 80) + assert capsys.readouterr().out == "" + + def test_render_topic_board_degenerate_narrow_terminal(self, capsys: pytest.CaptureFixture[str]) -> None: + """Width 20 — every column keeps its minimum of 8, the table may exceed.""" + records = [ + BoardRecord( + topic="a-very-long-topic", + branch="feat/x", + statuses=["done"], + current=False, + remote=False, + ) + ] + render_topic_board(records, 20) + lines = capsys.readouterr().out.splitlines() + # Three minimum-8 columns plus the grid overhead of 9 — the table is + # 33 columns wide on a 20-column terminal: minimum readability wins. + assert all(len(line) == 33 for line in lines) + # Truncation still applies — the 17-column topic does not fit 8. + assert "…" in lines[2] + assert "feat/x" in lines[2] + assert "[done]" in lines[2] + + @pytest.mark.parametrize(("width", "degenerate"), [(33, False), (32, True)]) + def test_render_topic_board_boundary_width_33_32( + self, capsys: pytest.CaptureFixture[str], width: int, degenerate: bool + ) -> None: + """Width 33 splits evenly into the minimum thirds; 32 stays at them anyway.""" + records = [ + BoardRecord(topic="feat-a", branch="feat/a", statuses=["done"], current=False, remote=False), + BoardRecord( + topic="a-very-long-topic-name", branch="feat/x", statuses=["done"], current=False, remote=False + ), + ] + render_topic_board(records, width) + lines = capsys.readouterr().out.splitlines() + # Both boundaries resolve to the 8/8/8 minimum layout — width 33 fits + # the table exactly; width 32 is the documented one-column overflow. + assert all(len(line) == 33 for line in lines) + assert "feat-a" in lines[2] + assert "[done]" in lines[2] + assert "…" in lines[3] + if degenerate: + assert width < 33 + else: + assert width == 33 From 189cfee29d4b45b13f4368cf737aa1c5bdffd0bb Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 18:33:57 +0000 Subject: [PATCH 090/229] feat: topics command group with status/create/switch subcommands in commands/topics cell --- goga/commands/topics/__init__.py | 3 +- goga/commands/topics/topics.py | 100 +++++++ tests/commands/topics/test_topics_command.py | 289 +++++++++++++++++++ 3 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 goga/commands/topics/topics.py create mode 100644 tests/commands/topics/test_topics_command.py diff --git a/goga/commands/topics/__init__.py b/goga/commands/topics/__init__.py index 50d24124..898c9fef 100644 --- a/goga/commands/topics/__init__.py +++ b/goga/commands/topics/__init__.py @@ -8,5 +8,6 @@ """ from .render import render_topic_board +from .topics import topics -__all__: list[str] = ["render_topic_board"] +__all__: list[str] = ["render_topic_board", "topics"] diff --git a/goga/commands/topics/topics.py b/goga/commands/topics/topics.py new file mode 100644 index 00000000..c36fb068 --- /dev/null +++ b/goga/commands/topics/topics.py @@ -0,0 +1,100 @@ +"""The ``goga topics`` command group — the CLI surface of the topics domain. + +The click group declared in the cell CODEMANIFEST with ``location: +topics.py``: the ``status``/``create``/``switch`` subcommands over the +topics domain. The group carries the year scope every subcommand shares and +is a thin wrapper — it resolves the inputs, delegates every computation to +the domain routines of ``goga.topics``, and renders the board through the +``render`` module. No inventory walking, no switch resolution, and no git +access live here; domain errors surface as clean CLI errors. +""" + +from __future__ import annotations + +import shutil +from dataclasses import dataclass + +import click + +from ...topics import collect_topic_board, create_topic, switch_topic +from .render import render_topic_board + + +@dataclass(kw_only=True) +class _TopicsScope: + """The year scope shared by every subcommand of the group.""" + + year: str | None = None + + +@click.group() +@click.option( + "--year", + "-y", + default=None, + help="Four-digit year scope shared by every subcommand (default: the current year)", +) +@click.pass_context +def topics(ctx: click.Context, year: str | None = None) -> None: + """Work with the topics of one year.""" + ctx.ensure_object(_TopicsScope) + ctx.obj.year = year + + +@topics.command("status") +@click.option( + "--remote", + "-r", + is_flag=True, + default=False, + help="Read remote-tracking refs instead of local branches.", +) +@click.pass_obj +def status(scope: _TopicsScope, remote: bool = False) -> None: + """Print the board — the cross-branch topic inventory of the scoped year. + + One three-column table row per topic: topic, branch, statuses — the row + of the current branch carries an asterisk and the statuses wrap onto + continuation lines when they overflow. --remote/-r reads remote-tracking + refs instead of local branches. An empty board prints nothing and exits + 0 — it is not an error. The year defaults to the current one and is + never printed. + """ + records = collect_topic_board(scope.year, remote) + render_topic_board(records, shutil.get_terminal_size().columns) + click.get_current_context().exit(0) + + +@topics.command("create") +@click.argument("branch_name") +@click.pass_obj +def create(scope: _TopicsScope, branch_name: str) -> None: + """Create fresh work — a branch with the name as entered and its topic directory. + + The branch name is taken verbatim; the topic directory of the scoped + year is created from its slug. The current branch already hosting the + same slug is an idempotent success. Occupied names and empty slugs + re-ask on an interactive terminal and fail with a clean error + otherwise. One result line on stdout. + """ + line = create_topic(branch_name, scope.year) + click.echo(line) + click.get_current_context().exit(0) + + +@topics.command("switch") +@click.argument("identifier") +@click.pass_obj +def switch(scope: _TopicsScope, identifier: str) -> None: + """Bring the repository onto the branch hosting the requested work. + + IDENTIFIER is a branch name, a topic slug, or their prefix — resolved in + that order. Several candidates offer a numbered list and a prompt on an + interactive terminal; already being on the host is an idempotent + success, and a dirty working tree is a clean error when a mutation is + needed. One result line on stdout; no pipeline is launched — + continuation is a separate command. + """ + line = switch_topic(identifier, scope.year) + click.echo(line) + click.get_current_context().exit(0) diff --git a/tests/commands/topics/test_topics_command.py b/tests/commands/topics/test_topics_command.py new file mode 100644 index 00000000..7bc57510 --- /dev/null +++ b/tests/commands/topics/test_topics_command.py @@ -0,0 +1,289 @@ +"""Contract and logic tests for the entity declared in +``goga/commands/topics/CODEMANIFEST`` with ``location: topics.py``: +the ``topics`` click group with the ``status``/``create``/``switch`` +subcommands. + +The group is a thin wrapper: the ``--year/-y`` option builds the scope every +subcommand shares, and each subcommand delegates its computation to the +``goga.topics`` domain — the board collection and rendering for ``status``, +the creation and switching procedures for ``create``/``switch``. The logic +tests mock the domain at its import site in the command module and drive +the CLI surface through ``CliRunner``; a pinned ``COLUMNS`` keeps the +measured terminal width deterministic. +""" + +from __future__ import annotations + +import inspect +import sys +from unittest import mock + +import click +import pytest +from click.testing import CliRunner +from goga.commands.topics import render_topic_board, topics +from goga.topics import BoardRecord + +# goga.commands.topics.topics is shadowed in the package __init__ by the +# topics click group, so attribute access through the package gives the +# group. Resolve the real module via sys.modules (precedent: test_history). +_topics_module = sys.modules["goga.commands.topics.topics"] +# The facade __all__ lives on the cell package itself. +_topics_facade = sys.modules["goga.commands.topics"] + +# --- Contract tests --- + + +class TestTopicsGroupContract: + def test_topics_importable_from_facade(self) -> None: + """topics is importable from the goga.commands.topics facade.""" + assert _topics_module.topics is topics + + def test_facade_exports_two_names(self) -> None: + """The cell facade carries the two declared names, alphabetically.""" + assert _topics_facade.__all__ == ["render_topic_board", "topics"] + assert callable(topics) + assert callable(render_topic_board) + + def test_topics_is_a_click_group(self) -> None: + """topics is a click.Group container for the subcommands.""" + assert isinstance(topics, click.Group) + + def test_topics_registers_three_subcommands(self) -> None: + """The group carries exactly the three declared subcommands.""" + assert sorted(topics.commands) == ["create", "status", "switch"] + + def test_topics_group_carries_the_year_option(self) -> None: + """The group owns the shared --year/-y option, defaulting to None.""" + assert len(topics.params) == 1 + year_option = next(p for p in topics.params if isinstance(p, click.Option) and p.name == "year") + assert "-y" in year_option.opts + assert "--year" in year_option.opts + assert year_option.default is None + + def test_topics_group_callback_signature(self) -> None: + """``topics(ctx, year)`` — the context and the scoped year.""" + callback = topics.callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["ctx", "year"] + assert signature.parameters["year"].default is None + + def test_scope_is_a_kw_only_dataclass_with_year(self) -> None: + """``_TopicsScope`` is a kw_only dataclass carrying the year field.""" + scope = _topics_module._TopicsScope(year="2025") + assert scope.year == "2025" + assert _topics_module._TopicsScope().year is None + + def test_status_callback_signature(self) -> None: + """``status(scope, remote=False)`` — the scope object and the flag.""" + callback = topics.commands["status"].callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["scope", "remote"] + assert signature.parameters["remote"].default is False + + def test_status_carries_the_remote_flag(self) -> None: + """status: --remote/-r flag, defaulting to False.""" + command = topics.commands["status"] + remote_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "remote") + assert "-r" in remote_option.opts + assert "--remote" in remote_option.opts + assert remote_option.is_flag is True + assert remote_option.default is False + + def test_create_carries_the_name_positional(self) -> None: + """create: the required branch_name positional.""" + command = topics.commands["create"] + argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "branch_name") + assert argument.required is True + + def test_create_callback_signature(self) -> None: + """``create(scope, branch_name)``.""" + callback = topics.commands["create"].callback + assert list(inspect.signature(callback).parameters) == ["scope", "branch_name"] + + def test_switch_carries_the_identifier_positional(self) -> None: + """switch: the required identifier positional.""" + command = topics.commands["switch"] + argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "identifier") + assert argument.required is True + + def test_switch_callback_signature(self) -> None: + """``switch(scope, identifier)``.""" + callback = topics.commands["switch"].callback + assert list(inspect.signature(callback).parameters) == ["scope", "identifier"] + + +# --- Logic tests --- + + +class TestTopicsGroupSurface: + def test_topics_group_help_and_year_scope(self) -> None: + """--help lists the subcommands and --year/-y; the scope reaches the domain.""" + runner = CliRunner() + result = runner.invoke(topics, ["--help"]) + assert result.exit_code == 0 + assert "Work with the topics of one year." in result.output + for subcommand in ("status", "create", "switch"): + assert subcommand in result.output + assert "--year" in result.output + assert "-y" in result.output + + with mock.patch.object(_topics_module, "create_topic") as mock_create: + mock_create.return_value = "Created branch X and topic 2025/x" + scoped = runner.invoke(topics, ["--year", "2025", "create", "X"]) + assert scoped.exit_code == 0 + mock_create.assert_called_once_with("X", "2025") + + @pytest.mark.parametrize("subcommand", ["status", "create", "switch"]) + def test_subcommand_help_follows_the_cli_docstring_rule(self, subcommand: str) -> None: + """The rendered help carries no Args/Returns/Raises sections.""" + result = CliRunner().invoke(topics, [subcommand, "--help"]) + assert result.exit_code == 0 + assert result.output.strip() != "" + for section in ("Args:", "Returns:", "Raises:"): + assert section not in result.output + + def test_year_defaults_to_none_for_the_domain(self) -> None: + """Without --year the subcommands hand the domain the current-year None.""" + with mock.patch.object(_topics_module, "create_topic") as mock_create: + mock_create.return_value = "Created branch X and topic 2026/x" + result = CliRunner().invoke(topics, ["create", "X"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with("X", None) + + +class TestTopicsStatus: + def test_status_collects_and_renders_the_board(self) -> None: + """status hands the domain (scope.year, remote) and renders the records.""" + records = [ + BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=True, remote=False), + ] + with ( + mock.patch.object(_topics_module, "collect_topic_board", return_value=records) as mock_collect, + mock.patch.dict("os.environ", {"COLUMNS": "100"}), + ): + result = CliRunner().invoke(topics, ["status"]) + assert result.exit_code == 0 + mock_collect.assert_called_once_with(None, False) + assert "feat-a" in result.output + assert "feat/a" in result.output + assert "[planned]" in result.output + assert "| Topic" in result.output + + def test_status_passes_the_year_and_the_remote_flag(self) -> None: + """--year and --remote/-r reach the domain call verbatim.""" + with ( + mock.patch.object(_topics_module, "collect_topic_board", return_value=[]) as mock_collect, + mock.patch.dict("os.environ", {"COLUMNS": "100"}), + ): + result = CliRunner().invoke(topics, ["--year", "2025", "status", "--remote"]) + assert result.exit_code == 0 + mock_collect.assert_called_once_with("2025", True) + + def test_status_short_forms_bind_the_same_values(self) -> None: + """-y and -r behave exactly like their long forms.""" + with ( + mock.patch.object(_topics_module, "collect_topic_board", return_value=[]) as mock_collect, + mock.patch.dict("os.environ", {"COLUMNS": "100"}), + ): + result = CliRunner().invoke(topics, ["-y", "2024", "status", "-r"]) + assert result.exit_code == 0 + mock_collect.assert_called_once_with("2024", True) + + def test_status_empty_board_prints_nothing_exit_zero(self) -> None: + """An empty board is not an error — nothing on stdout, exit 0.""" + with ( + mock.patch.object(_topics_module, "collect_topic_board", return_value=[]), + mock.patch.dict("os.environ", {"COLUMNS": "100"}), + ): + result = CliRunner().invoke(topics, ["status"]) + assert result.exit_code == 0 + assert result.output == "" + + @pytest.mark.parametrize(("columns", "expected"), [(40, 40), (30, 33)]) + def test_status_measures_the_terminal_width(self, columns: int, expected: int) -> None: + """The render width is the measured terminal width, not a constant.""" + records = [ + BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=False, remote=False), + ] + with ( + mock.patch.object(_topics_module, "collect_topic_board", return_value=records), + mock.patch.dict("os.environ", {"COLUMNS": str(columns)}), + ): + result = CliRunner().invoke(topics, ["status"]) + assert result.exit_code == 0 + # Width 40 lays out in thirds — the table fits it exactly; width 30 + # is the documented ultra-narrow exception where the minimum 8/8/8 + # layout of 33 columns wins. Either way the measurement was taken. + assert result.output.splitlines() != [] + assert all(len(line) == expected for line in result.output.splitlines()) + + def test_status_domain_error_surfaces_clean(self) -> None: + """A domain ClickException propagates as stderr + exit 1, no traceback.""" + with mock.patch.object( + _topics_module, + "collect_topic_board", + side_effect=click.ClickException("no branch hosts 'x' — run 'goga topics status' to see the board"), + ): + result = CliRunner().invoke(topics, ["status"]) + assert result.exit_code == 1 + assert "no branch hosts" in result.stderr + assert "Traceback" not in result.stderr + assert result.stdout == "" + + +class TestTopicsCreateAndSwitch: + def test_create_echoes_the_domain_result_line(self) -> None: + """create echoes the single result line and exits 0.""" + with mock.patch.object( + _topics_module, + "create_topic", + return_value="Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar", + ) as mock_create: + result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with("Feature/Foo_Bar", None) + assert result.output.splitlines() == ["Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar"] + + def test_switch_echoes_the_domain_result_line(self) -> None: + """switch echoes the single result line and exits 0.""" + with mock.patch.object( + _topics_module, + "switch_topic", + return_value="Switched to branch feat/a", + ) as mock_switch: + result = CliRunner().invoke(topics, ["switch", "feat-a"]) + assert result.exit_code == 0 + mock_switch.assert_called_once_with("feat-a", None) + assert result.output.splitlines() == ["Switched to branch feat/a"] + + def test_switch_receives_the_scoped_year(self) -> None: + """--year reaches switch_topic verbatim.""" + with mock.patch.object(_topics_module, "switch_topic", return_value="Already on branch feat/a") as mock_switch: + result = CliRunner().invoke(topics, ["--year", "2025", "switch", "feat-a"]) + assert result.exit_code == 0 + mock_switch.assert_called_once_with("feat-a", "2025") + assert result.output.splitlines() == ["Already on branch feat/a"] + + @pytest.mark.parametrize(("subcommand", "argument"), [("create", "branch_name"), ("switch", "identifier")]) + def test_missing_positional_is_usage_error(self, subcommand: str, argument: str) -> None: + """A missing positional is click's own usage error — exit 2, no domain call.""" + with ( + mock.patch.object(_topics_module, "create_topic") as mock_create, + mock.patch.object(_topics_module, "switch_topic") as mock_switch, + ): + result = CliRunner().invoke(topics, [subcommand]) + assert result.exit_code == 2 + assert argument.upper() in result.output + mock_create.assert_not_called() + mock_switch.assert_not_called() + + @pytest.mark.parametrize(("subcommand", "routine"), [("create", "create_topic"), ("switch", "switch_topic")]) + def test_domain_error_surfaces_clean(self, subcommand: str, routine: str) -> None: + """A domain ClickException propagates as stderr + exit 1, no traceback.""" + with mock.patch.object(_topics_module, routine, side_effect=click.ClickException("working tree is dirty")): + result = CliRunner().invoke(topics, [subcommand, "x"]) + assert result.exit_code == 1 + assert "working tree is dirty" in result.stderr + assert "Traceback" not in result.stderr + assert result.stdout == "" From b77a51518e8a87ec849dce95f733e1690fb71600 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 18:36:55 +0000 Subject: [PATCH 091/229] feat: topics command wired through goga/commands facade and root app registration --- goga/cli.py | 2 ++ goga/commands/__init__.py | 2 ++ tests/commands/test_commands_facade.py | 16 +++++++++ tests/test_cli.py | 45 +++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/goga/cli.py b/goga/cli.py index fa89ba7e..a2895f08 100644 --- a/goga/cli.py +++ b/goga/cli.py @@ -16,6 +16,7 @@ pipeline, schema, tool, + topics, uninstall, upgrade, usages, @@ -76,3 +77,4 @@ def app() -> None: app.add_command(tool) app.add_command(upgrade) app.add_command(history) +app.add_command(topics) diff --git a/goga/commands/__init__.py b/goga/commands/__init__.py index 8185235d..5f976762 100644 --- a/goga/commands/__init__.py +++ b/goga/commands/__init__.py @@ -9,6 +9,7 @@ from .pipeline import pipeline from .schema import schema from .tool import tool +from .topics import topics from .upgrade import upgrade from .usages import usages @@ -24,6 +25,7 @@ "pipeline", "schema", "tool", + "topics", "uninstall", "upgrade", "usages", diff --git a/tests/commands/test_commands_facade.py b/tests/commands/test_commands_facade.py index ee87fe35..be4bb689 100644 --- a/tests/commands/test_commands_facade.py +++ b/tests/commands/test_commands_facade.py @@ -4,10 +4,12 @@ from goga import commands from goga.commands import install as install_reexport from goga.commands import pipeline as pipeline_reexport +from goga.commands import topics as topics_reexport from goga.commands import uninstall as uninstall_reexport from goga.commands.install import install as install_source from goga.commands.install import uninstall as uninstall_source from goga.commands.pipeline import pipeline as pipeline_source +from goga.commands.topics import topics as topics_source class TestCommandsFacade: @@ -52,3 +54,17 @@ def test_uninstall_listed_in_all(self) -> None: def test_uninstall_is_a_click_command_via_facade(self) -> None: """The re-exported uninstall is a click.Command.""" assert isinstance(commands.uninstall, click.Command) + + +class TestTopicsFacade: + def test_topics_reexported_from_facade(self) -> None: + """topics is importable from the goga.commands facade.""" + assert topics_reexport is topics_source + + def test_topics_listed_in_all(self) -> None: + """topics is a declared member of the goga.commands facade.""" + assert "topics" in commands.__all__ + + def test_topics_is_a_click_group_via_facade(self) -> None: + """The re-exported topics is a click Group (a command group).""" + assert isinstance(topics_reexport, click.Group) diff --git a/tests/test_cli.py b/tests/test_cli.py index 06055eea..4b1c2406 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -103,6 +103,11 @@ def test_uninstall_command_registered(self) -> None: """The 'uninstall' command is registered on the app group.""" assert "uninstall" in app.commands + def test_topics_command_registered(self) -> None: + """The 'topics' command is registered on the app group (command.name).""" + assert any(command.name == "topics" for command in app.commands.values()) + assert "topics" in app.commands + class TestHelpOutput: def test_help_exit_code_zero(self) -> None: @@ -148,6 +153,12 @@ def test_help_contains_uninstall(self) -> None: result = runner.invoke(app, ["--help"]) assert "uninstall" in result.output + def test_help_contains_topics(self) -> None: + """The --help output lists the 'topics' command.""" + runner = CliRunner() + result = runner.invoke(app, ["--help"]) + assert "topics" in result.output + class TestBuildHelpOutput: def test_build_help_exit_code_zero(self) -> None: @@ -317,5 +328,37 @@ def test_cli_registers_history_group() -> None: assert subcommand in history_help.output assert "history" in commands.__all__ - assert len(commands.__all__) == 14 + assert len(commands.__all__) == 15 assert hasattr(commands, "history") + + +def test_facades_export_topics() -> None: + """Every facade of the feature exports its contract names. + + Design-doc scenario: ``topics`` resolves through ``goga.commands``; the + domain entries resolve through ``goga.topics``; the history embeddings + resolve through ``goga.history``; ``app`` registers the ``topics`` + command; the deleted single-status enum stays gone. + """ + import goga.commands + import goga.history + from goga import app as root_app + from goga.commands import topics as topics_group + from goga.history import ( + StatusScale, + assemble_status_scale, + resolve_history_root, + ) + from goga.topics import collect_topic_board, create_topic, switch_topic + + assert topics_group is not None + assert collect_topic_board is not None + assert switch_topic is not None + assert create_topic is not None + assert StatusScale is not None + assert assemble_status_scale is not None + assert resolve_history_root is not None + + assert "topics" in goga.commands.__all__ + assert any(command.name == "topics" for command in root_app.commands.values()) + assert "TopicStatus" not in goga.history.__all__ From d996cb052318e6c45de565abc03cba6e808d1831 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 18:43:04 +0000 Subject: [PATCH 092/229] feat: end-to-end integration tests for topic workflows --- tests/integration/test_topic_workflows.py | 264 ++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 tests/integration/test_topic_workflows.py diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py new file mode 100644 index 00000000..2c0bd532 --- /dev/null +++ b/tests/integration/test_topic_workflows.py @@ -0,0 +1,264 @@ +"""End-to-end integration tests for the topic workflows of one year. + +These exercise the cross-cell paths of the usability-for-topic-workaround +feature over its real surfaces — no domain routine and no renderer is +mocked, only the boundaries the environment cannot provide: + + goga topics status --year Y + -> goga.commands.topics.topics.status + -> goga.topics.collect_topic_board + -> [goga.history.assemble_status_scale (real tool packages) + goga.topics.git.list_branch_refs / read_ref_tree_paths + goga.history.resolve_topic_status (working copy)] + -> goga.commands.topics.render_topic_board + + goga history status -s <tool>.<name> + -> goga.commands.history.history.status + -> goga.history.assemble_status_scale + -> goga.history.collect_topic_statuses + -> goga.commands.history.render_topic_statuses + + goga pipeline -t/--topic — the removed -b/--branch regression, and the + idempotent switch chain of ``switch_topic`` through the real git cell. + +Git is real: the git-dependent scenarios run in a throwaway repository +under ``tmp_path`` (``git init`` plus commits, with ``git update-ref`` +manufacturing the remote-tracking twin) and skip when no git binary is +available. The status-scale assembly runs for real against the installed +``goga_tool_*`` packages; the qualified-name filter scenario first checks +that a registered tool status truly exists in ``assemble_status_scale()`` +and falls back to a fake ``goga_tool_*`` module in ``sys.modules`` plus a +patched packages map otherwise — the command surface, the domain, and the +assembly stay the real ones either way. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path +from types import ModuleType +from unittest import mock + +import pytest +from click.testing import CliRunner +from goga.cli import app +from goga.commands.history import history +from goga.commands.topics import topics +from goga.history import assemble_status_scale +from goga.history.statuses import assembly as statuses_assembly +from goga.topics import switch_topic +from goga.topics import switching as topics_switching + +# The scenarios drive real git — skip them where no git binary exists. +requires_git = pytest.mark.skipif(shutil.which("git") is None, reason="git binary is not available") + +_GIT_IDENTITY = [ + "-c", + "user.email=goga@example.com", + "-c", + "user.name=goga tests", +] + + +def _git(root: Path, *args: str) -> None: + """Run one git command in the throwaway repository. + + Args: + root: The repository root. + *args: The git arguments. + """ + subprocess.run(["git", *args], cwd=root, check=True, capture_output=True, text=True) + + +def _write(root: Path, relative: str) -> None: + """Create one artifact file of the throwaway history tree. + + Args: + root: The repository root. + relative: The file path relative to the root, directories included. + """ + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("integration\n", encoding="utf-8") + + +def _init_topic_repo(root: Path) -> None: + """Build the throwaway repository the board and switch scenarios share. + + A ``feat-a`` branch hosting the ``feat-a`` topic of 2025 with + ``prd.md`` and ``plan.md`` committed, a ``feat-b`` branch created from + it that adds the ``feat-b`` topic with ``prd.md`` (and so also carries + the shared ``feat-a`` history in its ref tree), the checkout back onto + ``feat-a``, and a remote-tracking twin of ``feat-a`` — the input of the + twin collapse. + + Args: + root: The empty directory the repository is built in. + """ + _git(root, "init", "-q", "-b", "feat-a") + _write(root, ".goga/history/2025/feat-a/prd.md") + _write(root, ".goga/history/2025/feat-a/plan.md") + _git(root, "add", ".goga") + _git(root, *_GIT_IDENTITY, "commit", "-qm", "topic feat-a") + _git(root, "update-ref", "refs/remotes/origin/feat-a", "HEAD") + _git(root, "switch", "-q", "-c", "feat-b") + _write(root, ".goga/history/2025/feat-b/prd.md") + _git(root, "add", ".goga") + _git(root, *_GIT_IDENTITY, "commit", "-qm", "topic feat-b") + _git(root, "switch", "-q", "feat-a") + + +def _board_rows(output: str) -> list[tuple[str, str, str]]: + """Parse the rendered board into its data rows. + + Args: + output: The captured stdout of ``goga topics status``. + + Returns: + The ``(topic cell, branch, statuses)`` tuples of the data rows — + the header and separator rows dropped, every cell stripped. + """ + lines = [line for line in output.splitlines() if line.startswith("|")] + rows = [] + for line in lines[2:]: + cells = line.split("|") + rows.append((cells[1].strip(), cells[2].strip(), cells[3].strip())) + return rows + + +@requires_git +class TestTopicsStatusBoard: + """``goga topics status --year Y`` over a real repository and scale.""" + + def test_board_renders_topics_statuses_and_current_marker( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The board table carries the topics, their artifact statuses, and + the current marker — exit 0.""" + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("COLUMNS", "120") + + result = CliRunner().invoke(topics, ["--year", "2025", "status"]) + + assert result.exit_code == 0 + rows = _board_rows(result.output) + # feat-b reads from its ref tree (defined, the shallowest artifact); + # the current feat-a row reads the working copy (planned outranks + # prd.md); feat-b also hosts the shared feat-a topic of the year. + assert rows == [ + ("feat-b", "feat-b", "[defined]"), + ("* feat-a", "feat-a", "[planned]"), + ("feat-a", "feat-b", "[planned]"), + ] + # The remote-tracking twin collapsed into the local row. + assert "origin/feat-a" not in result.output + + def test_board_empty_year_prints_nothing_and_exits_zero( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A year without topics renders nothing — an empty board is not an + error.""" + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("COLUMNS", "120") + + result = CliRunner().invoke(topics, ["--year", "2030", "status"]) + + assert result.exit_code == 0 + assert result.output == "" + + +class TestHistoryStatusToolFilter: + """``goga history status -s <tool>.<name>`` against the real assembly.""" + + def test_qualified_tool_status_validates_and_filters(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A registered tool status validates by its qualified name and + keeps exactly the topics carrying it.""" + scale = assemble_status_scale() + qualified = next((stage.name for stage in scale.stages if "." in stage.name), None) + if qualified is None: + # No installed tool package registers statuses — register a fake + # one. The command surface, the domain, and the assembly below + # stay the real ones; only the package enumeration is pinned. + package = ModuleType("goga_tool_fake") + + def register_topic_statuses(statuses: object) -> None: + statuses.register("published", "fake/published.md", after="planned") + + package.register_topic_statuses = register_topic_statuses + monkeypatch.setitem(sys.modules, "goga_tool_fake", package) + monkeypatch.setattr( + statuses_assembly, + "packages_distributions", + lambda: {"goga_tool_fake": ["goga-tool-fake"]}, + ) + qualified = "fake.published" + artifact = "fake/published.md" + else: + artifact = next(stage.filepath for stage in scale.stages if stage.name == qualified) + + # True registration: the qualified name assembles into the scale. + assert qualified in [stage.name for stage in assemble_status_scale().stages] + + (tmp_path / ".goga/history/2026/demo-topic").mkdir(parents=True) + _write(tmp_path, f".goga/history/2026/demo-topic/{artifact}") + _write(tmp_path, ".goga/history/2026/other-topic/prd.md") + monkeypatch.chdir(tmp_path) + + filtered = CliRunner().invoke(history, ["status", "2026", "-s", qualified]) + unfiltered = CliRunner().invoke(history, ["status", "2026"]) + + assert filtered.exit_code == 0 + assert filtered.output == f"demo-topic [{qualified}]\n" + assert "other-topic" not in filtered.output + assert "other-topic [defined]" in unfiltered.output + + +class TestPipelineTopicProcedureRegression: + """The removed ``-b/--branch`` and the ``-t/--topic`` that replaced it.""" + + def test_pipeline_help_carries_topic_and_not_branch(self) -> None: + """``--help`` shows ``-t/--topic``; no ``-b`` option remains.""" + result = CliRunner().invoke(app, ["pipeline", "--help"]) + + assert result.exit_code == 0 + assert "-t" in result.output + assert "--topic" in result.output + assert "-b" not in result.output + + def test_pipeline_branch_option_is_rejected(self) -> None: + """``goga pipeline -b x dev`` fails at parsing — unknown option.""" + result = CliRunner().invoke(app, ["pipeline", "-b", "x", "dev"]) + + assert result.exit_code != 0 + assert "No such option" in result.output + assert "-b" in result.output + + +@requires_git +class TestSwitchTopicIdempotentChain: + """The switch chain of the domain over the real git cell.""" + + def test_switch_on_current_host_is_idempotent_without_mutations( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Already hosting the work returns the idempotent line — no + cleanliness probe, no git mutation.""" + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + + with ( + mock.patch.object(topics_switching, "is_working_tree_clean") as clean_probe, + mock.patch.object(topics_switching, "checkout_local_branch") as checkout, + mock.patch.object(topics_switching, "create_branch_from_remote_tracking") as create_branch, + ): + clean_probe.return_value = True + line = switch_topic("feat-a", "2025") + + assert line == "Already on branch feat-a" + assert clean_probe.called is False + assert checkout.called is False + assert create_branch.called is False From 66420ce8800f709df19105b268aa3c1f4b075503 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 19:06:12 +0000 Subject: [PATCH 093/229] fix: address code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code: - board: compose the ref-tree prefix with as_posix() so git pathspec and ls-tree matching also works on Windows (native separators silently emptied the board) - creation: wrap the mkdir OSError of ensure_topic_dir into a clean ClickException — a stray file named like the slug previously escaped create_topic as a raw FileExistsError traceback after the branch was already created Tests: - scale: cover before-anchored, range, and transitive after-chain entries of maximal_present (previously unexercised edge directions) - render: cover two status segments joining on one line at width 80; drop a tautological boundary assertion - assembly: exercise the alphabetical package sort (packages now handed in reverse order) and the unknown before-anchor skip - creation/switching: cover missing git at the creation and mutation boundaries, click.Abort propagation at both prompts, and the stray-file clean error; ensure_topic_dir OSError propagation in paths - integration: real-git coverage for switch (checkout, remote-tracking branch creation, ambiguity without a terminal, dirty-tree refusal) and create (branch + directory, occupied-name clean error) Docs: - pipeline.md: replace the removed -b/--branch procedure with -t/--topic (resolution tiers, outcome lines, exit codes); fix the README example - new cli/topics.md and cli/history.md pages, index table rows, mkdocs nav; README topics section; document the register_topic_statuses tool hook in README and tools.md --- README.md | 27 +++- docs/cli/history.md | 94 +++++++++++++ docs/cli/index.md | 2 + docs/cli/pipeline.md | 38 +++--- docs/cli/topics.md | 88 ++++++++++++ docs/tools.md | 19 +++ docs/workflow/index.md | 2 +- goga/topics/board.py | 5 +- goga/topics/creation.py | 5 + goga/topics/git/.usages/refs-and-switching.md | 2 +- mkdocs.yml | 2 + tests/commands/topics/test_render.py | 27 +++- tests/history/statuses/test_assembly.py | 26 +++- tests/history/statuses/test_scale.py | 45 ++++++ tests/history/test_paths.py | 12 ++ tests/integration/test_topic_workflows.py | 128 +++++++++++++++++- tests/topics/test_creation.py | 56 ++++++++ tests/topics/test_switching.py | 53 ++++++++ 18 files changed, 600 insertions(+), 31 deletions(-) create mode 100644 docs/cli/history.md create mode 100644 docs/cli/topics.md diff --git a/README.md b/README.md index 87c0dbf3..9066dfac 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,22 @@ The slash-command form `/goga:<command>` works in agents that consume the goga c goga schema | goga tool viewer ``` +## Topics + +Work is organized as **topics** — one directory per piece of work under `.goga/history/<year>/<topic>/`, each usually living on its own git branch. The `goga topics` command group manages them: + +```bash +goga topics status # the board: every topic of the year across branches +goga topics status --remote # same board over remote-tracking refs +goga topics create feat/x # fresh work: the branch verbatim + its topic directory +goga topics switch feat-x # onto the branch hosting that work (branch, slug, or prefix) +goga topics --year 2025 status # the board of an explicit year +``` + +The board is a three-column table — topic, branch, statuses — with `*` marking the current branch and a local branch absorbing its remote twin. Each topic carries its **maximal statuses** in scale order: `empty → defined → discovered → backlog → designed → specified → planned → done`, deepening as `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. A topic can carry several statuses at once (`goga history status` prints them; `-s` filters by any of them). + +To resume work inside a pipeline, pass the identifier to the run — `goga pipeline development -t feat/x` switches to the hosting branch first (creating a local branch from its remote-tracking ref when needed) and is an idempotent no-op when you are already on it. Fresh work is started with `goga topics create`, not `-t`. + ## Pipelines A **pipeline** is a declarative scenario of stages an agent walks through to deliver a piece of work — propose, review, brainstorm, apply, design, plan, build, change, accept. A pipeline-file does not depend on any concrete agent: claude, codex, qwen, opencode, or any other installed wrapper can execute it. Stages with `communication: true` pause the run and ask for human input; without it they run autonomously. @@ -185,7 +201,7 @@ Pipelines are resolved from `<cwd>/.goga/pipelines/` (project) and `~/.goga/pipe ```bash goga pipeline development # run the development cycle (opens with brainstorm) -goga pipeline development -b feat/x # first create+switch to a fresh branch and history topic +goga pipeline development -t feat/x # first switch to the branch hosting this work, then run goga pipeline refinement -s discover # shorter run: skip technical discovery goga pipeline development -p 4 # cap parallelism (subject to the pipeline's dependency rules) goga pipeline development --clean # wipe persistent state for a fresh run @@ -397,6 +413,15 @@ A valid tool **must**: A tool **may** additionally expose an `install(user: str | None = None)` callable in its facade package: `goga install` calls it after a successful pip, passing the initiating user (`SUDO_USER` when goga itself runs under sudo, else the current OS user) only when the parameter is declared keyword-capable. A missing or non-callable `install` is skipped quietly. +A tool **may** also expose a `register_topic_statuses(statuses)` callable to extend the topic status scale with its own artifacts. goga imports every installed `goga_tool_*` package at each command start that computes statuses and calls the callable with a registry scoped to the package: + +```python +def register_topic_statuses(statuses): + statuses.register("published", "mkdocs/published.md", after="planned") +``` + +The name is shown qualified as `<tool>.<name>` (here `mkdocs.published`), the filepath is relative to the topic directory (nested paths allowed), and `before=`/`after=` anchor the entry to an existing scale entry (at least one anchor is required; both define a range). Built-in entries are immutable. A bad registration — an unknown anchor, an invalid range, or a crashed callback — is skipped with a warning on stderr and never aborts the command; only a package that fails to import is fatal. + After publication, install into any project: ```bash diff --git a/docs/cli/history.md b/docs/cli/history.md new file mode 100644 index 00000000..91cce42d --- /dev/null +++ b/docs/cli/history.md @@ -0,0 +1,94 @@ +# goga history + +Work with the `.goga/history/` tree — its per-year topics, their statuses, and their paths. + +`goga history` is a Click group with four subcommands (`list`, `status`, `path`, `ensure`) over the history domain. Everything is host-side and read-only except `ensure`; domain errors surface as clean one-line errors (exit 1, no traceback). + +## Synopsis + +```bash +goga history list +goga history status [YEAR] [-t TOPIC] [-s STATUS]... +goga history path [TOPIC] [-f FILENAME] [-y YEAR] +goga history ensure [NAME] +``` + +## `goga history list` + +The inventory view: one `YYYY/` line per year, each topic indented under its year. + +``` +2025/ + └── release-1-3-0 +2026/ + └── feat-x + └── history-commands +``` + +An empty tree prints nothing. Read-only — statuses and artifact names never appear. + +## `goga history status` + +Prints the topics of one year, one `topic [status] [status] …` line each: + +``` +feat-x [defined] [planned] +release-1-3-0 [done] [mkdocs.published] +``` + +A topic carries its **maximal present statuses** in scale order — one bracketed segment per status: + +| Status | Artifact | | +|---|---|---| +| `empty` | — | no artifact yet | +| `defined` | `prd.md` | | +| `discovered` | `adr.md` | | +| `backlog` | `task.md` | | +| `designed` | `arch.md` | | +| `specified` | `design.md` | | +| `planned` | `plan.md` | | +| `done` | `completed/plan.md` | | + +A topic can carry several statuses at once: every artifact present that is outranked by no other present artifact stays visible (tool statuses included, shown qualified such as `mkdocs.published` — see [Tools](../tools.md) for how a tool package registers its own statuses). YEAR defaults to the current year and is never printed; topics come out alphabetically. + +The status segments print colored (`cyan`) unless `NO_COLOR` is set in the environment. + +### Filters + +- `-t`/`--topic` keeps the topics whose normalized slug contains the normalized filter as a substring. +- `-s`/`--status` is repeatable and keeps the topics carrying **at least one** of the requested statuses — any-of matching. Both built-in names (`-s planned`) and qualified tool names (`-s mkdocs.published`) are valid; an unknown name is a clean error before anything prints. +- Both filters combine by AND. An empty result prints nothing and exits 0 — it is not an error. + +```bash +goga history status # the current year, every topic +goga history status 2025 # one explicit year +goga history status -s planned # every topic carrying [planned] +goga history status -t release # every topic whose slug contains "release" +``` + +## `goga history path` + +Prints exactly one path of the history tree — and nothing else — for scripting: + +```bash +plan=$(goga history path -f plan.md) +``` + +TOPIC defaults to the current git branch (taken raw, as a branch name or a slug — the two compose identically through the slug grammar). With `-f`/`--file` the artifact file path prints (the filename is taken verbatim and must carry an extension); otherwise the topic directory. `-y`/`--year` selects the year (default: the current one). Nothing is created on disk. + +## `goga history ensure` + +Creates the topic directory of the current year, idempotently: parents are created as needed and an existing directory is a success, not a conflict. NAME defaults to the current git branch. Prints nothing on stdout — the exit code carries the result. Only directories: no artifact file is created, and occupancy is not reported. + +## Exit Codes + +| Code | Meaning | +|------|---------| +| `0` | Success — the tree, statuses, or path printed, or the directory ensured | +| `1` | A clean domain error: an unknown `-s` status name, an empty topic filter or slug, an undeterminable current branch where a topic default is needed, or a broken `goga_tool_*` package failing to import during status-scale assembly | +| `2` | A usage error (unknown option, too many arguments) | + +## Notes + +- The topic slug grammar: lowercase, non-ASCII dropped, anything outside `[a-z0-9]` becomes `-`, repeat hyphens collapsed, edge hyphens trimmed (`Feature/Foo_Bar` → `feature-foo-bar`, `release/1.3.0` → `release-1-3-0`). +- `goga topics status` shows the same statuses across branches; `goga history status` shows the working copy of one year (see [topics](topics.md)). diff --git a/docs/cli/index.md b/docs/cli/index.md index 4f365149..3fdbe5a5 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -36,6 +36,8 @@ python -m goga --help | [`goga upgrade`](upgrade.md) | Upgrade goga and re-sync connected agents | | [`goga usages`](usages.md) | Sync cell-level usages from declared git dependencies and check their status against the remote | | [`goga pipeline`](pipeline.md) | Run a goga pipeline, or inspect the available ones (`--list`, `--info`) | +| [`goga history`](history.md) | Work with the `.goga/history/` tree (`list`, `status`, `path`, `ensure`) | +| [`goga topics`](topics.md) | Work with the topics of one year (`status` board, `create`, `switch`) | | [`goga tool`](tool.md) | Dynamic tool package invocation | ## Global Options diff --git a/docs/cli/pipeline.md b/docs/cli/pipeline.md index 3e6da02d..9ebf6bee 100644 --- a/docs/cli/pipeline.md +++ b/docs/cli/pipeline.md @@ -11,7 +11,7 @@ goga pipeline --list # flat list: available pipeline names (in-cont goga pipeline --list --info # overview: one bullet block per pipeline with its description goga pipeline <name> --info # card: name, description, stages in execution order goga pipeline <name> # run: execute the pipeline (in-container) -goga pipeline <name> -b <branch> # run: first create+switch to a fresh branch (host-side) +goga pipeline <name> -t <topic> # run: first switch onto the branch hosting the work (host-side) ``` ## Forms @@ -59,7 +59,7 @@ The card and the run share the same workflow rule set and the same compiler, so ## Run Mode (`goga pipeline <name>`) -Run a pipeline by name. Pass the bare name only (no `.yml` extension); the container resolves the absolute path internally, compiles the goga DSL pipeline-file into an afm flow-file at `<AFM_DIR>/flow.yml`, materializes the four agent prompt files into `<AFM_DIR>/prompts/` (applying any `roles` overrides from the pipeline-file header — see [Custom agent prompts](#custom-agent-prompts)), and runs that via `afm run`. Passing `-p/--parallel N` caps the number of stages afm executes concurrently (it threads through to `afm run --max-parallel <N>`); without it afm runs unbounded. A free port is allocated automatically and published on both sides (`-p <port>:<port>`); `afm` listens on that port inside the container. When a workflow is applied, a single log line naming it is printed to stdout; when `-b/--branch` prepared a branch, a single `Pipeline running on branch <name>` line is printed before the launch; otherwise the launcher prints no status line. +Run a pipeline by name. Pass the bare name only (no `.yml` extension); the container resolves the absolute path internally, compiles the goga DSL pipeline-file into an afm flow-file at `<AFM_DIR>/flow.yml`, materializes the four agent prompt files into `<AFM_DIR>/prompts/` (applying any `roles` overrides from the pipeline-file header — see [Custom agent prompts](#custom-agent-prompts)), and runs that via `afm run`. Passing `-p/--parallel N` caps the number of stages afm executes concurrently (it threads through to `afm run --max-parallel <N>`); without it afm runs unbounded. A free port is allocated automatically and published on both sides (`-p <port>:<port>`); `afm` listens on that port inside the container. When a workflow is applied, a single log line naming it is printed to stdout; when `-t/--topic` switched the repository onto the hosting branch, the single result line of the switch (`Switched to branch <name>`, `Created branch <name> from <remote>/<name>`, or `Already on branch <name>`) is echoed once before the launch; otherwise the launcher prints no status line. Pipelines are flat `*.yml` files (one per pipeline) resolved from two directories, with the project source winning on name conflicts: @@ -88,27 +88,31 @@ Pipeline running with workflow "feature-phases" If the name exists in both sources, the project source wins. The container exit code is propagated as the command's exit code. -### Branch preparation +### Topic switch -The run form can first prepare a fresh git branch and a fresh history topic on the host, before any docker activity: +The run form can first bring the repository onto the branch hosting the requested work — continuing existing work instead of creating fresh work (fresh work belongs to [`goga topics create`](topics.md)): ```bash -goga pipeline development -b feat/x +goga pipeline development -t feat/x ``` -The entered name plays two roles: +The identifier resolves through three tiers, and the first tier with a match wins (so a non-interactive `-t` never reaches a prompt): -- **branch name** — used exactly as entered when creating and switching (`git switch -c`; git rejects invalid names itself); -- **history topic slug** — the normalized form that names the topic folder `.goga/history/<YYYY>/<slug>/`: lowercase, non-ASCII dropped, anything outside `[a-z0-9]` becomes `-`, repeat hyphens collapse, edge hyphens trim (`Feature/Foo_Bar` → `feature-foo-bar`, `release/1.3.0` → `release-1-3-0`). +1. **exact branch name** — a branch whose display name equals the input; +2. **exact topic slug** — a branch hosting the topic `.goga/history/<YYYY>/<slug>/` whose slug equals the normalized input (lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed: `Feature/Foo_Bar` → `feature-foo-bar`); local branches come before remote-tracking refs; +3. **prefix** — a branch whose name, or whose hosted slug, starts with the input. -Occupancy is checked against three oracles in order: a local branch with the entered name, a remote-tracking branch with the entered name (local refs only — no network), and an existing `.goga/history/<YYYY>/<slug>/` folder for the current year. On a conflict — or a name that normalizes to an empty slug (a fully non-ASCII name): +Within a tier, several candidates may match (a branch chain carries several topics). On an interactive terminal goga prints the numbered list with each candidate's statuses and prompts for a number; with no terminal (CI/scripts) the numbered list itself becomes a clean error and the command exits 1 — no image refresh, build, or launch happens. No candidate at all exits 1 with a hint to `goga topics status`. -- **interactive terminal**: the reason is printed and a new name is prompted until the name is free (Ctrl-C aborts, nothing is created); -- **no terminal** (CI/scripts): the reason plus the hint `Pass another branch name via -b.` goes to stderr and the command exits 1 — no image refresh, build, or launch happens. +The outcome: -When the procedure completes (a created-and-switched branch, or the already-on-branch case where the current branch's slug equals the entered slug — nothing is touched), goga prints `Pipeline running on branch <name>` to stdout once, before the launch. The branch name is never forwarded into the container — the container sees the branch through the mounted project, and goga does not switch back after the launch. +- already on the hosting branch → idempotent success, nothing is touched and the working tree is not even probed; +- a local host → `git switch <branch>`; +- a remote-only host → the local branch is created from the remote-tracking ref (`git switch -c <branch> <remote>/<branch>`). -The flat list, overview, and card forms silently ignore `-b` — passing it there is not an error and has no effect. +A switch that would mutate checks the working tree first: a dirty tree exits 1 with `working tree is dirty — commit or stash before switching` before anything is touched. Every git action happens on the host, after every form check and before any docker activity. The single result line (`Switched to branch <name>`, `Created branch <name> from <remote>/<name>`, or `Already on branch <name>`) is echoed to stdout once, before the launch. The branch name is never forwarded into the container — the container sees the branch through the mounted project, and goga does not switch back after the launch. + +The flat list, overview, and card forms silently ignore `-t` — passing it there is not an error and has no effect. ## Prerequisites @@ -182,7 +186,7 @@ stages: | `name` (positional) | string | — | Pipeline name without extension. Selects the card (`--info`) or run form; omit it and pass `--list` for the listing forms. `--list` and a name together are rejected (exit 1) | | `-l`, `--list` | flag | off | List available pipelines (flat list). Add `--info` for a one-line description per pipeline | | `-i`, `--info` | flag | off | With `--list`: print the overview. With `NAME`: print the pipeline card instead of running it | -| `-b`, `--branch` | string | — | Create and switch to a fresh branch (and a fresh `.goga/history/<YYYY>/<slug>/` history topic) before the run; see [Branch preparation](#branch-preparation). Run form only — the list/info forms silently ignore it | +| `-t`, `--topic` | string | — | Bring the repository onto the branch hosting the requested work before the run — a branch name, a topic slug, or their prefix; see [Topic switch](#topic-switch). Run form only — the list/info forms silently ignore it | | `-e`, `--env` | string (repeatable) | — | Additional environment variable (`KEY=VALUE`) forwarded into the container env-file. Run form only | | `--proxy` | string | config | HTTP/HTTPS proxy URL; overrides `pipeline.proxy`. Adds `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` to the container env-file. Run form only | | `--add-host` | string (repeatable) | -- | Add a `docker run --add-host HOST:IP` entry; merges on top of `pipeline.hosts` (CLI wins on key conflict). Run form only — the info forms receive the configured `pipeline.hosts` only | @@ -258,10 +262,10 @@ Wipe persistent afm state for this pipeline/branch before launch: goga pipeline deploy --clean ``` -Start the run on a fresh branch and history topic: +Start the run on the branch hosting an existing piece of work: ```bash -goga pipeline development -b feat/x +goga pipeline development -t feat/x ``` ## Exit Codes @@ -271,7 +275,7 @@ Host side (all forms): | Code | Meaning | |------|---------| | `0` | The operation completed (container exit 0) | -| `1` | A `ClickException`: a form error (bare invocation, `--list` + name, `--workflow` + `--no-workflow`), the `pipeline` section missing in `.goga/config.yml`, an explicit `--workflow <name>` naming a file that does not exist or escaping the workflows dir, a branch-procedure failure (an empty topic slug or an unresolved occupancy conflict without a terminal, a failed `git switch -c` or ref listing, or a missing git binary — see [Branch preparation](#branch-preparation)), or a fatal image build/refresh. Or the pre-launch version check refusing the launch (a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version — a stderr message plus `SystemExit`, see [Pre-launch version check](#pre-launch-version-check)) | +| `1` | A `ClickException`: a form error (bare invocation, `--list` + name, `--workflow` + `--no-workflow`), the `pipeline` section missing in `.goga/config.yml`, an explicit `--workflow <name>` naming a file that does not exist or escaping the workflows dir, a topic-switch failure (no branch hosting the identifier, several candidates without a terminal, a dirty working tree, a failed `git switch` or ref listing, or a missing git binary — see [Topic switch](#topic-switch)), or a fatal image build/refresh. Or the pre-launch version check refusing the launch (a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version — a stderr message plus `SystemExit`, see [Pre-launch version check](#pre-launch-version-check)) | | other| The container's exit code, propagated unchanged (including the run-mode codes below) | Container side, run form: diff --git a/docs/cli/topics.md b/docs/cli/topics.md new file mode 100644 index 00000000..659badbc --- /dev/null +++ b/docs/cli/topics.md @@ -0,0 +1,88 @@ +# goga topics + +Work with the topics of one year — the cross-branch inventory, fresh-work creation, and switching. + +`goga topics` is a Click group with three subcommands (`status`, `create`, `switch`) over the topics domain. It is host-side and git-driven: the board reads branch trees without checkout, creation and switching perform bounded local git mutations, and no network access ever happens (no fetch, no push). + +## Synopsis + +```bash +goga topics [--year YYYY] status [--remote] +goga topics [--year YYYY] create BRANCH_NAME +goga topics [--year YYYY] switch IDENTIFIER +``` + +`--year`/`-y` scopes every subcommand to one four-digit year (default: the current year). The year is never printed. + +## `goga topics status` + +Prints the board — the cross-branch topic inventory of the scoped year — as a three-column table: topic, branch, statuses. + +``` +| Topic | Branch | Statuses | +|----------------|----------|-------------------| +| feat-b | feat-b | [defined] | +| * feat-a | feat-a | [planned] | +| feat-a | feat-b | [planned] | +``` + +- One row per topic hosted by a branch; `*` marks the row hosting the current branch. +- The current branch's row reads the working copy — uncommitted progress is visible; every other row reads the branch's committed tree (no checkout happens). +- A local branch and its remote twin collapse to one row — the local branch wins; a topic hosted only by a remote-tracking ref keeps its row with the remote name in the branch column. +- Rows sort by scale order of the first maximal status, then alphabetically by topic. +- `--remote`/`-r` reads remote-tracking refs instead of local branches; the current branch shows through its remote twin. +- The statuses column wraps onto continuation lines when the segments overflow the terminal width; the table never exceeds the width except on terminals below 33 columns, where every column keeps a minimum of 8. +- An empty board prints nothing and exits 0 — a year without topics is not an error. + +The statuses are the topic's **maximal present statuses** in scale order — `empty, defined, discovered, backlog, designed, specified, planned, done`, deepening as `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. Tool packages can add their own statuses, shown qualified (`mkdocs.published`); see [Tools](../tools.md). + +## `goga topics create` + +Creates fresh work — a branch named exactly as entered, plus the topic directory of the scoped year: + +```bash +goga topics create Feature/Foo_Bar +# Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar +``` + +- The branch name is taken verbatim (`git switch -c`); git itself rejects invalid names. +- The topic directory is `.goga/history/<YYYY>/<slug>/`, where the slug is the normalized name (lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed: `Feature/Foo_Bar` → `feature-foo-bar`). No artifact file is written. +- The current branch already hosting the same slug is an idempotent success — `Branch <name> already hosts topic <YYYY>/<slug>` — with nothing touched. +- Occupancy is probed against three oracles in order: a local branch with the entered name, a remote-tracking branch with the entered name (local refs only — no network), and an existing `.goga/history/<YYYY>/<slug>/` directory (only a directory occupies a topic). +- An occupied name or a name that normalizes to an empty slug (a fully non-ASCII name) prints the reason and prompts for a new name on an interactive terminal, restarting with it; with no terminal it exits 1 with the reason (and a hint to `goga topics status` for occupied names). Ctrl-C at the prompt aborts with nothing created. + +## `goga topics switch` + +Brings the repository onto the branch hosting the requested work: + +```bash +goga topics switch feat-x +# Switched to branch feat/x +``` + +IDENTIFIER resolves through three tiers — the first tier with a match wins, so a unique identifier never reaches a prompt: + +1. exact branch name; +2. exact topic slug (local branches before remote-tracking refs); +3. prefix — a branch whose name, or whose hosted slug, starts with the input. + +- Several candidates on an interactive terminal: the numbered list with each candidate's statuses is printed and a number is prompted; with no terminal, the numbered list itself is the error (exit 1). +- No candidate at all: exit 1 with a hint to run `goga topics status`. +- Already on the hosting branch: idempotent success — `Already on branch <name>` — with no working-tree probe and no mutation. +- A local host is checked out (`git switch <branch>`); a remote-only host creates the local branch from the remote-tracking ref (`git switch -c <branch> <remote>/<branch>`, reported as `Created branch <branch> from <remote>/<branch>`). +- A switch that would mutate first probes the working tree; a dirty tree exits 1 with `working tree is dirty — commit or stash before switching` before anything is touched. + +The same resolution backs `goga pipeline <name> -t <identifier>` (see [pipeline](pipeline.md#topic-switch)). + +## Exit Codes + +| Code | Meaning | +|------|---------| +| `0` | Success — the board printed, the work created, or the switch performed (including the idempotent outcomes) | +| `1` | A clean domain error: an unresolvable or ambiguous identifier, an occupied name without a terminal, a dirty working tree, a git infrastructure failure, or a broken `goga_tool_*` package failing to import during status-scale assembly | +| `2` | A usage error (unknown option, missing argument) | + +## Notes + +- Every mutation is local — no fetch, no push, no network. +- `goga history status` shows the same statuses scoped to the working copy of one year (see [history](history.md)). diff --git a/docs/tools.md b/docs/tools.md index 5fbf1336..ada12ba2 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -103,6 +103,25 @@ declared keyword-capable; otherwise the hook is called with no arguments. A missing or non-callable `install` is skipped quietly. See [`goga install` — Post-install hooks](cli/install.md#post-install-hooks). +A tool **may** also expose a `register_topic_statuses(statuses)` callable — +the topic-status hook. At every command start that computes topic statuses, +goga imports each installed `goga_tool_*` package and calls the callable +with a registry scoped to the package: + +```python +def register_topic_statuses(statuses): + statuses.register("published", "mkdocs/published.md", after="planned") +``` + +The entry's name is shown qualified as `<tool>.<name>` (here +`mkdocs.published`), its `filepath` is the artifact path relative to the +topic directory (nested paths allowed), and `before=`/`after=` anchor it to +an existing scale entry — at least one anchor is required, both define a +range. Built-in entries are immutable. A bad registration — an unknown +anchor, an invalid range, or a crashed callback — is skipped with a warning +on stderr and never aborts the command; only a package that fails to import +is fatal. See [Topics](cli/topics.md) for the status scale itself. + A `pipelines/` directory is **optional**. When present, `goga connect` copies its flat `*.yml` files into `~/.goga/pipelines/` **namespaced as `<tool>:<name>.yml`** (where `<tool>` is the package name with the diff --git a/docs/workflow/index.md b/docs/workflow/index.md index 01188c64..4af92e57 100644 --- a/docs/workflow/index.md +++ b/docs/workflow/index.md @@ -102,7 +102,7 @@ define → discover → propose → review(task) | [`change`](change.md) | Development | Change description | Modified code + reconciled contracts and usages | | [`accept`](accept.md) | Development | Completed implementation | Final acceptance report | -Workflow artifacts live at `.goga/history/<year>/<topic>/<kind>.md` (`<kind>` ∈ `prd | adr | task | arch | design | plan`): `<year>` is the current year as `YYYY`, and `<topic>` is a lowercase kebab-case slug — non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed (branch `release/1.3.0` → `release-1-3-0`). The topic directory is created lazily by the stage that first writes into it, and the whole `.goga/history/` tree is git-ignored by default. `goga pipeline <name> -b <branch>` prepares both a fresh branch and its fresh topic before a run. +Workflow artifacts live at `.goga/history/<year>/<topic>/<kind>.md` (`<kind>` ∈ `prd | adr | task | arch | design | plan`): `<year>` is the current year as `YYYY`, and `<topic>` is a lowercase kebab-case slug — non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed (branch `release/1.3.0` → `release-1-3-0`). The topic directory is created lazily by the stage that first writes into it, and the whole `.goga/history/` tree is git-ignored by default. `goga pipeline <name> -t <topic>` switches onto the branch hosting an existing topic before a run; `goga topics create <branch>` prepares both a fresh branch and its fresh topic. ## Next steps diff --git a/goga/topics/board.py b/goga/topics/board.py index 5a3dd762..faa782aa 100644 --- a/goga/topics/board.py +++ b/goga/topics/board.py @@ -183,7 +183,10 @@ def _year_topics_by_ref(refs: list[BranchRef], year: str) -> dict[str, dict[str, ...]}`` with the artifact paths relative to the topic directory, ready for ``StatusScale.maximal_present``. """ - prefix = f"{resolve_history_root()}/" + # ``as_posix`` — git pathspecs and ``ls-tree`` output are always + # forward-slashed, on Windows too; a native-separator path would match + # nothing and silently empty the board. + prefix = f"{resolve_history_root().as_posix()}/" return {ref.name: _year_topics(read_ref_tree_paths(ref.name, prefix), year) for ref in refs} diff --git a/goga/topics/creation.py b/goga/topics/creation.py index fcb0da65..549e2519 100644 --- a/goga/topics/creation.py +++ b/goga/topics/creation.py @@ -132,6 +132,11 @@ def create_topic(branch_name: str, year: str | None = None) -> str: raise click.ClickException(f"git failed: {detail}") from exc except FileNotFoundError as exc: raise click.ClickException(f"git is not available: {exc}") from exc + except OSError as exc: + # ``ensure_topic_dir`` propagates the mkdir failures — a stray file + # named like the slug occupies no topic for the oracle, so the + # failure can only surface here, after the branch was created. + raise click.ClickException(f"cannot create topic directory: {exc}") from exc def _occupancy_conflict( diff --git a/goga/topics/git/.usages/refs-and-switching.md b/goga/topics/git/.usages/refs-and-switching.md index ee53c19d..febce919 100644 --- a/goga/topics/git/.usages/refs-and-switching.md +++ b/goga/topics/git/.usages/refs-and-switching.md @@ -28,7 +28,7 @@ for ref in refs: from goga.history import resolve_history_root from goga.topics.git import read_ref_tree_paths -prefix = f"{resolve_history_root()}/" +prefix = f"{resolve_history_root().as_posix()}/" paths = read_ref_tree_paths("feature-foo", prefix) ``` diff --git a/mkdocs.yml b/mkdocs.yml index 961d7ba6..17a4d6bf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -79,6 +79,8 @@ nav: - Upgrade: cli/upgrade.md - Usages: cli/usages.md - Pipeline: cli/pipeline.md + - History: cli/history.md + - Topics: cli/topics.md - Tool: cli/tool.md - Architecture: - architecture/index.md diff --git a/tests/commands/topics/test_render.py b/tests/commands/topics/test_render.py index cdffa5a9..c571fcaf 100644 --- a/tests/commands/topics/test_render.py +++ b/tests/commands/topics/test_render.py @@ -120,9 +120,9 @@ def test_render_topic_board_degenerate_narrow_terminal(self, capsys: pytest.Capt assert "feat/x" in lines[2] assert "[done]" in lines[2] - @pytest.mark.parametrize(("width", "degenerate"), [(33, False), (32, True)]) + @pytest.mark.parametrize("width", [33, 32]) def test_render_topic_board_boundary_width_33_32( - self, capsys: pytest.CaptureFixture[str], width: int, degenerate: bool + self, capsys: pytest.CaptureFixture[str], width: int ) -> None: """Width 33 splits evenly into the minimum thirds; 32 stays at them anyway.""" records = [ @@ -139,7 +139,22 @@ def test_render_topic_board_boundary_width_33_32( assert "feat-a" in lines[2] assert "[done]" in lines[2] assert "…" in lines[3] - if degenerate: - assert width < 33 - else: - assert width == 33 + + def test_render_topic_board_two_segments_fit_one_line(self, capsys: pytest.CaptureFixture[str]) -> None: + """Width 80 — two short status segments join on one statuses line.""" + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["defined", "planned"], + current=False, + remote=False, + ) + ] + render_topic_board(records, 80) + lines = capsys.readouterr().out.splitlines() + # usable = 71, so topic_cap = branch_cap = 23 and statuses_w = 25; + # "[defined] [planned]" is 19 columns and fits — one data row only. + assert len(lines) == 3 + assert "[defined] [planned]" in lines[2] + assert all(len(line) <= 80 for line in lines) diff --git a/tests/history/statuses/test_assembly.py b/tests/history/statuses/test_assembly.py index a017e86f..ae9c231f 100644 --- a/tests/history/statuses/test_assembly.py +++ b/tests/history/statuses/test_assembly.py @@ -217,11 +217,33 @@ def test_assemble_unresolvable_anchor_skips( assert "goga_tool_a" in stderr assert _names(scale) == _BUILTIN_NAMES + def test_assemble_unresolvable_before_anchor_skips( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """A ``before`` anchor naming no entry of the scale skips the registration.""" + _install_package( + monkeypatch, + "goga_tool_a", + _registering({"name": "x", "filepath": "a/x.md", "before": "nonexistent.status"}), + ) + _packages(monkeypatch, "goga_tool_a") + + scale = assemble_status_scale() + + assert "a.x" not in _names(scale) + stderr = capsys.readouterr().err + assert "Warning" in stderr + assert "goga_tool_a" in stderr + assert _names(scale) == _BUILTIN_NAMES + def test_assemble_same_anchor_block_keeps_registration_order(self, monkeypatch: pytest.MonkeyPatch) -> None: """Two packages anchoring after the same entry form a block in package order. The design-review q1 regression: a bare ``insert(pos(A) + 1)`` would - reverse the block — the alphabetical package order must win. + reverse the block — the alphabetical package order must win. The + enumeration map is handed to the packages in reverse order so the + assertion depends on the alphabetical sort, not on dict insertion + order. """ _install_package( monkeypatch, "goga_tool_a", _registering({"name": "x", "filepath": "a/x.md", "after": "planned"}) @@ -229,7 +251,7 @@ def test_assemble_same_anchor_block_keeps_registration_order(self, monkeypatch: _install_package( monkeypatch, "goga_tool_b", _registering({"name": "y", "filepath": "b/y.md", "after": "planned"}) ) - _packages(monkeypatch, "goga_tool_a", "goga_tool_b") + _packages(monkeypatch, "goga_tool_b", "goga_tool_a") scale = assemble_status_scale() diff --git a/tests/history/statuses/test_scale.py b/tests/history/statuses/test_scale.py index 869d4e9c..f80ed692 100644 --- a/tests/history/statuses/test_scale.py +++ b/tests/history/statuses/test_scale.py @@ -125,6 +125,51 @@ def test_maximal_present_two_incomparable_tool_statuses(self, builtin_scale: Sta assert scale.maximal_present(paths) == ["mkdocs.published", "scriba.translated"] + def test_maximal_present_before_anchored_entry_stays_below_anchor(self, builtin_scale: StatusScale) -> None: + """A ``before``-anchored tool entry is strictly below its anchor.""" + scale = StatusScale( + stages=[ + *builtin_scale.stages[:7], # empty .. planned + Stage(name="tool.review", filepath="review.md", before="done"), + builtin_scale.stages[7], # done + ], + ) + + # Both artifacts present — the anchor outranks the tool entry. + assert scale.maximal_present(["review.md", "completed/plan.md"]) == ["done"] + # The tool artifact alone — the tool entry is maximal, and so is the + # axis entry it does not relate to. + assert scale.maximal_present(["plan.md", "review.md"]) == ["planned", "tool.review"] + + def test_maximal_present_range_entry_between_its_anchors(self, builtin_scale: StatusScale) -> None: + """A both-anchored range entry outranks its ``after`` and yields to its ``before``.""" + scale = StatusScale( + stages=[ + *builtin_scale.stages[:7], # empty .. planned + Stage(name="tool.range", filepath="range.md", after="defined", before="done"), + builtin_scale.stages[7], # done + ], + ) + + assert scale.maximal_present(["prd.md", "range.md"]) == ["tool.range"] + assert scale.maximal_present(["range.md", "completed/plan.md"]) == ["done"] + # The range entry and an unrelated axis entry are both maximal. + assert scale.maximal_present(["plan.md", "range.md"]) == ["planned", "tool.range"] + + def test_maximal_present_after_chain_is_transitive(self, builtin_scale: StatusScale) -> None: + """A chain of ``after`` anchors outranks transitively — the deepest wins.""" + scale = StatusScale( + stages=[ + *builtin_scale.stages[:7], # empty .. planned + Stage(name="tool.first", filepath="first.md", after="planned"), + Stage(name="tool.second", filepath="second.md", after="tool.first"), + builtin_scale.stages[7], # done + ], + ) + + assert scale.maximal_present(["plan.md", "first.md"]) == ["tool.first"] + assert scale.maximal_present(["first.md", "second.md"]) == ["tool.second"] + def test_maximal_present_dedupes_repeated_paths(self, builtin_scale: StatusScale) -> None: """A repeated path marks its entry once.""" assert builtin_scale.maximal_present(["plan.md", "plan.md"]) == ["planned"] diff --git a/tests/history/test_paths.py b/tests/history/test_paths.py index b3ea6708..b85c8637 100644 --- a/tests/history/test_paths.py +++ b/tests/history/test_paths.py @@ -287,3 +287,15 @@ def test_ensure_topic_dir_empty_slug_raises( with pytest.raises(ValueError, match="normalizes to an empty topic slug"): ensure_topic_dir("Релиз/Один") assert not (tmp_path / ".goga").exists() + + def test_ensure_topic_dir_stray_file_propagates_oserror( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A stray file at the topic path is an ``OSError`` from mkdir, propagated.""" + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + year_dir.mkdir(parents=True) + (year_dir / "feat-x").write_text("not a topic", encoding="utf-8") + + with pytest.raises(OSError, match="feat-x"): + ensure_topic_dir("feat-x", year="2026") diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index 2c0bd532..01980553 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -19,7 +19,9 @@ -> goga.commands.history.render_topic_statuses goga pipeline -t/--topic — the removed -b/--branch regression, and the - idempotent switch chain of ``switch_topic`` through the real git cell. + mutating chains of ``switch_topic``/``create_topic`` through the real git + cell: checkout, remote-tracking branch creation, and branch-plus-directory + creation. Git is real: the git-dependent scenarios run in a throwaway repository under ``tmp_path`` (``git init`` plus commits, with ``git update-ref`` @@ -41,6 +43,7 @@ from types import ModuleType from unittest import mock +import click import pytest from click.testing import CliRunner from goga.cli import app @@ -48,7 +51,7 @@ from goga.commands.topics import topics from goga.history import assemble_status_scale from goga.history.statuses import assembly as statuses_assembly -from goga.topics import switch_topic +from goga.topics import create_topic, switch_topic from goga.topics import switching as topics_switching # The scenarios drive real git — skip them where no git binary exists. @@ -84,6 +87,38 @@ def _write(root: Path, relative: str) -> None: path.write_text("integration\n", encoding="utf-8") +def _current_branch(root: Path) -> str: + """Read the checked-out branch of the throwaway repository. + + Args: + root: The repository root. + + Returns: + The current branch name as git reports it. + """ + result = subprocess.run( + ["git", "branch", "--show-current"], cwd=root, check=True, capture_output=True, text=True + ) + return result.stdout.strip() + + +def _add_solo_branch(root: Path) -> None: + """Add a branch hosting exactly one topic of its own. + + An orphan branch — its tree carries no other topic of the year, so it + resolves unambiguously by branch name and by slug alike, unlike the + chained ``feat-b`` which also carries the shared ``feat-a`` history. + + Args: + root: The repository root of an already initialized topic repo. + """ + _git(root, "switch", "-q", "--orphan", "solo") + _write(root, ".goga/history/2025/solo/prd.md") + _git(root, "add", ".goga") + _git(root, *_GIT_IDENTITY, "commit", "-qm", "topic solo") + _git(root, "switch", "-q", "feat-a") + + def _init_topic_repo(root: Path) -> None: """Build the throwaway repository the board and switch scenarios share. @@ -262,3 +297,92 @@ def test_switch_on_current_host_is_idempotent_without_mutations( assert clean_probe.called is False assert checkout.called is False assert create_branch.called is False + + def test_switch_by_slug_checks_out_local_host( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A slug identifier resolves to its single local host and checks it out for real.""" + _init_topic_repo(tmp_path) + _add_solo_branch(tmp_path) + monkeypatch.chdir(tmp_path) + + line = switch_topic("solo", "2025") + + assert line == "Switched to branch solo" + assert _current_branch(tmp_path) == "solo" + + def test_switch_ambiguous_slug_lists_candidates_without_terminal( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A branch hosting several topics of the year is ambiguous — the numbered + list is the non-interactive error, and nothing is mutated.""" + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + + with pytest.raises(click.ClickException, match=r"1\) feat-b"): + switch_topic("feat-b", "2025") + + assert _current_branch(tmp_path) == "feat-a" + + def test_switch_by_slug_creates_branch_from_remote_tracking( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A slug hosted only by a remote-tracking ref creates the local branch from it.""" + _init_topic_repo(tmp_path) + _git(tmp_path, "switch", "-q", "feat-b") + _git(tmp_path, "branch", "-D", "feat-a") + monkeypatch.chdir(tmp_path) + + line = switch_topic("feat-a", "2025") + + assert line == "Created branch feat-a from origin/feat-a" + assert _current_branch(tmp_path) == "feat-a" + + def test_switch_refuses_dirty_working_tree( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A dirty working tree is a clean error — the repository stays put.""" + _init_topic_repo(tmp_path) + _add_solo_branch(tmp_path) + (tmp_path / "uncommitted.txt").write_text("dirty\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + with pytest.raises(click.ClickException, match="dirty"): + switch_topic("solo", "2025") + + assert _current_branch(tmp_path) == "feat-a" + + +@requires_git +class TestCreateTopicRealGit: + """``goga topics create`` over the real git cell and path routines.""" + + def test_create_topic_creates_branch_and_topic_directory( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A free name creates the branch verbatim and the topic directory of the year.""" + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + + line = create_topic("Feature/Foo_Bar", year="2025") + + assert line == "Created branch Feature/Foo_Bar and topic 2025/feature-foo-bar" + assert _current_branch(tmp_path) == "Feature/Foo_Bar" + assert (tmp_path / ".goga" / "history" / "2025" / "feature-foo-bar").is_dir() + + def test_create_topic_occupied_local_branch_reasks_non_interactively( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An existing branch name is a clean occupancy error — nothing is created.""" + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + sys, "stdin", mock.Mock(**{"isatty.return_value": False}) + ) + + with pytest.raises(click.ClickException, match="already exists"): + create_topic("feat-b", year="2025") + + assert _current_branch(tmp_path) == "feat-a" + assert not (tmp_path / ".goga" / "history" / "2025" / "feat-b").exists() diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index 8ec9b7cc..34e5281c 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -339,6 +339,23 @@ def test_create_topic_occupied_reask_creates_second_name( stderr = capsys.readouterr().err assert "branch 'feat/x' already exists" in stderr + def test_create_topic_reask_abort_leaves_repository_untouched( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Ctrl-C at the re-ask prompt propagates as ``click.Abort`` — nothing is created.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + monkeypatch.setattr(click, "prompt", mock.Mock(side_effect=click.Abort())) + create_and_switch = _wire_inventory(monkeypatch, [], current="main") + + with pytest.raises(click.Abort): + create_topic("!!!") + + create_and_switch.assert_not_called() + assert not (tmp_path / ".goga").exists() + # --- Infrastructure boundary --- @@ -394,3 +411,42 @@ def test_create_mutation_failure_surfaces_as_clean_error( assert "fatal: invalid branch name" in raised.value.message assert not (tmp_path / ".goga" / "history" / "2026" / "feat-x").exists() + + def test_missing_git_binary_at_creation_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A missing git binary during the create mutation is a clean error.""" + monkeypatch.chdir(tmp_path) + _wire_inventory(monkeypatch, [], current="main") + monkeypatch.setattr( + creation, "create_and_switch_branch", mock.Mock(side_effect=FileNotFoundError("git")) + ) + + with pytest.raises(click.ClickException) as raised: + create_topic("feat/x", year="2026") + + assert "git" in raised.value.message + + def test_stray_file_at_topic_path_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A stray file named like the slug occupies no topic — the mkdir failure is a clean error. + + The history oracle counts directories only, so the name is free and + the branch is created first; ``ensure_topic_dir`` then fails on the + file, and the boundary turns the ``OSError`` into a clean error + instead of a traceback. + """ + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + year_dir.mkdir(parents=True) + (year_dir / "feat-x").write_text("not a topic", encoding="utf-8") + create_and_switch = _wire_inventory(monkeypatch, [], current="main") + + with pytest.raises(click.ClickException) as raised: + create_topic("feat-x", year="2026") + + assert "cannot create topic directory" in raised.value.message + assert "feat-x" in raised.value.message + # The traced order — the branch mutation runs before the directory. + create_and_switch.assert_called_once_with("feat-x") diff --git a/tests/topics/test_switching.py b/tests/topics/test_switching.py index 692f9402..945b15a5 100644 --- a/tests/topics/test_switching.py +++ b/tests/topics/test_switching.py @@ -556,3 +556,56 @@ def test_switch_mutation_failure_surfaces_as_clean_error( switch_topic("feat/a", "2026") assert "error: cannot switch" in raised.value.message + + def test_missing_git_binary_at_mutation_surfaces_as_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A missing git binary during the mutation phase is a clean error.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + monkeypatch.setattr( + switching, "is_working_tree_clean", mock.Mock(side_effect=FileNotFoundError("git")) + ) + + with pytest.raises(click.ClickException) as raised: + switch_topic("feat/a", "2026") + + assert "git" in raised.value.message + + def test_selection_prompt_abort_leaves_repository_untouched( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Ctrl-C at the selection prompt propagates as ``click.Abort`` — no mutation.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="feat/b", remote=False), + BranchRef(name="main", remote=False), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "feat/b": [".goga/history/2026/feat-b/plan.md"], + "main": ["README.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + monkeypatch.setattr(click, "prompt", mock.Mock(side_effect=click.Abort())) + cleanliness, checkout, creation = _wire_mutations(monkeypatch) + + with pytest.raises(click.Abort): + switch_topic("feat", "2026") + + cleanliness.assert_not_called() + checkout.assert_not_called() + creation.assert_not_called() From 99a09c90ab93eb808cbed4654cd19def4224c336 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 19:30:33 +0000 Subject: [PATCH 094/229] fix: address code review findings --- goga/topics/switching.py | 52 +++++++-- tests/integration/test_topic_workflows.py | 49 ++++++++- tests/topics/test_switching.py | 126 ++++++++++++++++++++-- 3 files changed, 210 insertions(+), 17 deletions(-) diff --git a/goga/topics/switching.py b/goga/topics/switching.py index b4c7ac81..775b8ab8 100644 --- a/goga/topics/switching.py +++ b/goga/topics/switching.py @@ -26,7 +26,7 @@ normalize_topic_slug, resolve_current_branch_name, ) -from .board import _current_branch_topic, _year_topics_by_ref +from .board import _current_branch_topic, _short_name, _year_topics_by_ref from .git import ( BranchRef, checkout_local_branch, @@ -71,8 +71,11 @@ def resolve_switch_candidates( Returns: The matching candidates, exact matches first, then prefix matches — locals before remote-tracking refs, then by branch, then by topic. - A branch without a topic is a valid candidate with ``topic=None`` - and empty ``statuses``. + Every branch appears once: a remote-tracking candidate whose local + twin hosts the same topic is collapsed — the local branch wins — + and a branch hosting several topics of the year contributes its + first entry only. A branch without a topic is a valid candidate + with ``topic=None`` and empty ``statuses``. Algorithm: 1. Normalize ``identifier`` into a slug via ``normalize_topic_slug`` @@ -84,11 +87,14 @@ def resolve_switch_candidates( local branches first 5. Prefix matches otherwise -> the branches whose name or hosted slug starts with the input - 6. Return the candidates with their statuses + 6. Collapse the tier to one entry per branch and return the + candidates with their statuses Requirements: Exact matches always precede prefix matches — the first non-empty tier wins and excludes every other tier. + A branch appears in the result once — a local branch beats its + remote twin, so an unambiguous identifier never reaches a prompt. A branch without a topic is a valid candidate. Read-only — no mutation before a choice. @@ -183,7 +189,7 @@ def _resolve_switch_candidates( Returns: The candidates of the first non-empty resolution tier — exact - branch, exact slug, prefix. + branch, exact slug, prefix — collapsed to one entry per branch. """ resolved_year = year or current_year() scale = assemble_status_scale() @@ -204,7 +210,7 @@ def _resolve_switch_candidates( ) for tier in tiers: if tier: - return tier + return _unique_candidates(tier) return [] @@ -259,6 +265,40 @@ def _hosted_candidates( ] +def _unique_candidates(candidates: list[SwitchCandidate]) -> list[SwitchCandidate]: + """Collapse the redundant candidates of one resolution tier. + + A remote-tracking candidate whose local twin hosts the same topic is + dropped — the local branch wins, mirroring the board's twin collapse — + and every branch is kept once: the first entry of the tier order + (locals first, then branch, then topic) carries it, so a branch + hosting several topics of the year never repeats in the list and an + unambiguous identifier stays unambiguous. + + Args: + candidates: The candidates of one resolution tier, in tier order. + + Returns: + The candidates without remote twins and branch repetitions. + """ + local_topics = { + (candidate.topic, candidate.branch) + for candidate in candidates + if not candidate.remote + } + unique: list[SwitchCandidate] = [] + branches: set[str] = set() + for candidate in candidates: + hosted_twin = (candidate.topic, _short_name(candidate.branch)) in local_topics + if candidate.remote and hosted_twin: + continue + if candidate.branch in branches: + continue + branches.add(candidate.branch) + unique.append(candidate) + return unique + + def _switch_topic(identifier: str, year: str | None) -> str: """Run the traced switch procedure — the unwrapped orchestration. diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index 01980553..b1afa9a5 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -311,20 +311,61 @@ def test_switch_by_slug_checks_out_local_host( assert line == "Switched to branch solo" assert _current_branch(tmp_path) == "solo" + def test_switch_by_branch_name_hosting_several_topics_switches( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An exact branch name is one candidate even when the branch hosts + several topics of the year — the switch needs no terminal.""" + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + + line = switch_topic("feat-b", "2025") + + assert line == "Switched to branch feat-b" + assert _current_branch(tmp_path) == "feat-b" + def test_switch_ambiguous_slug_lists_candidates_without_terminal( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A branch hosting several topics of the year is ambiguous — the numbered - list is the non-interactive error, and nothing is mutated.""" + """A slug hosted by several distinct branches is genuinely ambiguous — + the numbered list is the non-interactive error, and nothing is mutated.""" _init_topic_repo(tmp_path) + _git(tmp_path, "switch", "-q", "-c", "one") + _write(tmp_path, ".goga/history/2025/shared/prd.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "topic shared") + _git(tmp_path, "switch", "-q", "-c", "two") + _git(tmp_path, "switch", "-q", "feat-a") monkeypatch.chdir(tmp_path) monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) - with pytest.raises(click.ClickException, match=r"1\) feat-b"): - switch_topic("feat-b", "2025") + with pytest.raises(click.ClickException, match=r"(?s)1\) one.*2\) two"): + switch_topic("shared", "2025") assert _current_branch(tmp_path) == "feat-a" + def test_switch_by_slug_with_pushed_twin_switches_and_is_idempotent( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A slug hosted by a branch and its pushed remote twin resolves to the + local branch — from another branch and again on the host.""" + _init_topic_repo(tmp_path) + _git(tmp_path, "switch", "-q", "--orphan", "work/x") + _write(tmp_path, ".goga/history/2025/work-x/plan.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "topic work-x") + _git(tmp_path, "update-ref", "refs/remotes/origin/work/x", "HEAD") + _git(tmp_path, "switch", "-q", "feat-a") + monkeypatch.chdir(tmp_path) + + line = switch_topic("work-x", "2025") + idempotent = switch_topic("work-x", "2025") + + assert line == "Switched to branch work/x" + assert idempotent == "Already on branch work/x" + assert _current_branch(tmp_path) == "work/x" + def test_switch_by_slug_creates_branch_from_remote_tracking( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/topics/test_switching.py b/tests/topics/test_switching.py index 945b15a5..306dc009 100644 --- a/tests/topics/test_switching.py +++ b/tests/topics/test_switching.py @@ -258,13 +258,69 @@ def test_resolve_switch_candidates_orders_local_before_remote( ) -> None: """Within a tier: locals first, then by branch, then by topic.""" monkeypatch.chdir(tmp_path) - _wire_resolution(monkeypatch, builtin_scale, _twin_inventory(), _twin_trees(), None) + inventory = [ + BranchRef(name="origin/zz", remote=True), + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/aa", remote=True), + ] + trees = { + "feat/a": [".goga/history/2026/feat-x/plan.md"], + "origin/aa": [".goga/history/2026/feat-x/prd.md"], + "origin/zz": [".goga/history/2026/feat-x/adr.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) - candidates = resolve_switch_candidates("feat-a", "2026") + candidates = resolve_switch_candidates("feat-x", "2026") assert [(c.branch, c.remote, c.statuses) for c in candidates] == [ ("feat/a", False, ["planned"]), - ("origin/feat/a", True, ["planned"]), + ("origin/aa", True, ["defined"]), + ("origin/zz", True, ["discovered"]), + ] + + def test_resolve_switch_candidates_local_beats_remote_twin( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A branch and its remote twin hosting one slug resolve to the local + branch — an unambiguous slug never reaches a prompt.""" + monkeypatch.chdir(tmp_path) + _wire_resolution(monkeypatch, builtin_scale, _twin_inventory(), _twin_trees(), None) + + candidates = resolve_switch_candidates("feat-a", "2026") + + assert [(c.branch, c.remote) for c in candidates] == [("feat/a", False)] + + def test_resolve_switch_candidates_branch_appears_once( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A branch hosting several topics of the year is one candidate — the + branch never repeats in the tier list.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="other", remote=False), + ] + trees = { + "main": [ + ".goga/history/2026/feat-b/plan.md", + ".goga/history/2026/feat-a/prd.md", + ], + "other": ["README.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "other") + + candidates = resolve_switch_candidates("main", "2026") + + # The first entry of the tier order (branch, then topic) carries the + # branch — the alphabetically first hosted topic. + assert [(c.branch, c.topic, c.statuses) for c in candidates] == [ + ("main", "feat-a", ["defined"]) ] def test_resolve_switch_candidates_working_copy_of_current_branch( @@ -276,19 +332,23 @@ def test_resolve_switch_candidates_working_copy_of_current_branch( """The current branch's statuses come from the working copy.""" monkeypatch.chdir(tmp_path) _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/other", remote=True), + ] trees = { "feat/a": [".goga/history/2026/feat-a/notes.txt"], - "origin/feat/a": [".goga/history/2026/feat-a/notes.txt"], + "origin/other": [".goga/history/2026/feat-a/notes.txt"], } - _wire_resolution(monkeypatch, builtin_scale, _twin_inventory(), trees, "feat/a") + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "feat/a") candidates = resolve_switch_candidates("feat-a", "2026") # The uncommitted plan.md is visible on the current branch; the same - # work through its remote twin reads the ref tree only. + # topic on another ref reads the ref tree only. assert [(c.branch, c.statuses, c.current) for c in candidates] == [ ("feat/a", ["planned"], True), - ("origin/feat/a", ["empty"], False), + ("origin/other", ["empty"], False), ] @@ -317,6 +377,58 @@ def test_switch_topic_single_candidate_switches( assert result == "Switched to branch feat/a" checkout.assert_called_once_with("feat/a") + def test_switch_topic_slug_with_pushed_twin_switches_local( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A slug hosted by a branch and its remote twin checks out the local + branch — the twin never turns an unambiguous switch into a prompt.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/feat/a", remote=True), + ] + trees = {**_twin_trees(), "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) + + result = switch_topic("feat-a", "2026") + + assert result == "Switched to branch feat/a" + checkout.assert_called_once_with("feat/a") + creation.assert_not_called() + + def test_switch_topic_branch_hosting_several_topics_switches( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An exact branch name hosting several topics of the year is one + candidate — the switch proceeds without a selection prompt.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="other", remote=False), + ] + trees = { + "main": [ + ".goga/history/2026/feat-b/plan.md", + ".goga/history/2026/feat-a/prd.md", + ], + "other": ["README.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "other") + _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=True) + + result = switch_topic("main", "2026") + + assert result == "Switched to branch main" + checkout.assert_called_once_with("main") + def test_switch_topic_idempotent_when_already_on_host( self, builtin_scale: StatusScale, From af540c7cda5f703f4f62067c040b61a5b2b8c04a Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 20:09:10 +0000 Subject: [PATCH 095/229] fix: address acceptance review findings in topics cells - declare current_year and resolve_topic_dir imports in goga/topics CODEMANIFEST and reference them in collect_topic_board annotations - pin the tier-collapse guarantee of resolve_switch_candidates, the HEAD-symref drop of list_branch_refs, and the duplicate-name rejection of StatusRegistry.register in their manifests - demonstrate create_and_switch_branch and BranchRef construction in refs-and-switching usage - cover the git-missing handler of resolve_switch_candidates and the post-resolution ImportError handler of switch_topic with contract tests --- goga/history/statuses/CODEMANIFEST | 4 +- goga/topics/CODEMANIFEST | 10 ++- goga/topics/git/.usages/refs-and-switching.md | 9 ++- goga/topics/git/CODEMANIFEST | 3 +- tests/topics/test_switching.py | 69 ++++++++++++++----- 5 files changed, 72 insertions(+), 23 deletions(-) diff --git a/goga/history/statuses/CODEMANIFEST b/goga/history/statuses/CODEMANIFEST index 50ea5942..881156d9 100644 --- a/goga/history/statuses/CODEMANIFEST +++ b/goga/history/statuses/CODEMANIFEST @@ -162,7 +162,9 @@ Annotations: | 1. Qualify `name` with the registry's tool prefix 2. Validate the content: a non-empty name, a non-empty `filepath`, and at least one anchor present - 3. Append the entry to the registry content + 3. Reject a name already registered in this registry — the qualified + name is the identity, one entry per name + 4. Append the entry to the registry content Requirements: - A structural violation raises a clean registration error naming the diff --git a/goga/topics/CODEMANIFEST b/goga/topics/CODEMANIFEST index 38890bd6..b95bca24 100644 --- a/goga/topics/CODEMANIFEST +++ b/goga/topics/CODEMANIFEST @@ -6,6 +6,8 @@ Imports: - ensure_topic_dir - resolve_history_root - resolve_topic_status + - resolve_topic_dir + - current_year - StatusScale - assemble_status_scale Usages: @@ -104,6 +106,7 @@ Annotations: | Algorithm: 1. Resolve the year — `year` when given, otherwise the current year + via `current_year` 2. Assemble the status scale via `assemble_status_scale` once 3. Local mode enumerates local branches via `list_branch_refs` and reads the current branch from the working copy via @@ -116,7 +119,8 @@ Annotations: | `resolve_history_root` with `read_ref_tree_paths`, without checkout 5. For every ref, take the topics of the resolved year with their artifact paths and compute the maximal statuses — the working copy - via `resolve_topic_status`, every other ref via the `StatusScale` + over the directory composed by `resolve_topic_dir` via + `resolve_topic_status`, every other ref via the `StatusScale` 6. Collapse a local branch and its remote twin into one row — the local branch wins; different branches hosting one slug stay separate rows 7. Mark the row hosting the current branch @@ -191,6 +195,10 @@ Annotations: | Requirements: - Exact matches always precede prefix matches + - A branch appears in the result once — a local branch beats its + remote twin, and a branch hosting several topics of the year + contributes its first entry only, so an unambiguous identifier never + reaches a prompt - A branch without a topic is a valid candidate - Read-only — no mutation before a choice diff --git a/goga/topics/git/.usages/refs-and-switching.md b/goga/topics/git/.usages/refs-and-switching.md index febce919..190996c5 100644 --- a/goga/topics/git/.usages/refs-and-switching.md +++ b/goga/topics/git/.usages/refs-and-switching.md @@ -41,18 +41,25 @@ paths = read_ref_tree_paths("feature-foo", prefix) ```python from goga.topics.git import ( + BranchRef, checkout_local_branch, + create_and_switch_branch, create_branch_from_remote_tracking, is_working_tree_clean, ) if is_working_tree_clean(): checkout_local_branch("feature-foo") # existing local branch - create_branch_from_remote_tracking(remote_ref) # remote-only host + create_and_switch_branch("Feature/Foo_Bar") # fresh work, name verbatim + +remote_ref = BranchRef(name="origin/feature-foo", remote=True) +create_branch_from_remote_tracking(remote_ref) # remote-only host ``` - `checkout_local_branch` takes a short local branch name; the branch must exist. +- `create_and_switch_branch` takes the branch name exactly as entered — no + normalization, no suffixing; git owns name validity. - `create_branch_from_remote_tracking` takes a remote `BranchRef` obtained from `list_branch_refs` and creates a local branch with its short name at the ref's commit — no network. diff --git a/goga/topics/git/CODEMANIFEST b/goga/topics/git/CODEMANIFEST index f9ef8774..9b8ff1ce 100644 --- a/goga/topics/git/CODEMANIFEST +++ b/goga/topics/git/CODEMANIFEST @@ -64,7 +64,8 @@ Annotations: | Algorithm: 1. Ask git for the local branch refs 2. Ask git for the remote-tracking refs - 3. Merge both into one inventory sorted alphabetically by display name + 3. Drop the <remote>/HEAD symrefs — they are pointers, not branches + 4. Merge both into one inventory sorted alphabetically by display name Requirements: - Read-only — no ref is created, moved, or deleted diff --git a/tests/topics/test_switching.py b/tests/topics/test_switching.py index 306dc009..7ddf533b 100644 --- a/tests/topics/test_switching.py +++ b/tests/topics/test_switching.py @@ -63,9 +63,7 @@ def _wire_resolution( monkeypatch.setattr(board, "read_ref_tree_paths", _trees_reader(trees)) -def _wire_mutations( - monkeypatch: pytest.MonkeyPatch, clean: bool = True -) -> tuple[mock.Mock, mock.Mock, mock.Mock]: +def _wire_mutations(monkeypatch: pytest.MonkeyPatch, clean: bool = True) -> tuple[mock.Mock, mock.Mock, mock.Mock]: """Patch the switch mutations at their import points. Returns: @@ -131,9 +129,7 @@ def test_switch_candidate_is_a_frozen_kw_only_dataclass(self) -> None: "current": bool, "remote": bool, } - candidate = SwitchCandidate( - branch="feat/a", topic="feat-a", statuses=["planned"], current=True, remote=False - ) + candidate = SwitchCandidate(branch="feat/a", topic="feat-a", statuses=["planned"], current=True, remote=False) assert candidate.branch == "feat/a" assert candidate.topic == "feat-a" assert candidate.statuses == ["planned"] @@ -149,8 +145,7 @@ def test_resolve_switch_candidates_signature(self) -> None: signature = inspect.signature(resolve_switch_candidates) assert list(signature.parameters) == ["identifier", "year"] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None hints = typing.get_type_hints(resolve_switch_candidates) @@ -165,8 +160,7 @@ def test_switch_topic_signature(self) -> None: signature = inspect.signature(switch_topic) assert list(signature.parameters) == ["identifier", "year"] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None hints = typing.get_type_hints(switch_topic) @@ -319,9 +313,7 @@ def test_resolve_switch_candidates_branch_appears_once( # The first entry of the tier order (branch, then topic) carries the # branch — the alphabetically first hosted topic. - assert [(c.branch, c.topic, c.statuses) for c in candidates] == [ - ("main", "feat-a", ["defined"]) - ] + assert [(c.branch, c.topic, c.statuses) for c in candidates] == [("main", "feat-a", ["defined"])] def test_resolve_switch_candidates_working_copy_of_current_branch( self, @@ -571,9 +563,7 @@ def test_switch_topic_no_candidates_clean_error( with pytest.raises(click.ClickException) as raised: switch_topic("nope") - assert raised.value.message == ( - "no branch hosts 'nope' — run 'goga topics status' to see the board" - ) + assert raised.value.message == ("no branch hosts 'nope' — run 'goga topics status' to see the board") checkout.assert_not_called() def test_switch_topic_non_interactive_multiple_candidates_fails_with_list( @@ -631,6 +621,22 @@ def test_git_failure_surfaces_as_clean_error( assert "fatal: not a git repository" in raised.value.message + def test_missing_git_binary_at_resolution_surfaces_as_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A missing git binary during the resolution is a clean error.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(switching, "assemble_status_scale", lambda: builtin_scale) + monkeypatch.setattr(switching, "list_branch_refs", mock.Mock(side_effect=FileNotFoundError("git"))) + + with pytest.raises(click.ClickException) as raised: + resolve_switch_candidates("feat/a", "2026") + + assert "git is not available" in raised.value.message + def test_broken_tool_package_import_surfaces_as_clean_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -683,15 +689,40 @@ def test_missing_git_binary_at_mutation_surfaces_as_clean_error( ] trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") - monkeypatch.setattr( - switching, "is_working_tree_clean", mock.Mock(side_effect=FileNotFoundError("git")) - ) + monkeypatch.setattr(switching, "is_working_tree_clean", mock.Mock(side_effect=FileNotFoundError("git"))) with pytest.raises(click.ClickException) as raised: switch_topic("feat/a", "2026") assert "git" in raised.value.message + def test_broken_import_after_resolution_surfaces_as_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A fatal ``ImportError`` after the resolution is a clean error. + + The scale of a command run assembles inside the resolution, so the + orchestration wrapper's own ``ImportError`` boundary is probed here: + the cleanliness check raising it after the candidates resolved. + """ + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + broken = ImportError("package goga_tool_bad failed to import: boom") + monkeypatch.setattr(switching, "is_working_tree_clean", mock.Mock(side_effect=broken)) + + with pytest.raises(click.ClickException) as raised: + switch_topic("feat/a", "2026") + + assert raised.value.message == "package goga_tool_bad failed to import: boom" + def test_selection_prompt_abort_leaves_repository_untouched( self, builtin_scale: StatusScale, From 3ae620105f1d3d1ef3310e24a8487c00801116fd Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 23:12:47 +0300 Subject: [PATCH 096/229] feat: add memory to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f9d8856e..63292d85 100644 --- a/.gitignore +++ b/.gitignore @@ -226,3 +226,4 @@ docs/superpowers/ # Goga .goga/history +.goga/memory \ No newline at end of file From ee80a48dad3a78dbb95c5b1cb9571bd47113469a Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 23:14:33 +0300 Subject: [PATCH 097/229] feat: add memory to brainstorm --- .goga/workflows/development.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.goga/workflows/development.yml b/.goga/workflows/development.yml index 7e27117b..79111f72 100644 --- a/.goga/workflows/development.yml +++ b/.goga/workflows/development.yml @@ -6,6 +6,8 @@ stages: prompt: | Architectural design process. + Use the file `.goga/memory/development.md` as project rules if exists. The file is read only. + Requirements: - Annotations describe the high-level order of actions - Every usage file is connected through Imports and referenced in annotations From 67a9740233fb1cc0f10b848fb3676701adbbdbb2 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 23:30:49 +0300 Subject: [PATCH 098/229] feat: fix prompt for workflow in brainstorm --- .goga/workflows/development.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.goga/workflows/development.yml b/.goga/workflows/development.yml index 79111f72..7c650a96 100644 --- a/.goga/workflows/development.yml +++ b/.goga/workflows/development.yml @@ -6,13 +6,14 @@ stages: prompt: | Architectural design process. - Use the file `.goga/memory/development.md` as project rules if exists. The file is read only. + Use the memory file `.goga/memory/development.md` as project rules if exists. The file is read only. Requirements: - Annotations describe the high-level order of actions - Every usage file is connected through Imports and referenced in annotations - Usage files are self-contained - Footer Description describes the responsibility zone + - Before deciding how to do something, reflect on the memory file. Constraints: - Annotations must not reference previous functionality From 3cd3bb81307bc42adc1f6f1f92cb44cfc8edd322 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 20:47:37 +0000 Subject: [PATCH 099/229] fix: create topic from pipeline -t when no branch hosts it goga pipeline NAME -t <topic> delegated the identifier to switch_topic, whose contract mandated failing with "no branch hosts '<topic>'" when no candidate branches exist, so the documented create-if-missing behavior of -t was unreachable. Add the ensure_topic(identifier, year=None) domain procedure to the goga/topics cell: it switches when a branch hosts the topic and creates the topic otherwise. The pipeline command now consumes ensure_topic instead of switch_topic; switch_topic, create_topic and goga topics switch are unchanged. Reconcile CODEMANIFESTs, .usages (new ensuring.md) and user docs with the new contract, and cover the behavior with domain and command-level tests. Gates: pytest tests/ 4752 passed, ruff clean, goga lint 0 errors. --- docs/cli/pipeline.md | 16 +- docs/cli/topics.md | 2 +- docs/workflow/index.md | 2 +- .../pipeline/.usages/pipeline-command.md | 25 ++- goga/commands/pipeline/CODEMANIFEST | 67 +++--- goga/commands/pipeline/pipeline.py | 25 ++- goga/topics/.usages/ensuring.md | 42 ++++ goga/topics/.usages/switching.md | 3 +- goga/topics/CODEMANIFEST | 53 ++++- goga/topics/__init__.py | 19 +- goga/topics/switching.py | 99 ++++++++- .../pipeline/test_pipeline_command.py | 12 +- .../pipeline/test_pipeline_dispatch.py | 102 +++++---- tests/topics/test_creation.py | 1 + tests/topics/test_switching.py | 201 +++++++++++++++++- 15 files changed, 541 insertions(+), 128 deletions(-) create mode 100644 goga/topics/.usages/ensuring.md diff --git a/docs/cli/pipeline.md b/docs/cli/pipeline.md index 9ebf6bee..ea2e6339 100644 --- a/docs/cli/pipeline.md +++ b/docs/cli/pipeline.md @@ -59,7 +59,7 @@ The card and the run share the same workflow rule set and the same compiler, so ## Run Mode (`goga pipeline <name>`) -Run a pipeline by name. Pass the bare name only (no `.yml` extension); the container resolves the absolute path internally, compiles the goga DSL pipeline-file into an afm flow-file at `<AFM_DIR>/flow.yml`, materializes the four agent prompt files into `<AFM_DIR>/prompts/` (applying any `roles` overrides from the pipeline-file header — see [Custom agent prompts](#custom-agent-prompts)), and runs that via `afm run`. Passing `-p/--parallel N` caps the number of stages afm executes concurrently (it threads through to `afm run --max-parallel <N>`); without it afm runs unbounded. A free port is allocated automatically and published on both sides (`-p <port>:<port>`); `afm` listens on that port inside the container. When a workflow is applied, a single log line naming it is printed to stdout; when `-t/--topic` switched the repository onto the hosting branch, the single result line of the switch (`Switched to branch <name>`, `Created branch <name> from <remote>/<name>`, or `Already on branch <name>`) is echoed once before the launch; otherwise the launcher prints no status line. +Run a pipeline by name. Pass the bare name only (no `.yml` extension); the container resolves the absolute path internally, compiles the goga DSL pipeline-file into an afm flow-file at `<AFM_DIR>/flow.yml`, materializes the four agent prompt files into `<AFM_DIR>/prompts/` (applying any `roles` overrides from the pipeline-file header — see [Custom agent prompts](#custom-agent-prompts)), and runs that via `afm run`. Passing `-p/--parallel N` caps the number of stages afm executes concurrently (it threads through to `afm run --max-parallel <N>`); without it afm runs unbounded. A free port is allocated automatically and published on both sides (`-p <port>:<port>`); `afm` listens on that port inside the container. When a workflow is applied, a single log line naming it is printed to stdout; when `-t/--topic` brought the repository onto the requested work, the single result line of the topic procedure (`Switched to branch <name>`, `Created branch <name> from <remote>/<name>`, `Already on branch <name>`, or `Created branch <name> and topic <year>/<slug>`) is echoed once before the launch; otherwise the launcher prints no status line. Pipelines are flat `*.yml` files (one per pipeline) resolved from two directories, with the project source winning on name conflicts: @@ -90,10 +90,11 @@ If the name exists in both sources, the project source wins. The container exit ### Topic switch -The run form can first bring the repository onto the branch hosting the requested work — continuing existing work instead of creating fresh work (fresh work belongs to [`goga topics create`](topics.md)): +The run form can first bring the repository onto the requested work — continuing existing work, or creating fresh work in one command when nothing hosts the identifier: ```bash goga pipeline development -t feat/x +goga pipeline refinement -t prune-history-and-new-status ``` The identifier resolves through three tiers, and the first tier with a match wins (so a non-interactive `-t` never reaches a prompt): @@ -102,15 +103,16 @@ The identifier resolves through three tiers, and the first tier with a match win 2. **exact topic slug** — a branch hosting the topic `.goga/history/<YYYY>/<slug>/` whose slug equals the normalized input (lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed: `Feature/Foo_Bar` → `feature-foo-bar`); local branches come before remote-tracking refs; 3. **prefix** — a branch whose name, or whose hosted slug, starts with the input. -Within a tier, several candidates may match (a branch chain carries several topics). On an interactive terminal goga prints the numbered list with each candidate's statuses and prompts for a number; with no terminal (CI/scripts) the numbered list itself becomes a clean error and the command exits 1 — no image refresh, build, or launch happens. No candidate at all exits 1 with a hint to `goga topics status`. +Within a tier, several candidates may match (a branch chain carries several topics). On an interactive terminal goga prints the numbered list with each candidate's statuses and prompts for a number; with no terminal (CI/scripts) the numbered list itself becomes a clean error and the command exits 1 — no image refresh, build, or launch happens. No candidate at all creates fresh work instead of failing: the branch is created with the name as entered, the repository switches to it, and the topic directory of the year is created from its slug (`Created branch <name> and topic <year>/<slug>`). An unusable name — one that normalizes to an empty slug, or one already occupied by an existing branch, a remote-tracking twin, or the topic directory of the year — re-asks on an interactive terminal and exits 1 with the reason otherwise. The outcome: - already on the hosting branch → idempotent success, nothing is touched and the working tree is not even probed; - a local host → `git switch <branch>`; -- a remote-only host → the local branch is created from the remote-tracking ref (`git switch -c <branch> <remote>/<branch>`). +- a remote-only host → the local branch is created from the remote-tracking ref (`git switch -c <branch> <remote>/<branch>`); +- nothing hosts the identifier → the branch is created as entered and the topic directory of the year appears (uncommitted changes carry onto the fresh branch, exactly like `goga topics create`). -A switch that would mutate checks the working tree first: a dirty tree exits 1 with `working tree is dirty — commit or stash before switching` before anything is touched. Every git action happens on the host, after every form check and before any docker activity. The single result line (`Switched to branch <name>`, `Created branch <name> from <remote>/<name>`, or `Already on branch <name>`) is echoed to stdout once, before the launch. The branch name is never forwarded into the container — the container sees the branch through the mounted project, and goga does not switch back after the launch. +A switch that would mutate checks the working tree first: a dirty tree exits 1 with `working tree is dirty — commit or stash before switching` before anything is touched. Every git action happens on the host, after every form check and before any docker activity. The single result line (`Switched to branch <name>`, `Created branch <name> from <remote>/<name>`, `Already on branch <name>`, or `Created branch <name> and topic <year>/<slug>`) is echoed to stdout once, before the launch. The branch name is never forwarded into the container — the container sees the branch through the mounted project, and goga does not switch back after the launch. The flat list, overview, and card forms silently ignore `-t` — passing it there is not an error and has no effect. @@ -186,7 +188,7 @@ stages: | `name` (positional) | string | — | Pipeline name without extension. Selects the card (`--info`) or run form; omit it and pass `--list` for the listing forms. `--list` and a name together are rejected (exit 1) | | `-l`, `--list` | flag | off | List available pipelines (flat list). Add `--info` for a one-line description per pipeline | | `-i`, `--info` | flag | off | With `--list`: print the overview. With `NAME`: print the pipeline card instead of running it | -| `-t`, `--topic` | string | — | Bring the repository onto the branch hosting the requested work before the run — a branch name, a topic slug, or their prefix; see [Topic switch](#topic-switch). Run form only — the list/info forms silently ignore it | +| `-t`, `--topic` | string | — | Bring the repository onto the requested work before the run — a branch name, a topic slug, or their prefix, created fresh when nothing hosts it; see [Topic switch](#topic-switch). Run form only — the list/info forms silently ignore it | | `-e`, `--env` | string (repeatable) | — | Additional environment variable (`KEY=VALUE`) forwarded into the container env-file. Run form only | | `--proxy` | string | config | HTTP/HTTPS proxy URL; overrides `pipeline.proxy`. Adds `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` to the container env-file. Run form only | | `--add-host` | string (repeatable) | -- | Add a `docker run --add-host HOST:IP` entry; merges on top of `pipeline.hosts` (CLI wins on key conflict). Run form only — the info forms receive the configured `pipeline.hosts` only | @@ -275,7 +277,7 @@ Host side (all forms): | Code | Meaning | |------|---------| | `0` | The operation completed (container exit 0) | -| `1` | A `ClickException`: a form error (bare invocation, `--list` + name, `--workflow` + `--no-workflow`), the `pipeline` section missing in `.goga/config.yml`, an explicit `--workflow <name>` naming a file that does not exist or escaping the workflows dir, a topic-switch failure (no branch hosting the identifier, several candidates without a terminal, a dirty working tree, a failed `git switch` or ref listing, or a missing git binary — see [Topic switch](#topic-switch)), or a fatal image build/refresh. Or the pre-launch version check refusing the launch (a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version — a stderr message plus `SystemExit`, see [Pre-launch version check](#pre-launch-version-check)) | +| `1` | A `ClickException`: a form error (bare invocation, `--list` + name, `--workflow` + `--no-workflow`), the `pipeline` section missing in `.goga/config.yml`, an explicit `--workflow <name>` naming a file that does not exist or escaping the workflows dir, a topic-procedure failure (several candidates without a terminal, a dirty working tree on a switch, an unusable — empty-slug or occupied — name without a terminal, a failed `git switch` or ref listing, or a missing git binary — see [Topic switch](#topic-switch)), or a fatal image build/refresh. Or the pre-launch version check refusing the launch (a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version — a stderr message plus `SystemExit`, see [Pre-launch version check](#pre-launch-version-check)) | | other| The container's exit code, propagated unchanged (including the run-mode codes below) | Container side, run form: diff --git a/docs/cli/topics.md b/docs/cli/topics.md index 659badbc..6f355fb4 100644 --- a/docs/cli/topics.md +++ b/docs/cli/topics.md @@ -72,7 +72,7 @@ IDENTIFIER resolves through three tiers — the first tier with a match wins, so - A local host is checked out (`git switch <branch>`); a remote-only host creates the local branch from the remote-tracking ref (`git switch -c <branch> <remote>/<branch>`, reported as `Created branch <branch> from <remote>/<branch>`). - A switch that would mutate first probes the working tree; a dirty tree exits 1 with `working tree is dirty — commit or stash before switching` before anything is touched. -The same resolution backs `goga pipeline <name> -t <identifier>` (see [pipeline](pipeline.md#topic-switch)). +The same resolution backs the switch half of `goga pipeline <name> -t <identifier>` — there, an identifier nothing hosts creates fresh work instead of failing (see [pipeline](pipeline.md#topic-switch)). ## Exit Codes diff --git a/docs/workflow/index.md b/docs/workflow/index.md index 4af92e57..96d26aa9 100644 --- a/docs/workflow/index.md +++ b/docs/workflow/index.md @@ -102,7 +102,7 @@ define → discover → propose → review(task) | [`change`](change.md) | Development | Change description | Modified code + reconciled contracts and usages | | [`accept`](accept.md) | Development | Completed implementation | Final acceptance report | -Workflow artifacts live at `.goga/history/<year>/<topic>/<kind>.md` (`<kind>` ∈ `prd | adr | task | arch | design | plan`): `<year>` is the current year as `YYYY`, and `<topic>` is a lowercase kebab-case slug — non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed (branch `release/1.3.0` → `release-1-3-0`). The topic directory is created lazily by the stage that first writes into it, and the whole `.goga/history/` tree is git-ignored by default. `goga pipeline <name> -t <topic>` switches onto the branch hosting an existing topic before a run; `goga topics create <branch>` prepares both a fresh branch and its fresh topic. +Workflow artifacts live at `.goga/history/<year>/<topic>/<kind>.md` (`<kind>` ∈ `prd | adr | task | arch | design | plan`): `<year>` is the current year as `YYYY`, and `<topic>` is a lowercase kebab-case slug — non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed (branch `release/1.3.0` → `release-1-3-0`). The topic directory is created lazily by the stage that first writes into it, and the whole `.goga/history/` tree is git-ignored by default. `goga pipeline <name> -t <topic>` switches onto the branch hosting an existing topic before a run, or creates both a fresh branch and its fresh topic when nothing hosts it; `goga topics create <branch>` prepares both directly. ## Next steps diff --git a/goga/commands/pipeline/.usages/pipeline-command.md b/goga/commands/pipeline/.usages/pipeline-command.md index b9241593..aa9bde9b 100644 --- a/goga/commands/pipeline/.usages/pipeline-command.md +++ b/goga/commands/pipeline/.usages/pipeline-command.md @@ -25,7 +25,7 @@ exit 1). `--info` is a modifier, not a mode: without a name and without |---|---|---| | -l / --list | flag | select the listing forms | | -i / --info | flag | show instead of act (overview with --list, card with NAME) | -| -t / --topic ID | str | bring the repository onto the requested work (branch name, topic slug, or prefix) before the run; run form only | +| -t / --topic ID | str | bring the repository onto the requested work (branch name, topic slug, or prefix) before the run, creating it when nothing hosts it; run form only | | -w / --workflow NAME | str | apply an explicit workflow (run and card); the file must exist (early host validation) | | --no-workflow | flag | disable workflow resolution (run and card) | | -p / --parallel N | int | max concurrently executing stages; run only | @@ -33,17 +33,22 @@ exit 1). `--info` is a modifier, not a mode: without a name and without | -c / --clean | flag | wipe persistent afm state before launch; run only | | -u / --update | flag | refresh the image before the flat list and the run; no-op in the info forms | -## Continuing existing work +## Continuing or starting work goga pipeline development --topic history-com goga pipeline development -t release-1-3-0 - -Brings the repository onto the branch hosting the requested work — an exact -branch name, an exact topic slug, or their prefix — and then launches the -usual run. The switch completes on the host before any docker activity; a -repeated invocation already on the host continues without switching. The -flat list, overview, and card forms silently ignore -t. An unresolved -identifier or a dirty working tree is a clean error before any launch. + goga pipeline refinement -t prune-history-and-new-status + +Brings the repository onto the requested work — an exact branch name, an +exact topic slug, or their prefix — and then launches the usual run. When +nothing hosts the identifier, fresh work is created instead: the branch +named as entered and the topic directory of the year +(`Created branch <name> and topic <year>/<slug>`). The switch or creation +completes on the host before any docker activity; a repeated invocation +already on the host continues without switching. The flat list, overview, +and card forms silently ignore -t. Several candidates without a terminal, a +dirty working tree on a switch, or an unusable (empty-slug) or occupied +name without a terminal is a clean error before any launch. ## Flag behavior in the list/info forms @@ -76,7 +81,7 @@ The user never authors the docker -p. ## Threading chains goga pipeline NAME → run (full shape) - goga pipeline NAME -t feat/x → switch_topic(feat/x) → run (full shape) + goga pipeline NAME -t feat/x → ensure_topic(feat/x) → run (full shape) goga pipeline --list → minimal shape: list goga pipeline --list --info → minimal shape: list --info goga pipeline NAME --info → minimal shape: run NAME --info [-w WF | --no-workflow] diff --git a/goga/commands/pipeline/CODEMANIFEST b/goga/commands/pipeline/CODEMANIFEST index c6891ed1..a1da62f9 100644 --- a/goga/commands/pipeline/CODEMANIFEST +++ b/goga/commands/pipeline/CODEMANIFEST @@ -1,8 +1,8 @@ Imports: - Types: - - switch_topic + - ensure_topic Usages: - - switching + - ensuring From: goga/topics - Types: - ProjectConfig @@ -114,15 +114,17 @@ Annotations: | probe. The optional -t/--topic flag moves the repository onto the requested work - before the run form launches: the identifier resolves through `switch_topic` - — an exact branch name, an exact topic slug, or their prefix — and the - switch completes before any docker activity. The procedure runs after the - argument-form validation. The listing and info forms silently skip the - whole topic procedure. - - Use the `switching` practice for the consumer patterns of the topics + before the run form launches: the identifier resolves through `ensure_topic` + — an exact branch name, an exact topic slug, or their prefix — and, when + nothing hosts it, fresh work is created: the branch named as entered and + the topic directory of the year. The switch or creation completes before + any docker activity. The procedure runs after the argument-form + validation. The listing and info forms silently skip the whole topic + procedure. + + Use the `ensuring` practice for the consumer patterns of the topics facade used by the topic procedure — the identifier resolution and the - switch orchestration. + switch-or-create orchestration. Use the `git` practice for the git identity read into the container env-file. Use the `click` practice for the -t/--topic option: a long form and a @@ -137,7 +139,8 @@ Annotations: | launches the goga Docker container and invokes the in-container entrypoint inside it; the host never reads pipeline files directly. The optional -t/--topic flag moves the repository onto the requested work - before the run form starts. + before the run form starts — creating it when nothing hosts the + identifier. `ctx`: Click execution context, used to propagate exit codes (per the `click` practice) @@ -152,9 +155,11 @@ Annotations: | and a short alias on a single click option) — a branch name, a topic slug, or their prefix; the year scope is always the current year. Run form only: brings the repository onto the - hosting branch via `switch_topic` before any docker activity. - Silently ignored in the flat list, overview, and card forms — - not an error. + requested work via `ensure_topic` before any docker activity — + switching onto the hosting branch, or creating fresh work (the + branch named as entered and the topic directory of the year) + when nothing hosts the identifier. Silently ignored in the + flat list, overview, and card forms — not an error. `extra_env`: raw KEY=VALUE strings from the repeatable -e/--env option, forwarded into the container env-file in the run form only. `proxy`: optional HTTP/HTTPS proxy URL from the --proxy option; when @@ -210,15 +215,19 @@ Annotations: | a clean error (exit 1) 3. Topic procedure (run form only — `name` given, `list_requested` False, `topic` given): bring the repository onto the requested work - via `switch_topic`. When the procedure switched — or confirmed the - repository already on the host — echo the single result line to - stdout once, immediately after the topic procedure and before the - step-4 dispatch; the forms that skip the procedure print no topic - line. Every git action happens on the host before any docker - activity. An unresolved identifier or a dirty working tree aborts - the command with a non-zero exit before any image refresh, build, or - launch. The flat list, overview, and card forms skip the procedure - silently — passing -t there is not an error and has no effect. + via `ensure_topic` — a switch onto the hosting branch when one + hosts the identifier, or the creation of fresh work (the branch + named as entered and the topic directory of the year) when nothing + does. Echo the single result line to stdout once, immediately after + the topic procedure and before the step-4 dispatch; the forms that + skip the procedure print no topic line. Every git action happens on + the host before any docker activity. Several candidates without an + interactive terminal, a dirty working tree on a switch mutation, or + an unusable (empty-slug) or occupied name without a terminal aborts + the command with a non-zero exit before any image refresh, build, + or launch. The flat list, overview, and card forms skip the + procedure silently — passing -t there is not an error and has no + effect. 4. Dispatch by form: - flat list — `run_pipeline_info_container` with name=None, info=False; `update` applies (image refresh before the listing) @@ -240,8 +249,10 @@ Annotations: | - Register -t/--topic with both forms on a single click Option — both bind the `topic` parameter identically - When the topic procedure ran, echo exactly one stdout line — the - result line of `switch_topic` — before the launch; no topic line in - the flat list, overview, and card forms + result line of the topic procedure (a switch, a branch created from + a remote-tracking ref, the already-on-host confirmation, or the + fresh-work creation) — before the launch; no topic line in the + flat list, overview, and card forms - Every step-2 check runs before any git or docker activity — an argument-form error never switches a branch, refreshes, builds, or launches an image @@ -762,6 +773,6 @@ Description: | the flat list, the overview, the card, and the run — launches the goga Docker container and invokes the in-container pipeline entrypoint inside it; the run form can first bring the repository onto the requested work - (the -t/--topic switch) on the host. The runtime boundary to the - in-container pipeline is docker — this cell has no Python Type Imports - from it. + (the -t/--topic switch-or-create) on the host. The runtime boundary to + the in-container pipeline is docker — this cell has no Python Type + Imports from it. diff --git a/goga/commands/pipeline/pipeline.py b/goga/commands/pipeline/pipeline.py index e3840136..1fbbbfa0 100644 --- a/goga/commands/pipeline/pipeline.py +++ b/goga/commands/pipeline/pipeline.py @@ -6,7 +6,7 @@ import yaml from ...config import load_project_config -from ...topics import switch_topic +from ...topics import ensure_topic from .run_pipeline_container import run_pipeline_container from .run_pipeline_info_container import run_pipeline_info_container @@ -35,8 +35,9 @@ "topic", type=str, default=None, - help="Bring the repository onto the requested work before the run " - "(branch name, topic slug, or prefix; run form only)", + help="Bring the repository onto the requested work before the run, " + "creating it when nothing hosts it (branch name, topic slug, or prefix; " + "run form only)", ) @click.option( "-e", @@ -131,7 +132,8 @@ def pipeline( # noqa: C901, PLR0912, PLR0913, PLR0917 stages in execution order) without running anything. With -t/--topic: bring the repository onto the requested work (a branch - name, a topic slug, or their prefix) before the run. + name, a topic slug, or their prefix) before the run — creating it when + nothing hosts the identifier. All forms launch the goga Docker container and delegate there — the host never reads pipeline files directly. @@ -205,16 +207,17 @@ def pipeline( # noqa: C901, PLR0912, PLR0913, PLR0917 # Step 3 — topic procedure (run form only: `name` given, no --list, no # --info, and -t/--topic given). Every git action happens here on the # host, AFTER every step-2 form check and BEFORE any docker activity — a - # form error or a switching error never refreshes, builds, or launches an + # form error or a topic error never refreshes, builds, or launches an # image. The flat list, overview, and card forms skip the procedure # silently: passing -t there is not an error and has no effect. The single - # result line of `switch_topic` (a switch, a fresh branch created from a - # remote-tracking ref, or the already-on-host confirmation) is echoed to - # stdout exactly once, immediately after the procedure and before the - # dispatch — and never forwarded into a launcher: the container sees the - # branch through the mounted project. + # result line of `ensure_topic` (a switch, a fresh branch created from a + # remote-tracking ref, the already-on-host confirmation, or the creation + # of fresh work — branch plus topic directory — when nothing hosts the + # identifier) is echoed to stdout exactly once, immediately after the + # procedure and before the dispatch — and never forwarded into a launcher: + # the container sees the branch through the mounted project. if topic is not None and name is not None and not list_requested and not info: - line = switch_topic(topic) + line = ensure_topic(topic) click.echo(line) # Step 4 — dispatch. The info forms receive hosts from the config ONLY: diff --git a/goga/topics/.usages/ensuring.md b/goga/topics/.usages/ensuring.md new file mode 100644 index 00000000..2cdbc605 --- /dev/null +++ b/goga/topics/.usages/ensuring.md @@ -0,0 +1,42 @@ +# topics — ensuring work: switch or create + +How to bring the repository onto requested work in one call — switching +when it exists, creating it when nothing hosts it — with the `goga.topics` +facade. For consumers that resume *or* start work through a single +identifier: the pipeline run form (`goga pipeline NAME -t <identifier>`). + +`ensure_topic` resolves the identifier exactly like `switch_topic` — +exact branch name, then exact topic slug (a local branch beats its remote +twin), then prefixes, first non-empty tier wins — and falls back to +`create_topic` with the identifier as the branch name only when **zero +candidates** resolve. A resolvable identifier therefore never creates +anything. + +## Ensuring work + +```python +from goga.topics import ensure_topic + +result = ensure_topic("prune-history-and-new-status") # current year +result = ensure_topic("Feature/Foo_Bar", year="2025") +print(result) # one line — the outcome +``` + +- Nothing hosts the identifier -> fresh work: the branch is created with + the name as entered, the repository switches to it, and the topic + directory of the year is created from its slug — + `Created branch <name> and topic <year>/<slug>`. +- A hosted identifier -> the plain switch outcome: `Switched to branch + <name>`, `Created branch <name> from <remote>/<name>`, or `Already on + branch <name>` (idempotent, nothing touched). +- Several candidates -> the numbered list with statuses and a number + prompt; without interactive input the call fails with the list — + ambiguity never escapes into creation. +- An occupied name (an existing branch, a remote-tracking twin, or the + topic directory of the year) or an empty slug triggers a re-ask on an + interactive terminal, or a clean error with the reason and a hint + otherwise. +- A switch that would mutate probes the working tree first — a dirty tree + is a clean error. The creation fallback carries uncommitted changes + onto the fresh branch instead, exactly like `goga topics create`. +- Mutations are local-only — no network, no fetch, no push. diff --git a/goga/topics/.usages/switching.md b/goga/topics/.usages/switching.md index 91e41b45..f40ce4f4 100644 --- a/goga/topics/.usages/switching.md +++ b/goga/topics/.usages/switching.md @@ -1,8 +1,7 @@ # topics — switching and continuation How to move the repository onto existing work with the `goga.topics` -facade. For consumers that resume work: the topics switch command, the -pipeline run form. +facade. For consumers that resume work: the topics switch command. `switch_topic` resolves the identifier, chooses among candidates, and performs the switch. Resolution tries three tiers in order — exact branch diff --git a/goga/topics/CODEMANIFEST b/goga/topics/CODEMANIFEST index b95bca24..5d342b9b 100644 --- a/goga/topics/CODEMANIFEST +++ b/goga/topics/CODEMANIFEST @@ -54,10 +54,12 @@ Annotations: | This cell owns the topics domain — the work-tracker view of the history tree: the cross-branch topic inventory of one year with per-topic statuses, the switch-identifier resolution and switching orchestration, - and the fresh-work creation procedure. Topic identity, addressing, and - statuses belong to the history facade; git access belongs to the topics - git cell. Mutations are local-only and happen strictly after every - decision is made. Use relative imports. + the fresh-work creation procedure, and the combined ensure orchestration + that switches onto hosted work and creates it when nothing hosts the + identifier. Topic identity, addressing, and statuses belong to the + history facade; git access belongs to the topics git cell. Mutations are + local-only and happen strictly after every decision is made. Use + relative imports. --- @@ -248,6 +250,46 @@ Annotations: | belongs to the pipeline itself - Do not return to the previous branch — the switch is the outcome +"ensure_topic(identifier: str, year: str | None = None) -> result: str": + location: switching.py + annotations: | + Bring the repository onto the requested work, creating it when nothing + hosts the identifier. + + `identifier`: the user input — a branch name, a topic slug, or their + prefix + `year`: optional year as four digits; None means the current year + `result`: one line describing the outcome + + Apply the `click` practice for the interactive moments inherited from + the two orchestrations: the numbered candidate selection and the + creation re-ask cycle with the non-interactive detection. + Apply the `topic-paths` practice for the slug and topic-directory + patterns of the creation fallback. + Apply the `refs-and-switching` practice for the checkout and + create-and-switch patterns. + + Algorithm: + 1. Resolve the candidates via `resolve_switch_candidates` + 2. No candidate -> create fresh work via `create_topic` with + `identifier` as the branch name — the occupancy oracles, the re-ask + cycle, and the idempotent current-branch success belong to it + 3. Otherwise -> the switch procedure: the candidate choice, the + idempotent already-on-host confirmation, the cleanliness probe, and + the local checkout or the remote-tracking branch creation + + Requirements: + - Creation happens only at zero candidates — a resolvable identifier + never creates anything + - The result is exactly one line + - Every mutation is local — no network, no fetch, no push + + Constraints: + - Do not alter the switch-only contract of `switch_topic` — the topics + switch command keeps its stricter behavior + - Do not manage the stages of the hosting pipeline — continuation + belongs to the pipeline itself + "create_topic(branch_name: str, year: str | None = None) -> result: str": location: creation.py annotations: | @@ -333,4 +375,5 @@ Author: Goga CreatedAt: 29/08/26 Description: | The topics domain — the cross-branch topic inventory, switch resolution - and orchestration, and fresh-work creation. + and orchestration, fresh-work creation, and the combined ensure + orchestration that switches onto hosted work or creates it. diff --git a/goga/topics/__init__.py b/goga/topics/__init__.py index c4751f52..d0bbc9a4 100644 --- a/goga/topics/__init__.py +++ b/goga/topics/__init__.py @@ -1,16 +1,22 @@ """Topics domain cell — the work-tracker view of the history tree. The cross-branch topic inventory of one year with per-topic statuses, the -switch-identifier resolution and switching orchestration, and the -fresh-work creation procedure. Topic identity, addressing, and statuses -belong to the history facade; git access belongs to the nested leaf cell -``goga.topics.git``. Mutations are local-only and happen strictly after -every decision is made. +switch-identifier resolution and switching orchestration, the fresh-work +creation procedure, and the combined ensure orchestration that switches +onto hosted work and creates it when nothing hosts the identifier. Topic +identity, addressing, and statuses belong to the history facade; git +access belongs to the nested leaf cell ``goga.topics.git``. Mutations are +local-only and happen strictly after every decision is made. """ from .board import BoardRecord, collect_topic_board from .creation import check_branch_occupancy, create_topic -from .switching import SwitchCandidate, resolve_switch_candidates, switch_topic +from .switching import ( + SwitchCandidate, + ensure_topic, + resolve_switch_candidates, + switch_topic, +) __all__: list[str] = [ "BoardRecord", @@ -18,6 +24,7 @@ "check_branch_occupancy", "collect_topic_board", "create_topic", + "ensure_topic", "resolve_switch_candidates", "switch_topic", ] diff --git a/goga/topics/switching.py b/goga/topics/switching.py index 775b8ab8..ce42866f 100644 --- a/goga/topics/switching.py +++ b/goga/topics/switching.py @@ -3,10 +3,11 @@ The entities declared in the cell CODEMANIFEST with ``location: switching.py``: one candidate of a switch-identifier resolution, the read-only resolver walking the same ref trees as the -board, and the orchestrator that brings the repository onto the chosen -host branch. Topic identity and statuses belong to the history facade; -the bounded git mutations belong to the nested git cell. Git -infrastructure failures and the fatal scale-assembly ``ImportError`` +board, and the orchestrators that bring the repository onto the chosen +host branch — purely by switching, or by creating the fresh work when +nothing hosts the identifier. Topic identity and statuses belong to the +history facade; the bounded git mutations belong to the nested git cell. +Git infrastructure failures and the fatal scale-assembly ``ImportError`` surface as ``click.ClickException`` — the clean-error boundary of the domain; the interactive moments follow the ``click`` practice. """ @@ -27,6 +28,7 @@ resolve_current_branch_name, ) from .board import _current_branch_topic, _short_name, _year_topics_by_ref +from .creation import create_topic from .git import ( BranchRef, checkout_local_branch, @@ -178,6 +180,77 @@ def switch_topic(identifier: str, year: str | None = None) -> str: raise click.ClickException(str(exc)) from exc +def ensure_topic(identifier: str, year: str | None = None) -> str: + """Bring the repository onto the requested work, creating it when nothing + hosts the identifier. + + Args: + identifier: The user input — a branch name, a topic slug, or their + prefix. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + One line describing the outcome — the idempotent success, the + checkout, the branch creation from a remote-tracking ref, or the + fresh-work creation. + + Algorithm: + 1. Resolve the candidates via ``resolve_switch_candidates`` + 2. No candidate -> create fresh work via ``create_topic`` with the + identifier as the branch name — the occupancy oracles, the re-ask + cycle, and the idempotent current-branch success belong to it + 3. Otherwise -> the switch procedure — the candidate choice, the + idempotent confirmation, the cleanliness probe, and the checkout + + Requirements: + Creation happens only at zero candidates — a resolvable identifier + never creates anything. + The result is exactly one line. + Every mutation is local — no network, no fetch, no push. + + Constraints: + Do not alter the switch-only contract of ``switch_topic`` — the + topics switch command keeps its stricter behavior. + Do not manage the stages of the hosting pipeline — continuation + belongs to the pipeline itself. + + Raises: + click.ClickException: several candidates without an interactive + terminal, a dirty working tree on a switch mutation, an unusable + (empty-slug) or occupied name without a terminal, a git + infrastructure failure (its stderr when git reports one, or a + missing git binary), or the fatal ``ImportError`` of the scale + assembly. + click.Abort: Ctrl-C or EOF at a selection or re-ask prompt — the + repository is left untouched. + """ + try: + return _ensure_topic(identifier, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + except ImportError as exc: + raise click.ClickException(str(exc)) from exc + + +def _ensure_topic(identifier: str, year: str | None) -> str: + """Run the traced ensure procedure — the unwrapped orchestration. + + Args: + identifier: The user input as entered. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The single result line of the outcome. + """ + candidates = resolve_switch_candidates(identifier, year) + if not candidates: + return create_topic(identifier, year) + return _switch_to_candidate(candidates) + + def _resolve_switch_candidates( identifier: str, year: str | None ) -> list[SwitchCandidate]: @@ -314,6 +387,24 @@ def _switch_topic(identifier: str, year: str | None) -> str: raise click.ClickException( f"no branch hosts {identifier!r} — run 'goga topics status' to see the board" ) + return _switch_to_candidate(candidates) + + +def _switch_to_candidate(candidates: list[SwitchCandidate]) -> str: + """Take the resolved candidates onto the working copy — the shared switch + tail of ``switch_topic`` and ``ensure_topic``. + + Args: + candidates: The non-empty candidate list of the resolution. + + Returns: + The single result line of the outcome. + + Raises: + click.ClickException: several candidates without a terminal, or a + dirty working tree when a mutation is needed. + click.Abort: Ctrl-C or EOF at the selection prompt. + """ chosen = candidates[0] if len(candidates) == 1 else _choose_candidate(candidates) if chosen.current: return f"Already on branch {chosen.branch}" diff --git a/tests/commands/pipeline/test_pipeline_command.py b/tests/commands/pipeline/test_pipeline_command.py index 2df608c3..d65ab4a9 100644 --- a/tests/commands/pipeline/test_pipeline_command.py +++ b/tests/commands/pipeline/test_pipeline_command.py @@ -597,7 +597,7 @@ def test_pipeline_info_forms_ignore_run_flags(self, tmp_path: Path, monkeypatch: # two container launchers, and the two runtime-dir helpers (declared since the # cell existed, exported since release 1.3.0; the slug transformer and the # current-branch reader belong to goga.history, and the topic procedure -# delegates to goga.topics.switch_topic — neither is re-exported from this +# delegates to goga.topics.ensure_topic — neither is re-exported from this # facade; the former branch routines moved to the topics domain in release # 1.4.0 and are gone from this cell entirely). _PIPELINE_FACADE_ALL = [ @@ -657,7 +657,7 @@ def test_cell_facade_holds_no_branch_machinery(self) -> None: """The retired branch routines are gone from the facade and the package. The branch procedure was replaced by the topic procedure - (-t/--topic via ``switch_topic`` from the topics domain): neither + (-t/--topic via ``ensure_topic`` from the topics domain): neither ``ensure_pipeline_branch`` nor ``check_branch_occupancy`` is defined on the facade, listed in ``__all__``, or importable as a module of this cell. @@ -671,15 +671,15 @@ def test_cell_facade_holds_no_branch_machinery(self) -> None: assert "goga.commands.pipeline.branch" not in sys.modules def test_cell_facade_topic_procedure_imports_from_topics_domain(self) -> None: - """The command module binds ``switch_topic`` from the topics facade. + """The command module binds ``ensure_topic`` from the topics facade. The single identity the topic procedure runs through — the ``from - ...topics import switch_topic`` import-point the command's own + ...topics import ensure_topic`` import-point the command's own dispatch relies on. """ - from goga.topics import switch_topic as from_domain + from goga.topics import ensure_topic as from_domain - assert _pipeline_module.switch_topic is from_domain + assert _pipeline_module.ensure_topic is from_domain def test_commands_facade_info_launcher_is_importable_by_name(self) -> None: """The consumer form ``from goga.commands.pipeline import run_pipeline_info_container`` works.""" diff --git a/tests/commands/pipeline/test_pipeline_dispatch.py b/tests/commands/pipeline/test_pipeline_dispatch.py index ed6ac531..2ebff983 100644 --- a/tests/commands/pipeline/test_pipeline_dispatch.py +++ b/tests/commands/pipeline/test_pipeline_dispatch.py @@ -8,8 +8,9 @@ - the ``-t/--topic`` option (run form only): one Option with both forms binding the ``topic`` parameter; the guarded topic procedure runs after the step-2 validation and before any docker activity, echoes exactly one - stdout line — the result line of ``switch_topic`` — and never forwards - the topic into a launcher + stdout line — the result line of ``ensure_topic`` (a switch, or the + creation of fresh work when nothing hosts the identifier) — and never + forwards the topic into a launcher - proxy resolution: ``--proxy`` wins over ``config.pipeline.proxy`` - hosts resolution: ``--add-host`` entries merge on top of ``config.pipeline.hosts`` (CLI overrides config on key conflict) @@ -22,7 +23,7 @@ focused on the click surface and the host-side resolution logic, with no docker dependency. -The integration block at the bottom drives the REAL ``switch_topic`` from the +The integration block at the bottom drives the REAL ``ensure_topic`` from the topics domain through the real command surface, mocking the domain's git boundary at its import points (the same wiring the topics cell tests use), to verify the wiring the unit tests mock away. @@ -44,6 +45,7 @@ from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig from goga.history import current_year from goga.topics import board as topics_board +from goga.topics import creation as topics_creation from goga.topics import switching as topics_switching from goga.topics.git import BranchRef @@ -130,7 +132,7 @@ def test_pipeline_topic_option_contract_both_forms_one_option(self) -> None: defaults to None, and is a plain string option (click renders the declared ``type=str`` as its canonical STRING param type). The callback declares ``topic: str | None`` directly after ``info`` (contract - order), and ``--topic x NAME`` / ``-t x NAME`` reach ``switch_topic`` + order), and ``--topic x NAME`` / ``-t x NAME`` reach ``ensure_topic`` with the same value. """ topic_param = next(p for p in pipeline.params if p.name == "topic") @@ -149,13 +151,13 @@ def test_pipeline_topic_option_contract_both_forms_one_option(self) -> None: for argv in (["--topic", "x", "my-pipeline"], ["-t", "x", "my-pipeline"]): with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), - mock.patch.object(_pipeline_module, "switch_topic", return_value="Switched to branch x") as mock_switch, + mock.patch.object(_pipeline_module, "ensure_topic", return_value="Switched to branch x") as mock_ensure, mock.patch.object(_pipeline_module, "run_pipeline_container", return_value=0), ): result = runner.invoke(pipeline, argv) assert result.exit_code == 0 - mock_switch.assert_called_once_with("x") + mock_ensure.assert_called_once_with("x") # --- Logic tests (positive) --- @@ -312,28 +314,28 @@ def test_pipeline_topic_option_switches_before_docker(self) -> None: """The topic procedure runs, echoes its one result line, then the container launches.""" config = _make_config() switch_line = "Switched to branch feat/x" - mock_switch = mock.Mock(return_value=switch_line) + mock_ensure = mock.Mock(return_value=switch_line) mock_run = mock.Mock(return_value=0) order = mock.Mock() - order.attach_mock(mock_switch, "switch_topic") + order.attach_mock(mock_ensure, "ensure_topic") order.attach_mock(mock_run, "run_container") runner = CliRunner() with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), - mock.patch.object(_pipeline_module, "switch_topic", mock_switch), + mock.patch.object(_pipeline_module, "ensure_topic", mock_ensure), mock.patch.object(_pipeline_module, "run_pipeline_container", mock_run), ): result = runner.invoke(pipeline, ["-t", "feat/x", "development"]) assert result.exit_code == 0 - # Exactly one topic line on stdout, verbatim from switch_topic. + # Exactly one topic line on stdout, verbatim from ensure_topic. assert result.stdout.count(switch_line) == 1 - mock_switch.assert_called_once_with("feat/x") + mock_ensure.assert_called_once_with("feat/x") mock_run.assert_called_once() assert mock_run.call_args.kwargs["name"] == "development" - # The switch precedes the docker activity, and the topic identifier - # never crosses the docker boundary. - assert order.method_calls[0] == mock.call.switch_topic("feat/x") + # The topic procedure precedes the docker activity, and the topic + # identifier never crosses the docker boundary. + assert order.method_calls[0] == mock.call.ensure_topic("feat/x") assert order.method_calls[1][0] == "run_container" assert "topic" not in mock_run.call_args.kwargs assert "feat/x" not in mock_run.call_args.kwargs.values() @@ -353,13 +355,13 @@ def test_pipeline_topic_ignored_in_list_and_info_forms(self, argv: list[str]) -> runner = CliRunner() with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), - mock.patch.object(_pipeline_module, "switch_topic") as mock_switch, + mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure, mock.patch.object(_pipeline_module, "run_pipeline_info_container", return_value=0) as mock_info, ): result = runner.invoke(pipeline, argv) assert result.exit_code == 0 - mock_switch.assert_not_called() + mock_ensure.assert_not_called() mock_info.assert_called_once() assert "Switched to branch" not in result.stdout @@ -369,14 +371,14 @@ def test_pipeline_missing_name_with_topic_no_switch(self) -> None: runner = CliRunner() with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), - mock.patch.object(_pipeline_module, "switch_topic") as mock_switch, + mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure, mock.patch.object(_pipeline_module, "run_pipeline_container") as mock_run, ): result = runner.invoke(pipeline, ["-t", "x"]) assert result.exit_code == 1 assert "Missing pipeline name" in result.output - mock_switch.assert_not_called() + mock_ensure.assert_not_called() mock_run.assert_not_called() def test_pipeline_has_no_branch_option(self) -> None: @@ -385,12 +387,12 @@ def test_pipeline_has_no_branch_option(self) -> None: runner = CliRunner() with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), - mock.patch.object(_pipeline_module, "switch_topic") as mock_switch, + mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure, ): result = runner.invoke(pipeline, ["-b", "x", "dev"]) assert result.exit_code != 0 - mock_switch.assert_not_called() + mock_ensure.assert_not_called() help_result = runner.invoke(pipeline, ["--help"]) assert help_result.exit_code == 0 @@ -415,18 +417,21 @@ def _wire_topic_domain( inventory: list[BranchRef], trees: dict[str, list[str]], current: str | None, -) -> tuple[mock.Mock, mock.Mock, mock.Mock]: - """Wire the REAL ``switch_topic`` to a canned git boundary. +) -> tuple[mock.Mock, mock.Mock, mock.Mock, mock.Mock]: + """Wire the REAL ``ensure_topic`` to a canned git boundary. The resolution reads the scale, the ref inventory, the ref trees, and the current branch at their import points inside the topics domain (the same points the domain's own tests patch); the mutations are recording mocks. - Only the topics facade stays real — exactly the wiring ``pipeline`` relies - on through ``from ...topics import switch_topic``. + The creation fallback of ``ensure_topic`` runs the REAL ``create_topic``, + so the creation module's git boundary is wired the same way. Only the + topics facade stays real — exactly the wiring ``pipeline`` relies on + through ``from ...topics import ensure_topic``. Returns: - The cleanliness probe, the local checkout, and the remote-tracking - branch creation — all as recording mocks. + The cleanliness probe, the local checkout, the remote-tracking branch + creation, and the create-and-switch mutation of the creation fallback + — all as recording mocks. """ monkeypatch.setattr(topics_switching, "assemble_status_scale", _builtin_scale) monkeypatch.setattr(topics_switching, "list_branch_refs", lambda: inventory) @@ -435,11 +440,16 @@ def _wire_topic_domain( cleanliness = mock.Mock(return_value=True) checkout = mock.Mock() - creation = mock.Mock() + remote_creation = mock.Mock() monkeypatch.setattr(topics_switching, "is_working_tree_clean", cleanliness) monkeypatch.setattr(topics_switching, "checkout_local_branch", checkout) - monkeypatch.setattr(topics_switching, "create_branch_from_remote_tracking", creation) - return cleanliness, checkout, creation + monkeypatch.setattr(topics_switching, "create_branch_from_remote_tracking", remote_creation) + + monkeypatch.setattr(topics_creation, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(topics_creation, "resolve_current_branch_name", lambda: current) + create_and_switch = mock.Mock() + monkeypatch.setattr(topics_creation, "create_and_switch_branch", create_and_switch) + return cleanliness, checkout, remote_creation, create_and_switch def _builtin_scale(): @@ -461,7 +471,7 @@ def _builtin_scale(): class TestPipelineTopicIntegration: - """Cross-entity: the real ``switch_topic`` from the topics domain through the real command. + """Cross-entity: the real ``ensure_topic`` from the topics domain through the real command. Only the domain's git boundary is canned, so these tests verify the wiring the unit tests mock away: the ``from ...topics import`` path, the run-form @@ -478,7 +488,7 @@ def test_pipeline_topic_flow_switches_and_launches(self, tmp_path: Path, monkeyp BranchRef(name="feat/a", remote=False), ] trees = {"feat/a": [f".goga/history/{year}/feat-a/plan.md"]} - _cleanliness, checkout, _creation = _wire_topic_domain(monkeypatch, inventory, trees, "main") + _cleanliness, checkout, _remote, _fresh = _wire_topic_domain(monkeypatch, inventory, trees, "main") config = _make_config() runner = CliRunner() @@ -505,7 +515,9 @@ def test_pipeline_topic_idempotent_host_skips_git_mutations( year = current_year() inventory = [BranchRef(name="feat/a", remote=False)] trees = {"feat/a": [f".goga/history/{year}/feat-a/plan.md"]} - cleanliness, checkout, creation = _wire_topic_domain(monkeypatch, inventory, trees, "feat/a") + cleanliness, checkout, remote_creation, fresh_creation = _wire_topic_domain( + monkeypatch, inventory, trees, "feat/a" + ) config = _make_config() runner = CliRunner() @@ -519,33 +531,39 @@ def test_pipeline_topic_idempotent_host_skips_git_mutations( assert "Already on branch feat/a" in result.stdout cleanliness.assert_not_called() checkout.assert_not_called() - creation.assert_not_called() + remote_creation.assert_not_called() + fresh_creation.assert_not_called() assert mock_run.call_count == 1 - def test_pipeline_topic_unresolved_identifier_aborts_before_docker( + def test_pipeline_topic_unresolved_identifier_creates_and_launches( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """An unresolved identifier: clean failure on stderr, exit 1, nothing launches.""" + """An identifier nothing hosts: fresh work is created — the branch as + entered, the topic directory of the year — the creation line prints, + and the launcher runs topic-free.""" monkeypatch.chdir(tmp_path) year = current_year() inventory = [BranchRef(name="main", remote=False)] trees = {"main": [f".goga/history/{year}/other/prd.md"]} - cleanliness, checkout, _creation = _wire_topic_domain(monkeypatch, inventory, trees, "main") + _cleanliness, checkout, _remote, fresh_creation = _wire_topic_domain( + monkeypatch, inventory, trees, "main" + ) config = _make_config() runner = CliRunner() with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), - mock.patch.object(_pipeline_module, "run_pipeline_container") as mock_run, + mock.patch.object(_pipeline_module, "run_pipeline_container", return_value=0) as mock_run, ): result = runner.invoke(pipeline, ["-t", "nope", "my-pipeline"]) - assert result.exit_code == 1 - assert "no branch hosts 'nope'" in result.stderr - assert "goga topics status" in result.stderr - cleanliness.assert_not_called() + assert result.exit_code == 0 + assert f"Created branch nope and topic {year}/nope" in result.stdout + fresh_creation.assert_called_once_with("nope") checkout.assert_not_called() - mock_run.assert_not_called() + assert (tmp_path / ".goga" / "history" / year / "nope").is_dir() + assert mock_run.call_count == 1 + assert "topic" not in mock_run.call_args.kwargs class TestPipelineCallbackSignature: diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index 34e5281c..0c646f37 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -85,6 +85,7 @@ def test_entities_are_importable_from_the_cell_facade(self) -> None: "check_branch_occupancy", "collect_topic_board", "create_topic", + "ensure_topic", "resolve_switch_candidates", "switch_topic", } diff --git a/tests/topics/test_switching.py b/tests/topics/test_switching.py index 7ddf533b..d846c02a 100644 --- a/tests/topics/test_switching.py +++ b/tests/topics/test_switching.py @@ -5,12 +5,16 @@ candidate of a switch-identifier resolution - ``resolve_switch_candidates(identifier, year)`` — the read-only resolution - ``switch_topic(identifier, year)`` — the switching orchestration +- ``ensure_topic(identifier, year)`` — the switch-or-create orchestration The git boundary is mocked at the import point per the ``convention`` practice — no git binary and no repository are touched. The ref-tree helper shared with the board is mocked at its owner (``goga.topics.board``); the working-copy scenarios use ``tmp_path`` + ``monkeypatch.chdir`` with the real history path routines, and the scale is the ``builtin_scale`` fixture. +The creation fallback of ``ensure_topic`` runs the REAL ``create_topic`` +with its git boundary patched at ``goga.topics.creation``'s import points — +the same wiring the creation tests use. """ from __future__ import annotations @@ -30,6 +34,8 @@ from goga.topics import ( SwitchCandidate, board, + creation, + ensure_topic, resolve_switch_candidates, switch_topic, switching, @@ -72,11 +78,34 @@ def _wire_mutations(monkeypatch: pytest.MonkeyPatch, clean: bool = True) -> tupl """ cleanliness = mock.Mock(return_value=clean) checkout = mock.Mock() - creation = mock.Mock() + remote_creation = mock.Mock() monkeypatch.setattr(switching, "is_working_tree_clean", cleanliness) monkeypatch.setattr(switching, "checkout_local_branch", checkout) - monkeypatch.setattr(switching, "create_branch_from_remote_tracking", creation) - return cleanliness, checkout, creation + monkeypatch.setattr(switching, "create_branch_from_remote_tracking", remote_creation) + return cleanliness, checkout, remote_creation + + +def _wire_creation_boundary( + monkeypatch: pytest.MonkeyPatch, + inventory: list[BranchRef], + current: str | None, +) -> mock.Mock: + """Patch the creation fallback's import points inside ``goga.topics.creation``. + + Returns: + The create-and-switch mutation as a recording mock — the only git + mutation of the fallback. + """ + monkeypatch.setattr(creation, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(creation, "resolve_current_branch_name", lambda: current) + create_and_switch = mock.Mock() + monkeypatch.setattr(creation, "create_and_switch_branch", create_and_switch) + return create_and_switch + + +def _non_interactive(monkeypatch: pytest.MonkeyPatch) -> None: + """Make stdin a non-terminal — the re-ask path must abort cleanly.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) def _working_copy_topic(cwd: Path, year: str, slug: str, artifacts: list[str]) -> None: @@ -108,13 +137,14 @@ def _twin_trees() -> dict[str, list[str]]: class TestSwitchingContract: def test_entities_are_importable_from_the_cell_facade(self) -> None: - """``SwitchCandidate`` and both routines live on the cell facade.""" + """``SwitchCandidate`` and the three routines live on the cell facade.""" import goga.topics as cell assert cell.SwitchCandidate is SwitchCandidate assert cell.resolve_switch_candidates is resolve_switch_candidates assert cell.switch_topic is switch_topic - for name in ("SwitchCandidate", "resolve_switch_candidates", "switch_topic"): + assert cell.ensure_topic is ensure_topic + for name in ("SwitchCandidate", "resolve_switch_candidates", "switch_topic", "ensure_topic"): assert name in cell.__all__ def test_switch_candidate_is_a_frozen_kw_only_dataclass(self) -> None: @@ -166,6 +196,17 @@ def test_switch_topic_signature(self) -> None: hints = typing.get_type_hints(switch_topic) assert hints == {"identifier": str, "year": str | None, "return": str} + def test_ensure_topic_signature(self) -> None: + """``ensure_topic(identifier, year=None) -> str``.""" + signature = inspect.signature(ensure_topic) + assert list(signature.parameters) == ["identifier", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(ensure_topic) + assert hints == {"identifier": str, "year": str | None, "return": str} + # --- Logic tests: resolution --- @@ -598,6 +639,156 @@ def test_switch_topic_non_interactive_multiple_candidates_fails_with_list( creation.assert_not_called() +class TestEnsureTopic: + def test_ensure_topic_zero_candidates_creates_fresh_work( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Nothing hosts the identifier: the fallback creates the branch as + entered and the topic directory of the year — the creation line.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": [".goga/history/2026/other/prd.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _wire_mutations(monkeypatch, clean=True) + create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") + + result = ensure_topic("prune-history-and-new-status", "2026") + + assert result == "Created branch prune-history-and-new-status and topic 2026/prune-history-and-new-status" + create_and_switch.assert_called_once_with("prune-history-and-new-status") + assert (tmp_path / ".goga" / "history" / "2026" / "prune-history-and-new-status").is_dir() + + def test_ensure_topic_remote_tracking_twin_is_occupied( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A remote-tracking twin of the name occupies it: clean error with + the board hint, nothing created.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="origin/new-work", remote=True), + ] + trees = {"main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _wire_mutations(monkeypatch, clean=True) + create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") + _non_interactive(monkeypatch) + + with pytest.raises(click.ClickException) as raised: + ensure_topic("new-work", "2026") + + assert raised.value.message == ( + "remote-tracking branch 'new-work' already exists — run 'goga topics status' to see the board" + ) + create_and_switch.assert_not_called() + + def test_ensure_topic_single_candidate_switches_without_creation( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A hosted identifier takes the plain switch — the fallback never runs.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _cleanliness, checkout, _remote_creation = _wire_mutations(monkeypatch, clean=True) + create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") + + result = ensure_topic("feat/a", "2026") + + assert result == "Switched to branch feat/a" + checkout.assert_called_once_with("feat/a") + create_and_switch.assert_not_called() + + def test_ensure_topic_idempotent_when_already_on_host( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Already on the hosting branch: idempotent success, no probe, no + mutation — creation included.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + _wire_resolution(monkeypatch, builtin_scale, _twin_inventory(), _twin_trees(), "feat/a") + cleanliness, checkout, _remote_creation = _wire_mutations(monkeypatch, clean=True) + create_and_switch = _wire_creation_boundary(monkeypatch, _twin_inventory(), "feat/a") + + result = ensure_topic("feat/a") + + assert result == "Already on branch feat/a" + cleanliness.assert_not_called() + checkout.assert_not_called() + create_and_switch.assert_not_called() + + def test_ensure_topic_multiple_candidates_fail_with_list_not_creation( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Several candidates without a terminal fail with the numbered list — + ambiguity never escapes into creation.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="feat/ab", remote=False), + BranchRef(name="main", remote=False), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "feat/ab": [".goga/history/2026/feat-ab/plan.md"], + "main": ["README.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + cleanliness, checkout, _remote_creation = _wire_mutations(monkeypatch, clean=True) + create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") + _non_interactive(monkeypatch) + + with pytest.raises(click.ClickException) as raised: + ensure_topic("feat", "2026") + + assert "1)" in raised.value.message + assert "2)" in raised.value.message + cleanliness.assert_not_called() + checkout.assert_not_called() + create_and_switch.assert_not_called() + + def test_ensure_topic_empty_slug_identifier_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An identifier normalizing to an empty slug is a clean error — no + branch, no topic directory.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _wire_mutations(monkeypatch, clean=True) + create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") + _non_interactive(monkeypatch) + + with pytest.raises(click.ClickException) as raised: + ensure_topic("БББ", "2026") + + assert raised.value.message == "branch name 'БББ' normalizes to an empty topic slug" + create_and_switch.assert_not_called() + assert not (tmp_path / ".goga" / "history" / "2026").exists() + + # --- Infrastructure boundary --- From 51f4ad7b9d1b89762d09568accd7e2bad324fe3a Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sat, 29 Aug 2026 20:59:32 +0000 Subject: [PATCH 100/229] refactor: isolate ensuring domain and remove cross-cell usage references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acceptance of 3cd3bb8 found the ensuring domain hosted by the switching module (ensure_topic in switching.py, manifest location included), which mixed the switch-only responsibility with the switch-or-create orchestration, and usage files that referenced other cells — the pipeline run form and 'goga topics create' in ensuring.md, the topics switch and create commands in switching.md/creating.md, and goga/pipeline, ensure_topic, DockerRunner and the pipeline_cli chain in pipeline-command.md — breaking practice self-containment. Move ensure_topic with its unwrapped procedure to the new goga/topics/ensuring.py (behavior and the cell facade unchanged; the manifest location follows), mirror the tests in tests/topics/test_ensuring.py, and strip every cross-cell reference from the usage files of both cells. argv tokens fixed by the CODEMANIFEST contract (python -m goga.pipeline) are kept as literal docker values. Gates: pytest tests/ 4753 passed, ruff clean, goga lint 0 errors, goga contract consistent. --- .../pipeline/.usages/pipeline-command.md | 11 +- goga/topics/.usages/creating.md | 2 +- goga/topics/.usages/ensuring.md | 4 +- goga/topics/.usages/switching.md | 2 +- goga/topics/CODEMANIFEST | 2 +- goga/topics/__init__.py | 2 +- goga/topics/ensuring.py | 93 ++++++ goga/topics/switching.py | 81 +---- tests/topics/test_ensuring.py | 294 ++++++++++++++++++ tests/topics/test_switching.py | 190 +---------- 10 files changed, 405 insertions(+), 276 deletions(-) create mode 100644 goga/topics/ensuring.py create mode 100644 tests/topics/test_ensuring.py diff --git a/goga/commands/pipeline/.usages/pipeline-command.md b/goga/commands/pipeline/.usages/pipeline-command.md index aa9bde9b..35b4e714 100644 --- a/goga/commands/pipeline/.usages/pipeline-command.md +++ b/goga/commands/pipeline/.usages/pipeline-command.md @@ -3,7 +3,7 @@ `goga pipeline` is a single Click command with five explicit forms. Every form launches the goga Docker container and invokes python -m goga.pipeline inside it. The host never reads pipeline files directly — the runtime -boundary to goga/pipeline is docker. +boundary to the in-container pipeline is docker. ## Forms @@ -73,22 +73,21 @@ name without a terminal is a clean error before any launch. ## -p vs docker -p The user-facing -p/--parallel is a Click option. The Docker port-publish --p <port>:<port> is an internal translated docker token assembled by -run_pipeline_container/DockerRunner from the allocated port (run form +-p <port>:<port> is an internal translated docker token assembled by the +run launcher from the allocated port (run form only). Different namespaces (Click CLI vs docker run argv) — no collision. The user never authors the docker -p. ## Threading chains goga pipeline NAME → run (full shape) - goga pipeline NAME -t feat/x → ensure_topic(feat/x) → run (full shape) + goga pipeline NAME -t feat/x → switch-or-create → run (full shape) goga pipeline --list → minimal shape: list goga pipeline --list --info → minimal shape: list --info goga pipeline NAME --info → minimal shape: run NAME --info [-w WF | --no-workflow] goga pipeline NAME -p N → docker run … -m goga.pipeline run NAME --port PORT --parallel N - → pipeline_cli → run_pipeline(parallel=N) → run_flow(max_parallel=N) - → afm run --port PORT --max-parallel N <flow> + → the in-container run launches afm bounded to N concurrent stages Absent ⇒ parallel=None ⇒ no in-container --parallel ⇒ afm unbounded. diff --git a/goga/topics/.usages/creating.md b/goga/topics/.usages/creating.md index 5c7fbb20..186ee091 100644 --- a/goga/topics/.usages/creating.md +++ b/goga/topics/.usages/creating.md @@ -1,7 +1,7 @@ # topics — creating fresh work How to create a new branch with its topic directory using the `goga.topics` -facade. For consumers that start new work: the topics create command. +facade. For consumers that start new work. `create_topic` takes the branch name as entered. The branch keeps the name verbatim; the topic directory takes the normalized slug of the year — the diff --git a/goga/topics/.usages/ensuring.md b/goga/topics/.usages/ensuring.md index 2cdbc605..96b04652 100644 --- a/goga/topics/.usages/ensuring.md +++ b/goga/topics/.usages/ensuring.md @@ -3,7 +3,7 @@ How to bring the repository onto requested work in one call — switching when it exists, creating it when nothing hosts it — with the `goga.topics` facade. For consumers that resume *or* start work through a single -identifier: the pipeline run form (`goga pipeline NAME -t <identifier>`). +identifier. `ensure_topic` resolves the identifier exactly like `switch_topic` — exact branch name, then exact topic slug (a local branch beats its remote @@ -38,5 +38,5 @@ print(result) # one line — the outcome otherwise. - A switch that would mutate probes the working tree first — a dirty tree is a clean error. The creation fallback carries uncommitted changes - onto the fresh branch instead, exactly like `goga topics create`. + onto the fresh branch instead. - Mutations are local-only — no network, no fetch, no push. diff --git a/goga/topics/.usages/switching.md b/goga/topics/.usages/switching.md index f40ce4f4..33ca0b0b 100644 --- a/goga/topics/.usages/switching.md +++ b/goga/topics/.usages/switching.md @@ -1,7 +1,7 @@ # topics — switching and continuation How to move the repository onto existing work with the `goga.topics` -facade. For consumers that resume work: the topics switch command. +facade. For consumers that resume work. `switch_topic` resolves the identifier, chooses among candidates, and performs the switch. Resolution tries three tiers in order — exact branch diff --git a/goga/topics/CODEMANIFEST b/goga/topics/CODEMANIFEST index 5d342b9b..012c0297 100644 --- a/goga/topics/CODEMANIFEST +++ b/goga/topics/CODEMANIFEST @@ -251,7 +251,7 @@ Annotations: | - Do not return to the previous branch — the switch is the outcome "ensure_topic(identifier: str, year: str | None = None) -> result: str": - location: switching.py + location: ensuring.py annotations: | Bring the repository onto the requested work, creating it when nothing hosts the identifier. diff --git a/goga/topics/__init__.py b/goga/topics/__init__.py index d0bbc9a4..2b950376 100644 --- a/goga/topics/__init__.py +++ b/goga/topics/__init__.py @@ -11,9 +11,9 @@ from .board import BoardRecord, collect_topic_board from .creation import check_branch_occupancy, create_topic +from .ensuring import ensure_topic from .switching import ( SwitchCandidate, - ensure_topic, resolve_switch_candidates, switch_topic, ) diff --git a/goga/topics/ensuring.py b/goga/topics/ensuring.py new file mode 100644 index 00000000..ebd6ff71 --- /dev/null +++ b/goga/topics/ensuring.py @@ -0,0 +1,93 @@ +"""The ensure orchestration of the topics domain. + +The entity declared in the cell CODEMANIFEST with +``location: ensuring.py``: the combined orchestrator that brings the +repository onto the requested work — by switching when a branch hosts the +identifier, by creating the fresh work when nothing does. The resolution +and the switch tail belong to the switching module; the creation fallback +belongs to the creation module. Topic identity and statuses belong to the +history facade; the bounded git mutations belong to the nested git cell. +Git infrastructure failures and the fatal scale-assembly ``ImportError`` +surface as ``click.ClickException`` — the clean-error boundary of the +domain; the interactive moments follow the ``click`` practice. +""" + +from __future__ import annotations + +import subprocess + +import click + +from .creation import create_topic +from .switching import _switch_to_candidate, resolve_switch_candidates + + +def ensure_topic(identifier: str, year: str | None = None) -> str: + """Bring the repository onto the requested work, creating it when nothing + hosts the identifier. + + Args: + identifier: The user input — a branch name, a topic slug, or their + prefix. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + One line describing the outcome — the idempotent success, the + checkout, the branch creation from a remote-tracking ref, or the + fresh-work creation. + + Algorithm: + 1. Resolve the candidates via ``resolve_switch_candidates`` + 2. No candidate -> create fresh work via ``create_topic`` with the + identifier as the branch name — the occupancy oracles, the re-ask + cycle, and the idempotent current-branch success belong to it + 3. Otherwise -> the switch procedure — the candidate choice, the + idempotent confirmation, the cleanliness probe, and the checkout + + Requirements: + Creation happens only at zero candidates — a resolvable identifier + never creates anything. + The result is exactly one line. + Every mutation is local — no network, no fetch, no push. + + Constraints: + Do not alter the switch-only contract of ``switch_topic`` — the + topics switch command keeps its stricter behavior. + Do not manage the stages of the hosting pipeline — continuation + belongs to the pipeline itself. + + Raises: + click.ClickException: several candidates without an interactive + terminal, a dirty working tree on a switch mutation, an unusable + (empty-slug) or occupied name without a terminal, a git + infrastructure failure (its stderr when git reports one, or a + missing git binary), or the fatal ``ImportError`` of the scale + assembly. + click.Abort: Ctrl-C or EOF at a selection or re-ask prompt — the + repository is left untouched. + """ + try: + return _ensure_topic(identifier, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + except ImportError as exc: + raise click.ClickException(str(exc)) from exc + + +def _ensure_topic(identifier: str, year: str | None) -> str: + """Run the traced ensure procedure — the unwrapped orchestration. + + Args: + identifier: The user input as entered. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The single result line of the outcome. + """ + candidates = resolve_switch_candidates(identifier, year) + if not candidates: + return create_topic(identifier, year) + return _switch_to_candidate(candidates) diff --git a/goga/topics/switching.py b/goga/topics/switching.py index ce42866f..e6b83e88 100644 --- a/goga/topics/switching.py +++ b/goga/topics/switching.py @@ -3,9 +3,9 @@ The entities declared in the cell CODEMANIFEST with ``location: switching.py``: one candidate of a switch-identifier resolution, the read-only resolver walking the same ref trees as the -board, and the orchestrators that bring the repository onto the chosen -host branch — purely by switching, or by creating the fresh work when -nothing hosts the identifier. Topic identity and statuses belong to the +board, and the orchestrator that brings the repository onto the chosen +host branch by purely switching — the shared switch tail also serves the +ensure orchestration of ``ensuring.py``. Topic identity and statuses belong to the history facade; the bounded git mutations belong to the nested git cell. Git infrastructure failures and the fatal scale-assembly ``ImportError`` surface as ``click.ClickException`` — the clean-error boundary of the @@ -28,7 +28,6 @@ resolve_current_branch_name, ) from .board import _current_branch_topic, _short_name, _year_topics_by_ref -from .creation import create_topic from .git import ( BranchRef, checkout_local_branch, @@ -180,77 +179,6 @@ def switch_topic(identifier: str, year: str | None = None) -> str: raise click.ClickException(str(exc)) from exc -def ensure_topic(identifier: str, year: str | None = None) -> str: - """Bring the repository onto the requested work, creating it when nothing - hosts the identifier. - - Args: - identifier: The user input — a branch name, a topic slug, or their - prefix. - year: Optional year as four digits; ``None`` means the current year. - - Returns: - One line describing the outcome — the idempotent success, the - checkout, the branch creation from a remote-tracking ref, or the - fresh-work creation. - - Algorithm: - 1. Resolve the candidates via ``resolve_switch_candidates`` - 2. No candidate -> create fresh work via ``create_topic`` with the - identifier as the branch name — the occupancy oracles, the re-ask - cycle, and the idempotent current-branch success belong to it - 3. Otherwise -> the switch procedure — the candidate choice, the - idempotent confirmation, the cleanliness probe, and the checkout - - Requirements: - Creation happens only at zero candidates — a resolvable identifier - never creates anything. - The result is exactly one line. - Every mutation is local — no network, no fetch, no push. - - Constraints: - Do not alter the switch-only contract of ``switch_topic`` — the - topics switch command keeps its stricter behavior. - Do not manage the stages of the hosting pipeline — continuation - belongs to the pipeline itself. - - Raises: - click.ClickException: several candidates without an interactive - terminal, a dirty working tree on a switch mutation, an unusable - (empty-slug) or occupied name without a terminal, a git - infrastructure failure (its stderr when git reports one, or a - missing git binary), or the fatal ``ImportError`` of the scale - assembly. - click.Abort: Ctrl-C or EOF at a selection or re-ask prompt — the - repository is left untouched. - """ - try: - return _ensure_topic(identifier, year) - except subprocess.CalledProcessError as exc: - detail = (exc.stderr or "").strip() or str(exc) - raise click.ClickException(f"git failed: {detail}") from exc - except FileNotFoundError as exc: - raise click.ClickException(f"git is not available: {exc}") from exc - except ImportError as exc: - raise click.ClickException(str(exc)) from exc - - -def _ensure_topic(identifier: str, year: str | None) -> str: - """Run the traced ensure procedure — the unwrapped orchestration. - - Args: - identifier: The user input as entered. - year: Optional year as four digits; ``None`` means the current year. - - Returns: - The single result line of the outcome. - """ - candidates = resolve_switch_candidates(identifier, year) - if not candidates: - return create_topic(identifier, year) - return _switch_to_candidate(candidates) - - def _resolve_switch_candidates( identifier: str, year: str | None ) -> list[SwitchCandidate]: @@ -392,7 +320,8 @@ def _switch_topic(identifier: str, year: str | None) -> str: def _switch_to_candidate(candidates: list[SwitchCandidate]) -> str: """Take the resolved candidates onto the working copy — the shared switch - tail of ``switch_topic`` and ``ensure_topic``. + tail of ``switch_topic`` and the ensure orchestration of + ``ensuring.py``. Args: candidates: The non-empty candidate list of the resolution. diff --git a/tests/topics/test_ensuring.py b/tests/topics/test_ensuring.py new file mode 100644 index 00000000..2dfcf3fd --- /dev/null +++ b/tests/topics/test_ensuring.py @@ -0,0 +1,294 @@ +"""Contract and logic tests for the entity declared in +``goga/topics/CODEMANIFEST`` with ``location: ensuring.py``: + +- ``ensure_topic(identifier, year)`` — the switch-or-create orchestration + +The git boundary is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched. The ref-tree +helper shared with the board is mocked at its owner (``goga.topics.board``); +the working-copy scenarios use ``tmp_path`` + ``monkeypatch.chdir`` with the +real history path routines, and the scale is the ``builtin_scale`` fixture. +The creation fallback of ``ensure_topic`` runs the REAL ``create_topic`` +with its git boundary patched at ``goga.topics.creation``'s import points — +the same wiring the creation tests use. +""" + +from __future__ import annotations + +import inspect +import sys +import typing +from collections.abc import Callable +from pathlib import Path +from unittest import mock + +import click +import pytest +from goga.history.statuses import StatusScale +from goga.topics import board, creation, ensure_topic, switching +from goga.topics.git import BranchRef + +# --- Shared scenario helpers --- + + +def _trees_reader(trees: dict[str, list[str]]) -> Callable[..., list[str]]: + """A ``read_ref_tree_paths`` stand-in answering by ref display name.""" + + def read(ref: str, prefix: str) -> list[str]: + assert prefix == ".goga/history/", "the resolution reads under the history root only" + return [path for path in trees.get(ref, []) if path.startswith(prefix)] + + return read + + +def _wire_resolution( + monkeypatch: pytest.MonkeyPatch, + scale: StatusScale, + inventory: list[BranchRef], + trees: dict[str, list[str]], + current: str | None, +) -> None: + """Patch the resolution's import points: scale, git inventory, trees, branch.""" + monkeypatch.setattr(switching, "assemble_status_scale", lambda: scale) + monkeypatch.setattr(switching, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(switching, "resolve_current_branch_name", lambda: current) + monkeypatch.setattr(board, "read_ref_tree_paths", _trees_reader(trees)) + + +def _wire_mutations(monkeypatch: pytest.MonkeyPatch, clean: bool = True) -> tuple[mock.Mock, mock.Mock, mock.Mock]: + """Patch the switch mutations at their import points. + + Returns: + The cleanliness probe, the local checkout, and the remote-tracking + branch creation — all as recording mocks. + """ + cleanliness = mock.Mock(return_value=clean) + checkout = mock.Mock() + remote_creation = mock.Mock() + monkeypatch.setattr(switching, "is_working_tree_clean", cleanliness) + monkeypatch.setattr(switching, "checkout_local_branch", checkout) + monkeypatch.setattr(switching, "create_branch_from_remote_tracking", remote_creation) + return cleanliness, checkout, remote_creation + + +def _wire_creation_boundary( + monkeypatch: pytest.MonkeyPatch, + inventory: list[BranchRef], + current: str | None, +) -> mock.Mock: + """Patch the creation fallback's import points inside ``goga.topics.creation``. + + Returns: + The create-and-switch mutation as a recording mock — the only git + mutation of the fallback. + """ + monkeypatch.setattr(creation, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(creation, "resolve_current_branch_name", lambda: current) + create_and_switch = mock.Mock() + monkeypatch.setattr(creation, "create_and_switch_branch", create_and_switch) + return create_and_switch + + +def _non_interactive(monkeypatch: pytest.MonkeyPatch) -> None: + """Make stdin a non-terminal — the re-ask path must abort cleanly.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + + +def _working_copy_topic(cwd: Path, year: str, slug: str, artifacts: list[str]) -> None: + """Create the working-copy topic directory with its artifact files.""" + for artifact in artifacts: + path = cwd / ".goga" / "history" / year / slug / artifact + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("artifact", encoding="utf-8") + + +def _twin_inventory() -> list[BranchRef]: + """The design-scenario inventory: a local branch and its remote twin.""" + return [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/feat/a", remote=True), + ] + + +def _twin_trees() -> dict[str, list[str]]: + """The design-scenario ref trees: one planned topic on both refs.""" + return { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "origin/feat/a": [".goga/history/2026/feat-a/plan.md"], + } + + +# --- Contract tests --- + + +class TestEnsuringContract: + def test_ensure_topic_is_importable_from_the_cell_facade(self) -> None: + """``ensure_topic`` lives on the cell facade.""" + import goga.topics as cell + + assert cell.ensure_topic is ensure_topic + assert "ensure_topic" in cell.__all__ + + def test_ensure_topic_signature(self) -> None: + """``ensure_topic(identifier, year=None) -> str``.""" + signature = inspect.signature(ensure_topic) + assert list(signature.parameters) == ["identifier", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(ensure_topic) + assert hints == {"identifier": str, "year": str | None, "return": str} + + +# --- Logic tests --- + + +class TestEnsureTopic: + def test_ensure_topic_zero_candidates_creates_fresh_work( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Nothing hosts the identifier: the fallback creates the branch as + entered and the topic directory of the year — the creation line.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": [".goga/history/2026/other/prd.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _wire_mutations(monkeypatch, clean=True) + create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") + + result = ensure_topic("prune-history-and-new-status", "2026") + + assert result == "Created branch prune-history-and-new-status and topic 2026/prune-history-and-new-status" + create_and_switch.assert_called_once_with("prune-history-and-new-status") + assert (tmp_path / ".goga" / "history" / "2026" / "prune-history-and-new-status").is_dir() + + def test_ensure_topic_remote_tracking_twin_is_occupied( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A remote-tracking twin of the name occupies it: clean error with + the board hint, nothing created.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="origin/new-work", remote=True), + ] + trees = {"main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _wire_mutations(monkeypatch, clean=True) + create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") + _non_interactive(monkeypatch) + + with pytest.raises(click.ClickException) as raised: + ensure_topic("new-work", "2026") + + assert raised.value.message == ( + "remote-tracking branch 'new-work' already exists — run 'goga topics status' to see the board" + ) + create_and_switch.assert_not_called() + + def test_ensure_topic_single_candidate_switches_without_creation( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A hosted identifier takes the plain switch — the fallback never runs.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _cleanliness, checkout, _remote_creation = _wire_mutations(monkeypatch, clean=True) + create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") + + result = ensure_topic("feat/a", "2026") + + assert result == "Switched to branch feat/a" + checkout.assert_called_once_with("feat/a") + create_and_switch.assert_not_called() + + def test_ensure_topic_idempotent_when_already_on_host( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Already on the hosting branch: idempotent success, no probe, no + mutation — creation included.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + _wire_resolution(monkeypatch, builtin_scale, _twin_inventory(), _twin_trees(), "feat/a") + cleanliness, checkout, _remote_creation = _wire_mutations(monkeypatch, clean=True) + create_and_switch = _wire_creation_boundary(monkeypatch, _twin_inventory(), "feat/a") + + result = ensure_topic("feat/a") + + assert result == "Already on branch feat/a" + cleanliness.assert_not_called() + checkout.assert_not_called() + create_and_switch.assert_not_called() + + def test_ensure_topic_multiple_candidates_fail_with_list_not_creation( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Several candidates without a terminal fail with the numbered list — + ambiguity never escapes into creation.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="feat/ab", remote=False), + BranchRef(name="main", remote=False), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "feat/ab": [".goga/history/2026/feat-ab/plan.md"], + "main": ["README.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + cleanliness, checkout, _remote_creation = _wire_mutations(monkeypatch, clean=True) + create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") + _non_interactive(monkeypatch) + + with pytest.raises(click.ClickException) as raised: + ensure_topic("feat", "2026") + + assert "1)" in raised.value.message + assert "2)" in raised.value.message + cleanliness.assert_not_called() + checkout.assert_not_called() + create_and_switch.assert_not_called() + + def test_ensure_topic_empty_slug_identifier_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An identifier normalizing to an empty slug is a clean error — no + branch, no topic directory.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _wire_mutations(monkeypatch, clean=True) + create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") + _non_interactive(monkeypatch) + + with pytest.raises(click.ClickException) as raised: + ensure_topic("БББ", "2026") + + assert raised.value.message == "branch name 'БББ' normalizes to an empty topic slug" + create_and_switch.assert_not_called() + assert not (tmp_path / ".goga" / "history" / "2026").exists() diff --git a/tests/topics/test_switching.py b/tests/topics/test_switching.py index d846c02a..b80a7daa 100644 --- a/tests/topics/test_switching.py +++ b/tests/topics/test_switching.py @@ -5,16 +5,12 @@ candidate of a switch-identifier resolution - ``resolve_switch_candidates(identifier, year)`` — the read-only resolution - ``switch_topic(identifier, year)`` — the switching orchestration -- ``ensure_topic(identifier, year)`` — the switch-or-create orchestration The git boundary is mocked at the import point per the ``convention`` practice — no git binary and no repository are touched. The ref-tree helper shared with the board is mocked at its owner (``goga.topics.board``); the working-copy scenarios use ``tmp_path`` + ``monkeypatch.chdir`` with the real history path routines, and the scale is the ``builtin_scale`` fixture. -The creation fallback of ``ensure_topic`` runs the REAL ``create_topic`` -with its git boundary patched at ``goga.topics.creation``'s import points — -the same wiring the creation tests use. """ from __future__ import annotations @@ -34,8 +30,6 @@ from goga.topics import ( SwitchCandidate, board, - creation, - ensure_topic, resolve_switch_candidates, switch_topic, switching, @@ -85,24 +79,6 @@ def _wire_mutations(monkeypatch: pytest.MonkeyPatch, clean: bool = True) -> tupl return cleanliness, checkout, remote_creation -def _wire_creation_boundary( - monkeypatch: pytest.MonkeyPatch, - inventory: list[BranchRef], - current: str | None, -) -> mock.Mock: - """Patch the creation fallback's import points inside ``goga.topics.creation``. - - Returns: - The create-and-switch mutation as a recording mock — the only git - mutation of the fallback. - """ - monkeypatch.setattr(creation, "list_branch_refs", lambda: inventory) - monkeypatch.setattr(creation, "resolve_current_branch_name", lambda: current) - create_and_switch = mock.Mock() - monkeypatch.setattr(creation, "create_and_switch_branch", create_and_switch) - return create_and_switch - - def _non_interactive(monkeypatch: pytest.MonkeyPatch) -> None: """Make stdin a non-terminal — the re-ask path must abort cleanly.""" monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) @@ -137,14 +113,13 @@ def _twin_trees() -> dict[str, list[str]]: class TestSwitchingContract: def test_entities_are_importable_from_the_cell_facade(self) -> None: - """``SwitchCandidate`` and the three routines live on the cell facade.""" + """``SwitchCandidate`` and the two switch routines live on the cell facade.""" import goga.topics as cell assert cell.SwitchCandidate is SwitchCandidate assert cell.resolve_switch_candidates is resolve_switch_candidates assert cell.switch_topic is switch_topic - assert cell.ensure_topic is ensure_topic - for name in ("SwitchCandidate", "resolve_switch_candidates", "switch_topic", "ensure_topic"): + for name in ("SwitchCandidate", "resolve_switch_candidates", "switch_topic"): assert name in cell.__all__ def test_switch_candidate_is_a_frozen_kw_only_dataclass(self) -> None: @@ -196,17 +171,6 @@ def test_switch_topic_signature(self) -> None: hints = typing.get_type_hints(switch_topic) assert hints == {"identifier": str, "year": str | None, "return": str} - def test_ensure_topic_signature(self) -> None: - """``ensure_topic(identifier, year=None) -> str``.""" - signature = inspect.signature(ensure_topic) - assert list(signature.parameters) == ["identifier", "year"] - assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() - ) - assert signature.parameters["year"].default is None - hints = typing.get_type_hints(ensure_topic) - assert hints == {"identifier": str, "year": str | None, "return": str} - # --- Logic tests: resolution --- @@ -639,156 +603,6 @@ def test_switch_topic_non_interactive_multiple_candidates_fails_with_list( creation.assert_not_called() -class TestEnsureTopic: - def test_ensure_topic_zero_candidates_creates_fresh_work( - self, - builtin_scale: StatusScale, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Nothing hosts the identifier: the fallback creates the branch as - entered and the topic directory of the year — the creation line.""" - monkeypatch.chdir(tmp_path) - inventory = [BranchRef(name="main", remote=False)] - trees = {"main": [".goga/history/2026/other/prd.md"]} - _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") - _wire_mutations(monkeypatch, clean=True) - create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") - - result = ensure_topic("prune-history-and-new-status", "2026") - - assert result == "Created branch prune-history-and-new-status and topic 2026/prune-history-and-new-status" - create_and_switch.assert_called_once_with("prune-history-and-new-status") - assert (tmp_path / ".goga" / "history" / "2026" / "prune-history-and-new-status").is_dir() - - def test_ensure_topic_remote_tracking_twin_is_occupied( - self, - builtin_scale: StatusScale, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """A remote-tracking twin of the name occupies it: clean error with - the board hint, nothing created.""" - monkeypatch.chdir(tmp_path) - inventory = [ - BranchRef(name="main", remote=False), - BranchRef(name="origin/new-work", remote=True), - ] - trees = {"main": ["README.md"]} - _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") - _wire_mutations(monkeypatch, clean=True) - create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") - _non_interactive(monkeypatch) - - with pytest.raises(click.ClickException) as raised: - ensure_topic("new-work", "2026") - - assert raised.value.message == ( - "remote-tracking branch 'new-work' already exists — run 'goga topics status' to see the board" - ) - create_and_switch.assert_not_called() - - def test_ensure_topic_single_candidate_switches_without_creation( - self, - builtin_scale: StatusScale, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """A hosted identifier takes the plain switch — the fallback never runs.""" - monkeypatch.chdir(tmp_path) - inventory = [ - BranchRef(name="feat/a", remote=False), - BranchRef(name="main", remote=False), - ] - trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} - _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") - _cleanliness, checkout, _remote_creation = _wire_mutations(monkeypatch, clean=True) - create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") - - result = ensure_topic("feat/a", "2026") - - assert result == "Switched to branch feat/a" - checkout.assert_called_once_with("feat/a") - create_and_switch.assert_not_called() - - def test_ensure_topic_idempotent_when_already_on_host( - self, - builtin_scale: StatusScale, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Already on the hosting branch: idempotent success, no probe, no - mutation — creation included.""" - monkeypatch.chdir(tmp_path) - _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) - _wire_resolution(monkeypatch, builtin_scale, _twin_inventory(), _twin_trees(), "feat/a") - cleanliness, checkout, _remote_creation = _wire_mutations(monkeypatch, clean=True) - create_and_switch = _wire_creation_boundary(monkeypatch, _twin_inventory(), "feat/a") - - result = ensure_topic("feat/a") - - assert result == "Already on branch feat/a" - cleanliness.assert_not_called() - checkout.assert_not_called() - create_and_switch.assert_not_called() - - def test_ensure_topic_multiple_candidates_fail_with_list_not_creation( - self, - builtin_scale: StatusScale, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Several candidates without a terminal fail with the numbered list — - ambiguity never escapes into creation.""" - monkeypatch.chdir(tmp_path) - inventory = [ - BranchRef(name="feat/a", remote=False), - BranchRef(name="feat/ab", remote=False), - BranchRef(name="main", remote=False), - ] - trees = { - "feat/a": [".goga/history/2026/feat-a/plan.md"], - "feat/ab": [".goga/history/2026/feat-ab/plan.md"], - "main": ["README.md"], - } - _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") - cleanliness, checkout, _remote_creation = _wire_mutations(monkeypatch, clean=True) - create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") - _non_interactive(monkeypatch) - - with pytest.raises(click.ClickException) as raised: - ensure_topic("feat", "2026") - - assert "1)" in raised.value.message - assert "2)" in raised.value.message - cleanliness.assert_not_called() - checkout.assert_not_called() - create_and_switch.assert_not_called() - - def test_ensure_topic_empty_slug_identifier_clean_error( - self, - builtin_scale: StatusScale, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """An identifier normalizing to an empty slug is a clean error — no - branch, no topic directory.""" - monkeypatch.chdir(tmp_path) - inventory = [BranchRef(name="main", remote=False)] - trees = {"main": ["README.md"]} - _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") - _wire_mutations(monkeypatch, clean=True) - create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") - _non_interactive(monkeypatch) - - with pytest.raises(click.ClickException) as raised: - ensure_topic("БББ", "2026") - - assert raised.value.message == "branch name 'БББ' normalizes to an empty topic slug" - create_and_switch.assert_not_called() - assert not (tmp_path / ".goga" / "history" / "2026").exists() - - # --- Infrastructure boundary --- From 090e897090a4c8f8d96336575832dc53a64dd268 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 01:29:59 +0000 Subject: [PATCH 101/229] feat: add history prune, topic titles, and the new status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the contracts of the history and topics cells for three coordinated changes. Prune: the history git cell now enumerates the branch inventory (BranchRef, list_branch_refs — local branches and remote-tracking refs, <remote>/HEAD symrefs dropped), the history cell owns the orphan cleanup (remove_topic_dir, prune_topics — a topic is protected when any branch of the inventory normalizes to its slug, year-independent; deletion is unconditional and filesystem-only) and re-exports the git introspection on its facade, and 'goga history prune [YEAR] [--dry-run]' exposes it. Titles: create_topic accepts an optional title and writes title.txt (the text as entered plus a trailing newline, UTF-8; the idempotent re-run creates or overwrites only the title file), the board carries the title per row (BoardRecord.title — the first line of title.txt, read from the ref trees without checkout via the new read_ref_file), and 'goga topics create -t' / 'goga topics status --info' (the four-column width rule) surface both. Status: the built-in axis gains the 'new' entry marked by title.txt — nine entries, empty before defined. Reconcile every touched CODEMANIFEST and .usages file with the new contracts, including the new prune.md practice. Gates: goga lint 65 cells, 0 errors. --- .../history/.usages/history-command.md | 17 ++++ goga/commands/history/CODEMANIFEST | 35 ++++++- .../commands/topics/.usages/topics-command.md | 29 ++++-- goga/commands/topics/CODEMANIFEST | 67 ++++++++----- goga/history/.usages/prune.md | 44 +++++++++ goga/history/.usages/topic-statuses.md | 12 +-- goga/history/CODEMANIFEST | 94 +++++++++++++++++-- goga/history/git/CODEMANIFEST | 67 +++++++++++-- goga/history/statuses/CODEMANIFEST | 9 +- goga/topics/.usages/creating.md | 18 ++++ goga/topics/.usages/topic-board.md | 7 +- goga/topics/CODEMANIFEST | 87 +++++++++++------ goga/topics/git/.usages/refs-and-switching.md | 17 ++++ goga/topics/git/CODEMANIFEST | 57 ++++++++--- 14 files changed, 454 insertions(+), 106 deletions(-) create mode 100644 goga/history/.usages/prune.md diff --git a/goga/commands/history/.usages/history-command.md b/goga/commands/history/.usages/history-command.md index 03f04485..8bbb0afa 100644 --- a/goga/commands/history/.usages/history-command.md +++ b/goga/commands/history/.usages/history-command.md @@ -58,6 +58,23 @@ Creates the topic directory of the current year — idempotently. checks belong to the caller. - Prints nothing on stdout; the exit code carries the result. +## goga history prune [YEAR] [--dry-run] + +Deletes the orphan topics of one year — the topics no branch of the +repository inventory hosts — and prints one slug per line. Nothing else +is printed; an empty result prints nothing and exits 0. + + goga history prune --dry-run # list the candidates, delete nothing + goga history prune # current year, delete the orphans + goga history prune 2025 # an explicit year + +- Protection: a local branch or a remote-tracking ref whose short name + normalizes to the topic slug protects the topic — in every year. +- Deletion is unconditional: no status protects a topic, the whole topic + directory goes with all artifacts. The tree is not in git — a deleted + topic directory is unrecoverable; run --dry-run first. +- Filesystem-only: branches, refs, and the index are never touched. + ## Errors Every failure is a clean message on stderr with a non-zero exit and no diff --git a/goga/commands/history/CODEMANIFEST b/goga/commands/history/CODEMANIFEST index 6a2b1d7f..06243c49 100644 --- a/goga/commands/history/CODEMANIFEST +++ b/goga/commands/history/CODEMANIFEST @@ -6,6 +6,7 @@ Imports: - collect_topic_statuses - ensure_topic_dir - normalize_topic_slug + - prune_topics - resolve_current_branch_name - resolve_topic_dir - resolve_topic_file @@ -14,6 +15,7 @@ Imports: - topic-paths - topic-statuses - history-tree + - prune From: goga/history Usages: @@ -60,6 +62,7 @@ Annotations: | -s/--status (repeatable status filter) - path — an optional TOPIC positional, -f/--file FILENAME, --year/-y YYYY - ensure — an optional NAME positional + - prune — an optional YEAR positional, --dry-run Apply the `convention` CLI command docstring rule for the --help text (rendered verbatim by Click; omit Args/Returns/Raises). @@ -188,6 +191,34 @@ Annotations: | belongs to the caller - Do not create artifact files inside the directory + "prune(year: str | None = None, dry_run: bool = False) -> exit_code: int": | + Subcommand goga history prune: delete the orphan topics of one year — + the topics no branch of the repository inventory hosts. + + `year`: optional YEAR positional — four digits; None means the current + year + `dry_run`: the --dry-run flag — list the deletion candidates without + deleting anything + `exit_code`: 0 on success (an empty result included), 1 on error + + Apply the `prune` practice for the orphan-cleanup contract of the + domain. + Apply the `click` practice for the flag and echo. + + Algorithm: + 1. Run the orphan cleanup via `prune_topics` with `year` and `dry_run` + 2. Echo one slug per line of the returned list + 3. An empty result prints nothing — exit 0 + + Requirements: + - The deletion is irreversible — the dry pass is the safe preview + - Only stdout carries the slug list — nothing else is printed + + Constraints: + - Do not ask for confirmation — the dry pass is the safety tool + - Do not compute orphan-hood or delete anything here — both belong to + the domain + "render_history_tree(tree: list[HistoryYear])": location: render.py annotations: | @@ -243,5 +274,5 @@ Annotations: | Author: Goga CreatedAt: 28/08/26 Description: | - The goga history command group with the list, status, path, and ensure - subcommands over the history domain. + The goga history command group with the list, status, path, ensure, and + prune subcommands over the history domain. diff --git a/goga/commands/topics/.usages/topics-command.md b/goga/commands/topics/.usages/topics-command.md index ca4fed72..d63a67c2 100644 --- a/goga/commands/topics/.usages/topics-command.md +++ b/goga/commands/topics/.usages/topics-command.md @@ -13,23 +13,32 @@ current year); the status subcommand reads remote-tracking refs with goga topics status goga topics --year 2025 status goga topics status --remote - -Prints a three-column table — topic, branch, statuses — with column and row -separators fitted to the terminal width. The current branch row carries `*`; -remote hosts keep their remote prefix. A topic's statuses are all its -maximal statuses, wrapped onto continuation lines. An empty board prints -nothing and exits 0. + goga topics status --info + +Prints a three-column table — topic, branch, statuses — with column and +row separators fitted to the terminal width. `--info/-i` adds the title +column: topic, branch, title, and statuses share the width — each of +the first three capped at a quarter of it — and the title shows the +first line of the topic's `title.txt` read from the ref trees without +checkout; a topic without a title file shows an empty cell. The current +branch row carries `*`; remote hosts keep their remote prefix. A topic's +statuses are all its maximal statuses, wrapped onto continuation lines. +An empty board prints nothing and exits 0. ## Creating fresh work goga topics create Feature/Foo_Bar goga topics --year 2025 create Feature/Foo_Bar + goga topics create Feature/Foo_Bar -t "Payment retry" Creates the branch with the name as entered, switches to it, and creates -the topic directory of the scoped year. The current branch already hosting -the same slug is an idempotent success. Occupied names and empty slugs -trigger a re-ask on an interactive terminal, or a clean error with a hint -otherwise. +the topic directory of the scoped year. An explicit `--title/-t` also +writes the topic title file `title.txt` — the text as entered plus a +trailing newline; on the idempotent re-run (the current branch already +hosts the same slug) the title file is created or overwritten and +nothing else mutates. Without `-t` no title file is written. Occupied +names and empty slugs trigger a re-ask on an interactive terminal, or a +clean error with a hint otherwise. ## Switching to existing work diff --git a/goga/commands/topics/CODEMANIFEST b/goga/commands/topics/CODEMANIFEST index d5d6bba8..fd151265 100644 --- a/goga/commands/topics/CODEMANIFEST +++ b/goga/commands/topics/CODEMANIFEST @@ -50,19 +50,21 @@ Annotations: | the subcommand registration. Subcommand surfaces: - - status — a --remote/-r flag - - create — a NAME positional + - status — a --remote/-r flag, an --info/-i flag + - create — a NAME positional, a --title/-t option - switch — an IDENTIFIER positional Apply the `convention` CLI command docstring rule for the --help text (rendered verbatim by Click; omit Args/Returns/Raises). methods: - "status(remote: bool = False) -> exit_code: int": | + "status(remote: bool = False, info: bool = False) -> exit_code: int": | Subcommand goga topics status: print the board — the cross-branch - topic inventory of the scoped year as a three-column table. + topic inventory of the scoped year as a three-column table, or a + four-column table with the title column under --info/-i. `remote`: the --remote/-r flag — read remote-tracking refs instead of local branches + `info`: the --info/-i flag — add the title column to the table `exit_code`: 0 on success (an empty board included), 1 on error Apply the `topic-board` practice for the board contract of the @@ -73,7 +75,8 @@ Annotations: | 1. Collect the board via `collect_topic_board` with the scoped year and `remote` 2. Measure the terminal width - 3. Render the table via `render_topic_board` + 3. Render the table via `render_topic_board` with the records, the + width, and `info` 4. An empty board renders nothing — exit 0 Requirements: @@ -81,12 +84,16 @@ Annotations: | Constraints: - Do not print the year, the artifacts, or a header — the table - carries topic, branch, and statuses only - "create(branch_name: str) -> exit_code: int": | + carries topic, branch, the title column under `info`, and + statuses only + "create(branch_name: str, title: str | None = None) -> exit_code: int": | Subcommand goga topics create: create fresh work — a branch with the - name as entered and its topic directory of the scoped year. + name as entered, its topic directory of the scoped year, and an + optional topic title. `branch_name`: NAME positional — the branch name as entered + `title`: the --title/-t value — the topic title; None writes no title + file `exit_code`: 0 on success, 1 on error Apply the `creating` practice for the creation contract of the @@ -94,7 +101,8 @@ Annotations: | Apply the `click` practice for exit-code propagation. Algorithm: - 1. Delegate to `create_topic` with `branch_name` and the scoped year + 1. Delegate to `create_topic` with `branch_name`, the scoped year, + and `title` 2. Echo the single result line 3. Propagate the exit code @@ -121,40 +129,51 @@ Annotations: | Constraints: - Do not launch any pipeline — continuation is a separate command -"render_topic_board(records: list[BoardRecord], width: int)": +"render_topic_board(records: list[BoardRecord], width: int, info: bool = False)": location: render.py annotations: | - Render the board as a three-column table: topic, branch, statuses. + Render the board as a table: topic, branch, and statuses — under + `info` the title column sits between branch and statuses. `records`: the collected board records — already sorted by the domain `width`: the measured terminal width in columns + `info`: True adds the title column and switches to the four-column + width rule Apply the `click` practice for echo. Algorithm: 1. Compute the column widths from `width` and the record content per - the width rule of the requirements + the width rule of the requirements — the three-column rule without + `info`, the four-column rule with it 2. Print one header row and one separator row with column and row - dividers - 3. Print each record: the topic truncated with an ellipsis when it - exceeds its column, the branch truncated the same way, and the - statuses wrapped onto continuation lines without affecting the - column widths + dividers — the column order is topic, branch, title, statuses + under `info` + 3. Print each record: every text column truncated with an ellipsis when + it exceeds its column, the statuses wrapped onto continuation lines + without affecting the column widths 4. Mark the record hosting the current branch with an asterisk; keep the remote prefix of a remote host visible in the branch column 5. An empty `records` prints nothing Requirements: - - Column widths: topic and branch get an equal share first, statuses - take the remainder — each of topic and branch is capped at one third - of `width` minus the dividers, statuses receives what is left, and - every column keeps a minimum of 8 columns before truncation applies + - Three-column widths: topic and branch get an equal share first, + statuses take the remainder — each of topic and branch is capped at + one third of `width` minus the dividers, statuses receives what is + left, and every column keeps a minimum of 8 columns before + truncation applies + - Four-column widths under `info`: topic, branch, and title get an + equal share — each capped at one quarter of `width` minus the + dividers, statuses receives the non-negative remainder, and every + column keeps a minimum of 8 columns before truncation applies + - A title of None or an empty string renders an empty cell - The truncation marker is a single ellipsis character - An overlong status is truncated like the other columns - The table never exceeds `width`, with one documented exception: when - `width` is below 33, every column keeps its minimum of 8 and the table - may exceed `width` — minimum readability wins over the width cap on - ultra-narrow terminals + the minimum columns no longer fit — below the narrow threshold of the + active column rule — every column keeps its minimum of 8 and the + table may exceed `width`; minimum readability wins over the width cap + on ultra-narrow terminals Constraints: - Read-only on `records` — do not mutate, do not re-sort, do not filter diff --git a/goga/history/.usages/prune.md b/goga/history/.usages/prune.md new file mode 100644 index 00000000..cf52916e --- /dev/null +++ b/goga/history/.usages/prune.md @@ -0,0 +1,44 @@ +# history — pruning orphan topics + +How to clean up the orphan topics of a year with the `goga.history` +facade. For consumers that maintain the tree: CLI cleanup commands, +maintenance scripts. + +A topic is an orphan when no branch of the repository inventory hosts +its slug: a local branch named so, or a remote-tracking ref whose short +name — the part after the first "/" — normalizes to it. The protection +is year-independent: a branch protects same-named topics of every year. +Deletion is unconditional — no status protects a topic, and the whole +topic directory goes with all of its artifacts. The tree lives outside +git, so a deleted topic directory is unrecoverable; run the dry pass +first. + +## Pruning a year + +```python +from goga.history import prune_topics + +candidates = prune_topics(dry_run=True) # lists candidates, deletes nothing +removed = prune_topics() # current year, deletes orphans +removed = prune_topics(year="2025") # an explicit year +print("\n".join(removed)) +``` + +- One slug per result entry, sorted alphabetically; an empty result is + an empty list — not an error. +- `year` defaults to the current year; other years are never touched. +- Filesystem-only: no branch, ref, or index of git is mutated in any + mode. + +## Deleting one topic directory + +```python +from goga.history import remove_topic_dir + +removed = remove_topic_dir("release-1-3-0", year="2025") +``` + +- True when the directory existed and was deleted, False when it was + absent — idempotent absence. +- Deletes the whole directory with every artifact inside; the orphan + decision belongs to the caller. diff --git a/goga/history/.usages/topic-statuses.md b/goga/history/.usages/topic-statuses.md index c3dc22af..bfbbe5b1 100644 --- a/goga/history/.usages/topic-statuses.md +++ b/goga/history/.usages/topic-statuses.md @@ -5,12 +5,12 @@ For consumers that report progress: CLI status output, boards, reviews, dashboards. A topic's status is the set of its maximal present statuses on the topic -status scale. The built-in axis is fixed — empty, defined, discovered, -backlog, designed, specified, planned, done, marked by the artifacts prd.md, -adr.md, task.md, arch.md, design.md, plan.md, completed/plan.md inside the -topic directory. Tool packages extend the scale with qualified statuses -`<tool>.<name>`, so one topic can carry several statuses at once — all of -them are shown. +status scale. The built-in axis is fixed — empty, new, defined, discovered, +backlog, designed, specified, planned, done, marked by the artifacts +title.txt, prd.md, adr.md, task.md, arch.md, design.md, plan.md, +completed/plan.md inside the topic directory. Tool packages extend the scale +with qualified statuses `<tool>.<name>`, so one topic can carry several +statuses at once — all of them are shown. ## Listing a year with statuses diff --git a/goga/history/CODEMANIFEST b/goga/history/CODEMANIFEST index 18e0dfcd..9baf8a6c 100644 --- a/goga/history/CODEMANIFEST +++ b/goga/history/CODEMANIFEST @@ -1,6 +1,8 @@ Imports: - Types: - resolve_current_branch_name + - BranchRef + - list_branch_refs From: goga/history/git - Types: - StatusScale @@ -22,12 +24,15 @@ Annotations: | This cell is the single owner of the .goga/history/ tree: topic identity (the slug grammar and the current year), topic addressing (the tree root, - directory and artifact file paths, existence, creation), the topic status - listing, and tree traversal. The status scale is provided by the statuses + directory and artifact file paths, existence, creation, removal), the + topic status listing, tree traversal, and the orphan cleanup of one year + — the deletion of topic directories no branch of the repository + inventory hosts. The status scale is provided by the statuses subcell and + re-exported on this facade; the branch inventory is provided by the git subcell and re-exported on this facade. Artifact files are written by - their producers — this cell computes paths and creates topic directories - only; it never writes artifact content. Pure filesystem and grammar - logic: no git access beyond the branch reader re-export, no CLI, no + their producers — this cell computes paths and creates and removes topic + directories; it never writes artifact content. Pure filesystem and + grammar logic: no git access beyond the subcell re-exports, no CLI, no output rendering. Every topic value received on the input is normalized — a branch name and an already-normalized slug are both accepted, identically and idempotently. Use relative imports. @@ -35,6 +40,8 @@ Annotations: | --- ->resolve_current_branch_name: {} +->BranchRef: {} +->list_branch_refs: {} ->StatusScale: {} ->Stage: {} ->StatusRegistry: {} @@ -217,6 +224,35 @@ Annotations: | belongs to the caller - Do not create or touch artifact files inside the directory +"remove_topic_dir(name: str, year: str | None = None) -> removed: bool": + location: paths.py + annotations: | + Delete the directory of a history topic of a year. + + `name`: topic input — a branch name or an already-normalized slug + `year`: optional year as four digits; None means the current year + `removed`: True when the topic directory existed and was deleted, + False when it was absent + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Compose the topic directory via `resolve_topic_dir` with `name` and + `year` + 2. An absent directory yields False — idempotent absence, not an error + 3. Delete the directory with all of its content and return True + + Requirements: + - Deletes the whole topic directory — every artifact including nested + directories goes with it + - Pure filesystem mutation — no git branch, ref, or index is touched + + Constraints: + - Do not decide whether a topic deserves deletion — the orphan decision + belongs to the caller + - Do not touch sibling topic directories or the year directory itself + "TopicRecord(topic: str, statuses: list[str])": location: status.py annotations: | @@ -336,11 +372,55 @@ Annotations: | - Do not compute statuses — the tree carries topic names only - Do not render — output shaping belongs to the consumer +"prune_topics(year: str | None = None, dry_run: bool = False) -> removed: list[str]": + location: prune.py + annotations: | + Delete the orphan topics of one year — the topics no branch of the + repository inventory hosts. + + `year`: optional year as four digits; None means the current year + `dry_run`: True lists the orphan topics without deleting anything + `removed`: the slugs of the orphan topics sorted alphabetically — the + deleted ones, or the deletion candidates under `dry_run` + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Resolve the year — `year` when given, otherwise the current year + 2. Take the topics of the resolved year from the tree collected via + `collect_history_tree` — an absent year yields no topics + 3. Enumerate the repository branch inventory via `list_branch_refs` + 4. Build the hosted slug set: every local branch name normalized via + `normalize_topic_slug`, and the short name of every remote-tracking + ref — the part after the first "/" — normalized the same way + 5. The orphans are the year's topics whose slug is not in the hosted set + 6. `dry_run` False -> delete every orphan directory via + `remove_topic_dir`; True -> delete nothing + 7. Return the orphan slugs sorted alphabetically + + Requirements: + - A topic is protected when at least one branch of the inventory + normalizes to its slug — the protection is year-independent, a branch + protects same-named topics of every year + - Deletion is unconditional — no status protects a topic + - Only the resolved year is affected — no other year is touched + - Filesystem-only — no branch, ref, or index of git is mutated in any + mode + - `dry_run` True mutates nothing at all + - An empty result is an empty list — not an error + + Constraints: + - Do not assemble the status scale — statuses take no part in the orphan + decision + - Do not spare topics by status or age + - Do not print — output shaping belongs to the consumer + --- Author: Goga CreatedAt: 28/08/26 Description: | Owner of the .goga/history/ tree — topic identity, addressing, status - listing, and traversal. Re-exports the branch reader and the status scale - on its facade. + listing, traversal, and orphan cleanup. Re-exports the git introspection + and the status scale on its facade. diff --git a/goga/history/git/CODEMANIFEST b/goga/history/git/CODEMANIFEST index a82208b4..948f62c5 100644 --- a/goga/history/git/CODEMANIFEST +++ b/goga/history/git/CODEMANIFEST @@ -2,7 +2,9 @@ Usages: convention: .goga/usages/conventions.md git: | External git binary invoked via subprocess.run (check=True, capture_output=True). - Set GIT_TERMINAL_PROMPT=0 in the env to suppress interactive prompts. Mock the + Set GIT_TERMINAL_PROMPT=0 in the env to suppress interactive prompts. + Read-only inspection: the current branch name and the branch ref + inventory (local branches and remote-tracking refs). Mock the subprocess call in tests per `convention`. Annotations: | @@ -13,14 +15,63 @@ Annotations: | - Organizing the test infrastructure - Understanding the general principles and rules of development and testing in the project - This cell owns git-environment introspection for the history domain: reading the - current branch name. It is environment-probing, NOT history-path or topic logic — - that lives in goga/history. All git access flows through the `git` practice; mock - the subprocess call in tests per `convention`. Use relative imports. Re-exported - on the goga/history facade via embedding. + This cell owns git-environment introspection for the history domain: reading + the current branch name and enumerating the repository branch inventory. It + is environment access, not history-path or topic logic — every decision + belongs to the caller. All git access flows through the `git` practice; + mock the subprocess call in tests per `convention`. Use relative imports. + Re-exported on the goga/history facade via embedding. --- +"BranchRef(name: str, remote: bool)": + location: refs.py + annotations: | + One branch ref of the repository inventory — a local branch or a + remote-tracking ref. + + `name`: the display name — the short branch name for a local ref, + <remote>/<branch> for a remote-tracking ref + `remote`: True when the ref is remote-tracking + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The display name is the identity used by consumers — no reshortening, + no normalization + properties: + "name -> str": | + The display branch name of the ref. + "remote -> bool": | + True when the ref is a remote-tracking ref. + +"list_branch_refs() -> refs: list[BranchRef]": + location: refs.py + annotations: | + Enumerate the branch refs of the repository — local branches and + remote-tracking refs together. + + `refs`: every branch ref, sorted alphabetically by display name + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git for the local branch refs + 2. Ask git for the remote-tracking refs + 3. Drop the <remote>/HEAD symrefs — they are pointers, not branches + 4. Merge both into one inventory sorted alphabetically by display name + + Requirements: + - Read-only — no ref is created, moved, or deleted + - No network — remote-tracking refs as they exist locally + + Constraints: + - Do not deduplicate — a local branch and its remote twin are two distinct + refs here; collapsing them belongs to the caller + "resolve_current_branch_name() -> branch: str | None": location: branch.py annotations: | @@ -49,5 +100,5 @@ Annotations: | Author: Goga CreatedAt: 28/08/26 Description: | - Git-environment introspection for the history domain — reads the current branch - name. Re-exported on the goga/history facade. + Git-environment introspection for the history domain — the current branch + name and the branch inventory. Re-exported on the goga/history facade. diff --git a/goga/history/statuses/CODEMANIFEST b/goga/history/statuses/CODEMANIFEST index 881156d9..bb875534 100644 --- a/goga/history/statuses/CODEMANIFEST +++ b/goga/history/statuses/CODEMANIFEST @@ -42,9 +42,10 @@ Annotations: | intra-package imports. Requirements: - - The built-in axis is ordered empty, defined, discovered, backlog, - designed, specified, planned, done by the artifacts prd.md, adr.md, - task.md, arch.md, design.md, plan.md, completed/plan.md + - The built-in axis is ordered empty, new, defined, discovered, + backlog, designed, specified, planned, done by the artifacts + title.txt, prd.md, adr.md, task.md, arch.md, design.md, plan.md, + completed/plan.md - A tool status never reorders or replaces a built-in one properties: "stages -> list[Stage]": | @@ -189,7 +190,7 @@ Annotations: | imports. Algorithm: - 1. Build the built-in axis of eight entries + 1. Build the built-in axis of nine entries 2. Enumerate the installed goga_tool_* packages in alphabetical order of package name 3. Import each package — a broken import is a clean error naming the diff --git a/goga/topics/.usages/creating.md b/goga/topics/.usages/creating.md index 186ee091..03ea1ba8 100644 --- a/goga/topics/.usages/creating.md +++ b/goga/topics/.usages/creating.md @@ -28,3 +28,21 @@ print(result) # one line describing what was created topic directory of the year — exposed as `check_branch_occupancy`. - No artifact files are written inside the topic directory — artifacts belong to their producers. + +## Creating with a title + +```python +from goga.topics import create_topic + +result = create_topic("Feature/Foo_Bar", title="Payment retry") +``` + +- Fresh work: the branch, the switch, the topic directory, and the + title file `title.txt` — the text as entered plus a trailing newline, + UTF-8. +- The current branch already hosting the same slug with an explicit + title: the topic directory is ensured and `title.txt` is created or + overwritten — nothing else mutates, no switch happens. +- Without a title the behavior carries no title file at all. +- `title.txt` marks the `new` status on the topic status scale; no + other artifact is written — artifacts belong to their producers. diff --git a/goga/topics/.usages/topic-board.md b/goga/topics/.usages/topic-board.md index b5566615..b357513a 100644 --- a/goga/topics/.usages/topic-board.md +++ b/goga/topics/.usages/topic-board.md @@ -19,11 +19,14 @@ from goga.topics import collect_topic_board records = collect_topic_board() # current year, local records = collect_topic_board(year="2025", remote=True) # remote-tracking refs for record in records: - print(record.topic, record.branch, record.statuses, record.current) + print(record.topic, record.branch, record.statuses, record.current, record.title) ``` - One `BoardRecord` per hosted topic: the slug, the hosting branch display - name, the maximal status names in scale order, and the current marker. + name, the maximal status names in scale order, the current marker, and + the title — the first line of the topic's `title.txt`, or None when the + topic has none. The title is read from the ref trees without checkout; + rows hosted by other branches show their titles. - A local branch and its remote twin collapse to one row — the local branch wins. Two different branches hosting one slug stay two rows. - Sorting: scale order of the first maximal status, then topic alphabet. diff --git a/goga/topics/CODEMANIFEST b/goga/topics/CODEMANIFEST index 012c0297..20fed5a0 100644 --- a/goga/topics/CODEMANIFEST +++ b/goga/topics/CODEMANIFEST @@ -7,6 +7,7 @@ Imports: - resolve_history_root - resolve_topic_status - resolve_topic_dir + - resolve_topic_file - current_year - StatusScale - assemble_status_scale @@ -18,6 +19,7 @@ Imports: - BranchRef - list_branch_refs - read_ref_tree_paths + - read_ref_file - checkout_local_branch - create_branch_from_remote_tracking - create_and_switch_branch @@ -44,26 +46,26 @@ Annotations: | Use the `topic-paths` practice for the consumer patterns of the history facade — the topic slug, the topic directory of a year, the existence - oracle, and the current branch. + oracle, the topic title file path, and the current branch. Use the `topic-statuses` practice for the status scale patterns of the history facade — scale assembly and maximal-status computation. Use the `refs-and-switching` practice for the git patterns of the topics - git cell — the branch inventory, ref tree reading, and the bounded switch - mutations. + git cell — the branch inventory, ref tree reading, and file reading + without checkout. This cell owns the topics domain — the work-tracker view of the history tree: the cross-branch topic inventory of one year with per-topic - statuses, the switch-identifier resolution and switching orchestration, - the fresh-work creation procedure, and the combined ensure orchestration - that switches onto hosted work and creates it when nothing hosts the - identifier. Topic identity, addressing, and statuses belong to the - history facade; git access belongs to the topics git cell. Mutations are - local-only and happen strictly after every decision is made. Use - relative imports. + statuses and titles, the switch-identifier resolution and switching + orchestration, the fresh-work creation procedure with its optional topic + title, and the combined ensure orchestration that switches onto hosted + work and creates it when nothing hosts the identifier. Topic identity, + addressing, and statuses belong to the history facade; git access + belongs to the topics git cell. Mutations are local-only and happen + strictly after every decision is made. Use relative imports. --- -"BoardRecord(topic: str, branch: str, statuses: list[str], current: bool, remote: bool)": +"BoardRecord(topic: str, branch: str, statuses: list[str], current: bool, remote: bool, title: str | None = None)": location: board.py annotations: | One row of the topic board — a topic hosted by one branch. @@ -74,6 +76,8 @@ Annotations: | scale order `current`: True when the row hosts the current working branch `remote`: True when the hosting ref is remote-tracking + `title`: the first line of the topic title file, or None when the topic + has none Apply the `convention` practice for the data-model rules and intra-package imports. @@ -88,12 +92,15 @@ Annotations: | True when the row hosts the current working branch. "remote -> bool": | True when the hosting ref is remote-tracking. + "title -> str | None": | + The first line of the topic title file, or None when the topic has + no title file. "collect_topic_board(year: str | None = None, remote: bool = False) -> records: list[BoardRecord]": location: board.py annotations: | Collect the cross-branch topic inventory of one year — every topic with - its hosting branch and statuses. + its hosting branch, statuses, and title. `year`: optional year as four digits; None means the current year `remote`: True reads remote-tracking refs instead of local branches @@ -103,8 +110,8 @@ Annotations: | Apply the `topic-paths` practice for the year and tree-root patterns. Apply the `topic-statuses` practice for the scale assembly and maximal-status computation. - Apply the `refs-and-switching` practice for the inventory and - tree-reading patterns. + Apply the `refs-and-switching` practice for the inventory, + tree-reading, and file-reading patterns. Algorithm: 1. Resolve the year — `year` when given, otherwise the current year @@ -123,10 +130,14 @@ Annotations: | artifact paths and compute the maximal statuses — the working copy over the directory composed by `resolve_topic_dir` via `resolve_topic_status`, every other ref via the `StatusScale` - 6. Collapse a local branch and its remote twin into one row — the local + 6. Read the title of every hosted topic — the working copy from the + title file title.txt of its directory, every other ref from the + title file of its ref tree via `read_ref_file`; the value is the + first line of the file, None when it is absent + 7. Collapse a local branch and its remote twin into one row — the local branch wins; different branches hosting one slug stay separate rows - 7. Mark the row hosting the current branch - 8. Sort by scale order of the first maximal status, then alphabetically + 8. Mark the row hosting the current branch + 9. Sort by scale order of the first maximal status, then alphabetically by topic, and return the records Requirements: @@ -134,6 +145,9 @@ Annotations: | progress is visible; remote mode shows it through its remote twin - Read-only — no checkout, no worktree, no mutation of any kind - A year without topics yields an empty list — not an error + - A multi-line title file yields its first line; an empty title file + yields an empty string + - The title never affects the sort order Constraints: - Do not render — output shaping belongs to the consumer @@ -290,20 +304,21 @@ Annotations: | - Do not manage the stages of the hosting pipeline — continuation belongs to the pipeline itself -"create_topic(branch_name: str, year: str | None = None) -> result: str": +"create_topic(branch_name: str, year: str | None = None, title: str | None = None) -> result: str": location: creation.py annotations: | - Create fresh work — a branch with the name as entered and its topic - directory of the year. + Create fresh work — a branch with the name as entered, its topic + directory of the year, and an optional topic title. `branch_name`: the branch name as entered by the user `year`: optional year as four digits; None means the current year + `title`: optional topic title; None writes no title file `result`: one line describing the outcome Apply the `click` practice for the re-ask prompt and the non-interactive detection. - Apply the `topic-paths` practice for the slug, existence, and directory - creation patterns. + Apply the `topic-paths` practice for the slug, existence, directory + creation, and title-file path patterns. Apply the `refs-and-switching` practice for the create-and-switch pattern. @@ -313,19 +328,29 @@ Annotations: | on an interactive terminal and restart, or fail with the hint otherwise 3. The current branch — read via `resolve_current_branch_name` — hosts - the same slug -> idempotent success, no mutation, no occupancy - check + the same slug -> the idempotent path: a `title` given writes the + topic title file title.txt — the path resolved via + `resolve_topic_file` — of the ensured topic directory; no `title` + is a success without mutation; no occupancy check, no switch 4. `check_branch_occupancy` reports a conflict -> print the reason with a hint to the board, prompt for a new name on an interactive terminal and restart, or fail otherwise 5. Free name -> create the branch named exactly as entered and switch - to it via `create_and_switch_branch`, and create the topic directory - via `ensure_topic_dir` of the year + to it via `create_and_switch_branch`, create the topic directory + via `ensure_topic_dir` of the year, and a `title` given writes the + title file title.txt — the path resolved via `resolve_topic_file` + — of the topic directory 6. Return the single result line Requirements: - The branch keeps the name as entered; the topic directory takes the slug — the two may deliberately differ + - The title file carries `title` as entered plus a single trailing + newline, encoded UTF-8 + - The title file is written only when `title` is given — None never + creates and never overwrites it; an explicit `title` creates the + file or overwrites it + - The topic directory exists before the title file is written - An aborted re-ask leaves the repository untouched - The caller stays on the new branch @@ -333,7 +358,8 @@ Annotations: | - Do not validate branch-name characters — git owns name validity - Do not auto-pick suffixed names on a conflict — the user re-asks or aborts - - Do not write artifact files inside the topic directory + - Do not write artifact files other than the topic title file inside + the topic directory "check_branch_occupancy(branch_name: str, slug: str, year: str | None = None) -> conflict: str | None": location: creation.py @@ -374,6 +400,7 @@ Annotations: | Author: Goga CreatedAt: 29/08/26 Description: | - The topics domain — the cross-branch topic inventory, switch resolution - and orchestration, fresh-work creation, and the combined ensure - orchestration that switches onto hosted work or creates it. + The topics domain — the cross-branch topic inventory with titles, switch + resolution and orchestration, fresh-work creation with an optional title, + and the combined ensure orchestration that switches onto hosted work or + creates it. diff --git a/goga/topics/git/.usages/refs-and-switching.md b/goga/topics/git/.usages/refs-and-switching.md index 190996c5..aff9d1ec 100644 --- a/goga/topics/git/.usages/refs-and-switching.md +++ b/goga/topics/git/.usages/refs-and-switching.md @@ -37,6 +37,23 @@ paths = read_ref_tree_paths("feature-foo", prefix) - One git invocation per ref; a ref or prefix without matches yields an empty list. +## Reading one file of a ref + +```python +from goga.history import resolve_history_root +from goga.topics.git import read_ref_file + +path = f"{resolve_history_root().as_posix()}/2026/feature-foo/title.txt" +content = read_ref_file("feature-foo", path) +if content is not None: + print(content.splitlines()[0] if content else "") +``` + +- Returns the file content as text, or None when the file is absent at + the ref — absence is a normal condition, not an error. +- One git invocation per file; no checkout, no worktree, no temp + directory — the working copy stays untouched. + ## Switching branches ```python diff --git a/goga/topics/git/CODEMANIFEST b/goga/topics/git/CODEMANIFEST index 9b8ff1ce..fa7ec483 100644 --- a/goga/topics/git/CODEMANIFEST +++ b/goga/topics/git/CODEMANIFEST @@ -3,10 +3,11 @@ Usages: git: | External git binary invoked via subprocess.run (check=True, capture_output=True). Set GIT_TERMINAL_PROMPT=0 in the env to suppress interactive prompts. - Read-only inspection (branch refs, ref trees, working tree state) plus - host-side mutations (checkout of a local branch, creating a local branch - from a remote-tracking ref, create-and-switch to a new branch). Mock the - subprocess call in tests per `convention`. + Read-only inspection (branch refs, ref tree paths and file contents, + working tree state) plus host-side mutations (checkout of a local + branch, creating a local branch from a remote-tracking ref, + create-and-switch to a new branch). Mock the subprocess call in tests + per `convention`. Annotations: | The `convention` practice is used for: @@ -17,13 +18,13 @@ Annotations: | - Understanding the general principles and rules of development and testing in the project This cell owns git access for the topics domain: enumerating branch refs, - reading the file paths of a ref tree, and the bounded set of host-side - branch mutations — checking out a local branch, creating a local branch - from a remote-tracking ref, creating and switching to a new branch, and - the working-tree cleanliness probe. It is environment access, not topic - logic — every decision belongs to the caller. All git access flows through - the `git` practice; mock the subprocess call in tests per `convention`. - Use relative imports. + reading the file paths and the file contents of a ref tree, and the + bounded set of host-side branch mutations — checking out a local branch, + creating a local branch from a remote-tracking ref, creating and + switching to a new branch, and the working-tree cleanliness probe. It is + environment access, not topic logic — every decision belongs to the + caller. All git access flows through the `git` practice; mock the + subprocess call in tests per `convention`. Use relative imports. --- @@ -107,6 +108,36 @@ Annotations: | directory - Do not inspect file contents — paths only +"read_ref_file(ref: str, path: str) -> content: str | None": + location: trees.py + annotations: | + Read the content of one file of a ref tree — without checkout, worktree, + or temporary directories. + + `ref`: the ref to read — a display branch name as carried by `BranchRef` + `path`: the file path to read, relative to the repository root + `content`: the file content as text, or None when the file is absent at + the ref + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git for the content of `path` at the `ref` + 2. An absent file at the ref yields None — not an error + 3. Return the content as text + + Requirements: + - One git invocation per file + - Read-only — the working copy, the index, and .git stay untouched + - The content is returned as-is — no interpretation, no transformation + + Constraints: + - Do not materialize the tree — no checkout, no worktree, no temp + directory + - Do not list paths — path enumeration belongs to `read_ref_tree_paths` + "checkout_local_branch(branch: str)": location: switch.py annotations: | @@ -203,5 +234,5 @@ Annotations: | Author: Goga CreatedAt: 29/08/26 Description: | - Git access for the topics domain — branch refs, ref tree paths, and the - bounded host-side branch mutations. + Git access for the topics domain — branch refs, ref tree paths and file + contents, and the bounded host-side branch mutations. From 8a8d1057470f5d04edcdbe9849e838f746e3fc13 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 01:35:12 +0000 Subject: [PATCH 102/229] feat: add the new status to the built-in scale axis (nine entries) --- goga/history/statuses/assembly.py | 3 +- goga/history/statuses/scale.py | 9 +++--- tests/history/statuses/conftest.py | 7 ++-- tests/history/statuses/test_assembly.py | 43 +++++++++++++++++++++++++ tests/history/statuses/test_registry.py | 8 ++--- tests/history/statuses/test_scale.py | 30 ++++++++++++----- tests/topics/conftest.py | 7 ++-- 7 files changed, 84 insertions(+), 23 deletions(-) diff --git a/goga/history/statuses/assembly.py b/goga/history/statuses/assembly.py index 1f5c8867..3982971a 100644 --- a/goga/history/statuses/assembly.py +++ b/goga/history/statuses/assembly.py @@ -19,6 +19,7 @@ _BUILTIN_AXIS: list[Stage] = [ Stage(name="empty", filepath=""), + Stage(name="new", filepath="title.txt"), Stage(name="defined", filepath="prd.md"), Stage(name="discovered", filepath="adr.md"), Stage(name="backlog", filepath="task.md"), @@ -36,7 +37,7 @@ def assemble_status_scale() -> StatusScale: scale: The assembled scale. Algorithm: - 1. Build the built-in axis of eight entries + 1. Build the built-in axis of nine entries 2. Enumerate the installed goga_tool_* packages in alphabetical order of package name 3. Import each package — a broken import is a clean error naming diff --git a/goga/history/statuses/scale.py b/goga/history/statuses/scale.py index b01c960a..1af526d5 100644 --- a/goga/history/statuses/scale.py +++ b/goga/history/statuses/scale.py @@ -50,10 +50,11 @@ class StatusScale: stages: The scale content in scale order. Requirements: - The built-in axis is ordered empty, defined, discovered, backlog, - designed, specified, planned, done by the artifacts prd.md, adr.md, - task.md, arch.md, design.md, plan.md, completed/plan.md; a tool - status never reorders or replaces a built-in one. + The built-in axis is ordered empty, new, defined, discovered, + backlog, designed, specified, planned, done by the artifacts + title.txt, prd.md, adr.md, task.md, arch.md, design.md, plan.md, + completed/plan.md; a tool status never reorders or replaces a + built-in one. """ stages: list[Stage] diff --git a/tests/history/statuses/conftest.py b/tests/history/statuses/conftest.py index 0dbd42c3..42533e60 100644 --- a/tests/history/statuses/conftest.py +++ b/tests/history/statuses/conftest.py @@ -8,14 +8,15 @@ @pytest.fixture def builtin_scale() -> StatusScale: - """Deterministic built-in scale — eight entries with the contract artifacts. + """Deterministic built-in scale — nine entries with the contract artifacts. - The deepening order is the contract: empty, defined, discovered, backlog, - designed, specified, planned, done. + The deepening order is the contract: empty, new, defined, discovered, + backlog, designed, specified, planned, done. """ return StatusScale( stages=[ Stage(name="empty", filepath=""), + Stage(name="new", filepath="title.txt"), Stage(name="defined", filepath="prd.md"), Stage(name="discovered", filepath="adr.md"), Stage(name="backlog", filepath="task.md"), diff --git a/tests/history/statuses/test_assembly.py b/tests/history/statuses/test_assembly.py index ae9c231f..18d59ff5 100644 --- a/tests/history/statuses/test_assembly.py +++ b/tests/history/statuses/test_assembly.py @@ -26,6 +26,7 @@ _BUILTIN_NAMES = [ "empty", + "new", "defined", "discovered", "backlog", @@ -118,6 +119,26 @@ def test_routine_does_not_cache_across_runs(self, monkeypatch: pytest.MonkeyPatc class TestAssembleBuiltinAxis: + def test_assemble_status_scale_builds_nine_entry_axis(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The built-in axis counts nine entries — ``new``/``title.txt`` second, no regress to eight.""" + _packages(monkeypatch) + + scale = assemble_status_scale() + + assert _names(scale)[:9] == [ + "empty", + "new", + "defined", + "discovered", + "backlog", + "designed", + "specified", + "planned", + "done", + ] + assert scale.stages[1].filepath == "title.txt" + assert len(scale.stages) == 9 + def test_assemble_builtin_axis_order(self, monkeypatch: pytest.MonkeyPatch) -> None: """No tool packages — the pure built-in axis in the contract order.""" _packages(monkeypatch) @@ -127,6 +148,7 @@ def test_assemble_builtin_axis_order(self, monkeypatch: pytest.MonkeyPatch) -> N assert _names(scale) == _BUILTIN_NAMES assert [stage.filepath for stage in scale.stages] == [ "", + "title.txt", "prd.md", "adr.md", "task.md", @@ -180,6 +202,27 @@ def test_assemble_both_anchors_range(self, monkeypatch: pytest.MonkeyPatch) -> N names = _names(scale) assert names.index("discovered") < names.index("a.x") < names.index("backlog") + def test_assembly_anchors_around_new_axis( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """Anchors around ``empty``/``new``/``defined`` stay resolvable on the nine-entry axis.""" + _install_package( + monkeypatch, + "goga_tool_x", + _registering( + {"name": "ranged", "filepath": "x/ranged.md", "after": "empty", "before": "defined"}, + {"name": "afternew", "filepath": "x/afternew.md", "after": "new"}, + ), + ) + _packages(monkeypatch, "goga_tool_x") + + scale = assemble_status_scale() + + names = _names(scale) + assert names.index("empty") < names.index("x.ranged") < names.index("defined") + assert names.index("new") < names.index("x.afternew") < names.index("defined") + assert capsys.readouterr().err == "" + def test_assemble_invalid_anchor_range_skips_with_warning( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tests/history/statuses/test_registry.py b/tests/history/statuses/test_registry.py index 035f24dd..03077765 100644 --- a/tests/history/statuses/test_registry.py +++ b/tests/history/statuses/test_registry.py @@ -77,8 +77,8 @@ def test_register_qualifies_name_and_appends(self, builtin_scale: StatusScale) - assert [s.name for s in registry.stages][-1] == "mkdocs.published" assert len(registry.stages) == before + 1 - # The built-in part is untouched — same eight names in the same order. - assert [s.name for s in registry.stages[:8]] == [s.name for s in builtin_scale.stages] + # The built-in part is untouched — same nine names in the same order. + assert [s.name for s in registry.stages[:9]] == [s.name for s in builtin_scale.stages] def test_register_stores_anchors_verbatim(self, builtin_scale: StatusScale) -> None: """Both anchors are carried as given — resolution is not done here.""" @@ -108,7 +108,7 @@ def test_register_duplicate_qualified_name_raises(self, builtin_scale: StatusSca with pytest.raises(ValueError, match=r"mkdocs\.published"): registry.register("published", "mkdocs/published.md", after="planned") - assert len(registry.stages) == 9 + assert len(registry.stages) == 10 @pytest.mark.parametrize( ("name", "filepath"), @@ -132,4 +132,4 @@ def test_stages_returns_a_copy(self, builtin_scale: StatusScale) -> None: issued = registry.stages issued.append(Stage(name="tamper", filepath="tamper.md", after="planned")) - assert len(registry.stages) == 9 + assert len(registry.stages) == 10 diff --git a/tests/history/statuses/test_scale.py b/tests/history/statuses/test_scale.py index f80ed692..b7fbb99a 100644 --- a/tests/history/statuses/test_scale.py +++ b/tests/history/statuses/test_scale.py @@ -23,10 +23,10 @@ def _tool_extended_scale(builtin_scale: StatusScale) -> StatusScale: """Builtin axis plus two tool entries anchored after ``planned`` in package order.""" return StatusScale( stages=[ - *builtin_scale.stages[:7], # empty .. planned + *builtin_scale.stages[:8], # empty .. planned Stage(name="mkdocs.published", filepath="mkdocs/published.md", after="planned"), Stage(name="scriba.translated", filepath="scriba/translated.md", after="planned"), - builtin_scale.stages[7], # done + builtin_scale.stages[8], # done ], ) @@ -118,6 +118,20 @@ def test_maximal_present_empty_when_no_artifacts(self, builtin_scale: StatusScal assert builtin_scale.maximal_present([]) == ["empty"] assert builtin_scale.maximal_present(["notes.txt"]) == ["empty"] + def test_maximal_present_title_only_is_new(self, builtin_scale: StatusScale) -> None: + """The title artifact alone marks the built-in ``new`` entry.""" + assert builtin_scale.maximal_present(["title.txt"]) == ["new"] + + def test_maximal_present_title_with_prd_is_defined(self, builtin_scale: StatusScale) -> None: + """``title.txt`` below ``prd.md`` — the maximal entry wins, ``new`` is not duplicated.""" + assert builtin_scale.maximal_present(["title.txt", "prd.md"]) == ["defined"] + + def test_maximal_present_empty_and_title_interplay(self, builtin_scale: StatusScale) -> None: + """``empty`` against ``new``: no artifact and an off-scale artifact stay ``empty``.""" + assert builtin_scale.maximal_present([]) == ["empty"] + assert builtin_scale.maximal_present(["notes.txt"]) == ["empty"] + assert builtin_scale.maximal_present(["title.txt"]) == ["new"] + def test_maximal_present_two_incomparable_tool_statuses(self, builtin_scale: StatusScale) -> None: """Two tool entries sharing an anchor are incomparable — both stay maximal.""" scale = _tool_extended_scale(builtin_scale) @@ -129,9 +143,9 @@ def test_maximal_present_before_anchored_entry_stays_below_anchor(self, builtin_ """A ``before``-anchored tool entry is strictly below its anchor.""" scale = StatusScale( stages=[ - *builtin_scale.stages[:7], # empty .. planned + *builtin_scale.stages[:8], # empty .. planned Stage(name="tool.review", filepath="review.md", before="done"), - builtin_scale.stages[7], # done + builtin_scale.stages[8], # done ], ) @@ -145,9 +159,9 @@ def test_maximal_present_range_entry_between_its_anchors(self, builtin_scale: St """A both-anchored range entry outranks its ``after`` and yields to its ``before``.""" scale = StatusScale( stages=[ - *builtin_scale.stages[:7], # empty .. planned + *builtin_scale.stages[:8], # empty .. planned Stage(name="tool.range", filepath="range.md", after="defined", before="done"), - builtin_scale.stages[7], # done + builtin_scale.stages[8], # done ], ) @@ -160,10 +174,10 @@ def test_maximal_present_after_chain_is_transitive(self, builtin_scale: StatusSc """A chain of ``after`` anchors outranks transitively — the deepest wins.""" scale = StatusScale( stages=[ - *builtin_scale.stages[:7], # empty .. planned + *builtin_scale.stages[:8], # empty .. planned Stage(name="tool.first", filepath="first.md", after="planned"), Stage(name="tool.second", filepath="second.md", after="tool.first"), - builtin_scale.stages[7], # done + builtin_scale.stages[8], # done ], ) diff --git a/tests/topics/conftest.py b/tests/topics/conftest.py index 3f8057bd..37abb308 100644 --- a/tests/topics/conftest.py +++ b/tests/topics/conftest.py @@ -8,14 +8,15 @@ @pytest.fixture def builtin_scale() -> StatusScale: - """Deterministic built-in scale — eight entries with the contract artifacts. + """Deterministic built-in scale — nine entries with the contract artifacts. - The deepening order is the contract: empty, defined, discovered, backlog, - designed, specified, planned, done. + The deepening order is the contract: empty, new, defined, discovered, + backlog, designed, specified, planned, done. """ return StatusScale( stages=[ Stage(name="empty", filepath=""), + Stage(name="new", filepath="title.txt"), Stage(name="defined", filepath="prd.md"), Stage(name="discovered", filepath="adr.md"), Stage(name="backlog", filepath="task.md"), From baddbf46d678ff142480a8a0e25e72a30833a385 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 01:40:27 +0000 Subject: [PATCH 103/229] feat: add the branch inventory module to the history git cell --- goga/history/git/__init__.py | 5 +- goga/history/git/refs.py | 98 ++++++++++++++++++ tests/history/git/test_branch.py | 11 ++- tests/history/git/test_refs.py | 164 +++++++++++++++++++++++++++++++ 4 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 goga/history/git/refs.py create mode 100644 tests/history/git/test_refs.py diff --git a/goga/history/git/__init__.py b/goga/history/git/__init__.py index c424a589..adf25997 100644 --- a/goga/history/git/__init__.py +++ b/goga/history/git/__init__.py @@ -1,5 +1,6 @@ -"""Git-environment introspection cell for the history domain — the branch reader.""" +"""Git-environment introspection cell for the history domain — the branch reader, the branch inventory.""" from .branch import resolve_current_branch_name +from .refs import BranchRef, list_branch_refs -__all__: list[str] = ["resolve_current_branch_name"] +__all__: list[str] = ["BranchRef", "list_branch_refs", "resolve_current_branch_name"] diff --git a/goga/history/git/refs.py b/goga/history/git/refs.py new file mode 100644 index 00000000..4a579895 --- /dev/null +++ b/goga/history/git/refs.py @@ -0,0 +1,98 @@ +"""The branch-ref inventory of the history-domain git cell. + +The entities declared in the cell CODEMANIFEST with ``location: refs.py``: +one branch ref of the repository inventory — a local branch or a +remote-tracking ref — and the read-only enumerator that merges both kinds +into one alphabetically sorted inventory. Every git invocation follows the +``git`` practice — ``subprocess.run`` with ``check=True``, captured output, +and ``GIT_TERMINAL_PROMPT=0`` in the environment. +""" + +from __future__ import annotations + +import os +import subprocess +from dataclasses import dataclass + + +@dataclass(frozen=True, kw_only=True) +class BranchRef: + """One branch ref of the repository inventory. + + Attributes: + name: The display name — the short branch name for a local ref, + ``<remote>/<branch>`` for a remote-tracking ref. + remote: ``True`` when the ref is remote-tracking. + + Requirements: + The display name is the identity used by consumers — no + reshortening, no normalization. + """ + + name: str + remote: bool + + +def list_branch_refs() -> list[BranchRef]: + """Enumerate the branch refs of the repository. + + Asks git for the local branches and the remote-tracking refs (as they + exist locally — no network), drops the ``*/HEAD`` symrefs, and merges + both answers into one inventory sorted alphabetically by display name. + A local branch and its remote twin stay two distinct refs — collapsing + them belongs to the caller. + + Returns: + Every branch ref, sorted alphabetically by display name. + + Algorithm: + 1. Ask git for the local branch refs + 2. Ask git for the remote-tracking refs + 3. Merge both into one inventory sorted alphabetically by display + name + + Requirements: + Read-only — no ref is created, moved, or deleted. + + No network — remote-tracking refs as they exist locally. + + Constraints: + Do not deduplicate — a local branch and its remote twin are two + distinct refs here; collapsing them belongs to the caller. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + ref listing itself (propagated — the caller wraps it). + OSError: unexpected OS-level failures of the git invocations (e.g. a + missing git binary). + """ + local = _refs_under("refs/heads", remote=False) + tracked = _refs_under("refs/remotes", remote=True) + return sorted([*local, *tracked], key=lambda ref: ref.name) + + +def _refs_under(ref_prefix: str, remote: bool) -> list[BranchRef]: + """Run one ``for-each-ref`` invocation and parse it into branch refs. + + Args: + ref_prefix: The ref namespace to list — ``refs/heads`` or + ``refs/remotes``. + remote: Whether the listed refs are remote-tracking. + + Returns: + The parsed refs of the namespace, in git order. Refs whose display + name ends with ``/HEAD`` (the ``<remote>/HEAD`` symrefs) are not + branches and are dropped. + """ + result = subprocess.run( + ["git", "for-each-ref", "--format=%(refname:short)", ref_prefix], + check=True, + capture_output=True, + text=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) + return [ + BranchRef(name=line, remote=remote) + for line in result.stdout.splitlines() + if line and not line.endswith("/HEAD") + ] diff --git a/tests/history/git/test_branch.py b/tests/history/git/test_branch.py index 5bc077e0..7ef97673 100644 --- a/tests/history/git/test_branch.py +++ b/tests/history/git/test_branch.py @@ -5,6 +5,9 @@ with the three documented None modes (detached HEAD, missing git binary, non-repository) +The cell's second module — the branch inventory of ``refs.py`` — is covered +by ``tests/history/git/test_refs.py``. + Git is mocked at the subprocess boundary per the ``git`` practice — ``mock.patch.object(branch_module.subprocess, "run")`` — never as a git double. """ @@ -82,10 +85,14 @@ def test_routine_is_importable_from_facade_and_callable(self) -> None: assert branch_module.resolve_current_branch_name is resolve_current_branch_name def test_facade_all_lists_the_routine(self) -> None: - """The cell facade exports exactly the one declared name.""" + """The cell facade exports the reader and the inventory, sorted.""" import goga.history.git - assert goga.history.git.__all__ == ["resolve_current_branch_name"] + assert goga.history.git.__all__ == [ + "BranchRef", + "list_branch_refs", + "resolve_current_branch_name", + ] def test_resolve_current_branch_name_signature(self) -> None: """``resolve_current_branch_name() -> str | None`` — no parameters.""" diff --git a/tests/history/git/test_refs.py b/tests/history/git/test_refs.py new file mode 100644 index 00000000..bb630c8f --- /dev/null +++ b/tests/history/git/test_refs.py @@ -0,0 +1,164 @@ +"""Contract and logic tests for the entities declared in +``goga/history/git/CODEMANIFEST`` with ``location: refs.py``: + +- ``BranchRef(name, remote)`` — one branch ref of the repository inventory +- ``list_branch_refs()`` — the read-only enumerator merging local branches + and remote-tracking refs into one sorted inventory + +The module mirrors ``goga/topics/git/refs.py`` (the cells are unlinked by +design — no imports between them); the mirror equivalence is pinned by +``test_history_git_inventory_matches_topics_git``. The subprocess call is +mocked at the import point per the ``convention`` practice — no git binary +and no repository are touched. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import os +import subprocess +import typing +from collections.abc import Callable +from unittest import mock + +import pytest +from goga.history.git import BranchRef, list_branch_refs + + +def _git_answer(stdout: str) -> subprocess.CompletedProcess[str]: + """A successful ``for-each-ref`` invocation answering ``stdout``.""" + return subprocess.CompletedProcess(args=["git"], returncode=0, stdout=stdout, stderr="") + + +def _answering_run(heads: str = "", remotes: str = "") -> Callable[..., subprocess.CompletedProcess[str]]: + """A ``subprocess.run`` mock answering by the requested ref prefix.""" + + def run(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + outputs = {"refs/heads": heads, "refs/remotes": remotes} + return _git_answer(outputs[command[-1]]) + + return run + + +# --- Contract tests --- + + +class TestRefsContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """``BranchRef`` and ``list_branch_refs`` live on the cell facade.""" + import goga.history.git as cell + + assert cell.BranchRef is BranchRef + assert cell.list_branch_refs is list_branch_refs + assert "BranchRef" in cell.__all__ + assert "list_branch_refs" in cell.__all__ + + def test_facade_all_lists_the_three_declared_names(self) -> None: + """The facade exports the branch reader and the branch inventory.""" + import goga.history.git + + assert goga.history.git.__all__ == [ + "BranchRef", + "list_branch_refs", + "resolve_current_branch_name", + ] + + def test_branch_ref_is_a_frozen_kw_only_dataclass(self) -> None: + """``BranchRef(name=..., remote=...)`` — frozen, keyword-only.""" + ref = BranchRef(name="feat/a", remote=False) + + assert ref.name == "feat/a" + assert ref.remote is False + + with pytest.raises(TypeError): + BranchRef("feat/a", False) # type: ignore[misc] + with pytest.raises(dataclasses.FrozenInstanceError): + ref.name = "renamed" # type: ignore[misc] + + def test_branch_ref_declares_name_and_remote_only(self) -> None: + """The record carries exactly the two declared fields.""" + fields = {field.name for field in dataclasses.fields(BranchRef)} + assert fields == {"name", "remote"} + + def test_list_branch_refs_signature(self) -> None: + """``list_branch_refs() -> list[BranchRef]`` — no parameters.""" + signature = inspect.signature(list_branch_refs) + assert list(signature.parameters) == [] + hints = typing.get_type_hints(list_branch_refs) + assert hints == {"return": list[BranchRef]} + + def test_git_invocations_follow_the_git_practice(self) -> None: + """Two ``for-each-ref`` calls — check/capture/text and a muted prompt.""" + run = mock.Mock(side_effect=_answering_run()) + with mock.patch("goga.history.git.refs.subprocess.run", run): + list_branch_refs() + + assert run.call_count == 2 + for call in run.call_args_list: + command = call.args[0] + assert command[:2] == ["git", "for-each-ref"] + assert "--format=%(refname:short)" in command + assert call.kwargs["check"] is True + assert call.kwargs["capture_output"] is True + assert call.kwargs["text"] is True + assert call.kwargs["env"] == {**os.environ, "GIT_TERMINAL_PROMPT": "0"} + prefixes = [call.args[0][-1] for call in run.call_args_list] + assert prefixes == ["refs/heads", "refs/remotes"] + + +# --- Logic tests --- + + +class TestListBranchRefs: + def test_list_branch_refs_merges_and_sorts(self) -> None: + """Local and remote refs merge sorted by display; ``*/HEAD`` dropped.""" + run = mock.Mock( + side_effect=_answering_run( + heads="main\nfeat/a\n", + remotes="origin/HEAD\norigin/feat/a\n", + ) + ) + with mock.patch("goga.history.git.refs.subprocess.run", run): + refs = list_branch_refs() + + assert [ref.name for ref in refs] == ["feat/a", "main", "origin/feat/a"] + assert [ref.remote for ref in refs] == [False, False, True] + assert run.call_count == 2 + for call in run.call_args_list: + assert call.kwargs["check"] is True + assert call.kwargs["capture_output"] is True + assert call.kwargs["text"] is True + assert call.kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0" + + def test_history_git_inventory_matches_topics_git(self) -> None: + """The mirror implementations answer identically for one inventory.""" + from goga.history.git import refs as history_refs + from goga.topics.git import refs as topics_refs + + run = mock.Mock( + side_effect=_answering_run( + heads="main\nfeat/a\n", + remotes="origin/HEAD\norigin/feat/a\n", + ) + ) + with ( + mock.patch("goga.history.git.refs.subprocess.run", run), + mock.patch("goga.topics.git.refs.subprocess.run", run), + ): + history_result = history_refs.list_branch_refs() + topics_result = topics_refs.list_branch_refs() + + assert [ref.name for ref in history_result] == [ref.name for ref in topics_result] + assert [(ref.name, ref.remote) for ref in history_result] == [ + (ref.name, ref.remote) for ref in topics_result + ] + + def test_list_branch_refs_empty_repository(self) -> None: + """An empty inventory is the norm, answered by exactly two calls.""" + run = mock.Mock(side_effect=_answering_run(heads="", remotes="")) + with mock.patch("goga.history.git.refs.subprocess.run", run): + refs = list_branch_refs() + + assert refs == [] + assert run.call_count == 2 From 15a58d2fa8c9c6115da1ec2507b58ded9e823c0e Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 01:43:57 +0000 Subject: [PATCH 104/229] feat: add read_ref_file to the topics git cell (ref-tree file content without checkout) --- goga/topics/git/__init__.py | 14 ++++--- goga/topics/git/trees.py | 64 +++++++++++++++++++++++++++++--- tests/topics/git/test_trees.py | 67 +++++++++++++++++++++++++++++++++- 3 files changed, 132 insertions(+), 13 deletions(-) diff --git a/goga/topics/git/__init__.py b/goga/topics/git/__init__.py index 4a3a9c73..b5b15436 100644 --- a/goga/topics/git/__init__.py +++ b/goga/topics/git/__init__.py @@ -1,10 +1,11 @@ """Git-access cell for the topics domain. -The branch-ref inventory, the file-path reading of a ref tree, and the -bounded set of host-side branch mutations — checking out a local branch, -creating a local branch from a remote-tracking ref, create-and-switch to a -new branch, and the working-tree cleanliness probe. It is environment -access, not topic logic — every decision belongs to the caller. +The branch-ref inventory, the file-path reading of a ref tree, the file +contents of a ref tree, and the bounded set of host-side branch mutations +— checking out a local branch, creating a local branch from a +remote-tracking ref, create-and-switch to a new branch, and the +working-tree cleanliness probe. It is environment access, not topic +logic — every decision belongs to the caller. """ from .refs import BranchRef, list_branch_refs @@ -14,7 +15,7 @@ create_branch_from_remote_tracking, is_working_tree_clean, ) -from .trees import read_ref_tree_paths +from .trees import read_ref_file, read_ref_tree_paths __all__: list[str] = [ "BranchRef", @@ -23,5 +24,6 @@ "create_branch_from_remote_tracking", "is_working_tree_clean", "list_branch_refs", + "read_ref_file", "read_ref_tree_paths", ] diff --git a/goga/topics/git/trees.py b/goga/topics/git/trees.py index b62a8dcc..ef6cc733 100644 --- a/goga/topics/git/trees.py +++ b/goga/topics/git/trees.py @@ -1,10 +1,10 @@ """The ref-tree reading of the topics-domain git cell. -The entity declared in the cell CODEMANIFEST with ``location: trees.py``: -the file paths of one ref tree under a path prefix. ``ls-tree`` walks the -object database of the repository — no checkout, no worktree, no -temporary directory — and every git invocation follows the ``git`` -practice. +The entities declared in the cell CODEMANIFEST with ``location: trees.py``: +the file paths of one ref tree under a path prefix, and the file contents +of one file of a ref tree. ``ls-tree`` and ``show`` walk the object +database of the repository — no checkout, no worktree, no temporary +directory — and every git invocation follows the ``git`` practice. """ from __future__ import annotations @@ -60,3 +60,57 @@ def read_ref_tree_paths(ref: str, prefix: str) -> list[str]: env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, ) return [path for path in result.stdout.splitlines() if path and path.startswith(prefix)] + + +def read_ref_file(ref: str, path: str) -> str | None: + """Read the content of one file of a ref tree. + + Args: + ref: The ref to read — a display branch name as carried by + :class:`~goga.topics.git.refs.BranchRef`. + path: The file path to read, relative to the repository root. + + Returns: + The file content as text, or None when the file is absent at + the ref. + + Algorithm: + 1. Ask git for the content of ``path`` at the ``ref`` + 2. An absent file at the ref yields None — not an error + 3. Return the content as text + + Requirements: + One git invocation per file. + + Read-only — the working copy, the index, and ``.git`` stay + untouched. + + The content is returned as-is — no interpretation, no + transformation. The content is UTF-8 by the creation contract, so + the invocation decodes UTF-8 explicitly — locale decoding breaks + on non-ASCII content under the C/POSIX locale. + + Constraints: + Do not materialize the tree — no checkout, no worktree, no temp + directory. + + Do not list paths — path enumeration belongs to + :func:`read_ref_tree_paths`. + + Raises: + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + try: + result = subprocess.run( + ["git", "show", f"{ref}:{path}"], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) + except subprocess.CalledProcessError: + return None + + return result.stdout diff --git a/tests/topics/git/test_trees.py b/tests/topics/git/test_trees.py index b969d419..2d18d82c 100644 --- a/tests/topics/git/test_trees.py +++ b/tests/topics/git/test_trees.py @@ -4,6 +4,8 @@ - ``read_ref_tree_paths(ref, prefix)`` — the read-only file listing of one ref tree under a path prefix, without checkout, worktree, or temp directories +- ``read_ref_file(ref, path)`` — the read-only content of one file of a + ref tree, without checkout, worktree, or temp directories The subprocess call is mocked at the import point per the ``convention`` practice — no git binary and no repository are touched. @@ -11,15 +13,17 @@ from __future__ import annotations +import inspect import os import subprocess +import typing from unittest import mock -from goga.topics.git import read_ref_tree_paths +from goga.topics.git import read_ref_file, read_ref_tree_paths def _git_answer(stdout: str) -> subprocess.CompletedProcess[str]: - """A successful ``ls-tree`` invocation answering ``stdout``.""" + """A successful git invocation answering ``stdout``.""" return subprocess.CompletedProcess(args=["git"], returncode=0, stdout=stdout, stderr="") @@ -63,6 +67,28 @@ def test_git_invocation_follows_the_git_practice(self) -> None: assert kwargs["text"] is True assert kwargs["env"] == {**os.environ, "GIT_TERMINAL_PROMPT": "0"} + def test_file_entity_is_importable_from_the_cell_facade(self) -> None: + """``read_ref_file`` lives on the eight-name cell facade.""" + import goga.topics.git as cell + + assert cell.read_ref_file is read_ref_file + assert "read_ref_file" in cell.__all__ + assert cell.__all__ == sorted(cell.__all__) + assert len(cell.__all__) == 8 + + def test_file_signature_takes_ref_and_path_and_returns_optional_str(self) -> None: + """``read_ref_file(ref: str, path: str) -> str | None``.""" + signature = inspect.signature(read_ref_file) + + assert list(signature.parameters) == ["ref", "path"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + + hints = typing.get_type_hints(read_ref_file) + assert hints == {"ref": str, "path": str, "return": str | None} + # --- Logic tests --- @@ -89,3 +115,40 @@ def test_read_ref_tree_paths_keeps_git_order(self) -> None: result = read_ref_tree_paths("feat-a", ".goga/history/") assert result == [".goga/history/2026/feat-a/plan.md", ".goga/history/2026/feat-a/prd.md"] + + +class TestReadRefFile: + def test_read_ref_file_returns_content_as_is(self) -> None: + """The content returns as-is — one ``git show``, UTF-8, muted prompt.""" + run = mock.Mock(return_value=_git_answer("Payment retry\n")) + with mock.patch("goga.topics.git.trees.subprocess.run", run): + content = read_ref_file("feat/a", ".goga/history/2026/feat-a/title.txt") + + assert content == "Payment retry\n" + assert run.call_count == 1 + command = run.call_args.args[0] + assert command == ["git", "show", "feat/a:.goga/history/2026/feat-a/title.txt"] + kwargs = run.call_args.kwargs + assert kwargs["check"] is True + assert kwargs["capture_output"] is True + assert kwargs["text"] is True + assert kwargs["encoding"] == "utf-8" + assert kwargs["env"] == {**os.environ, "GIT_TERMINAL_PROMPT": "0"} + + def test_read_ref_file_absent_file_returns_none(self) -> None: + """An absent file at the ref yields None — not an error.""" + failure = subprocess.CalledProcessError(1, ["git", "show"], stderr="fatal: path 'x' does not exist") + + with mock.patch("goga.topics.git.trees.subprocess.run", side_effect=failure): + content = read_ref_file("feat/a", ".goga/history/2026/feat-a/absent.md") + + assert content is None + + def test_read_ref_file_empty_file_returns_empty_string(self) -> None: + """An empty file is present — ``""`` differs from absence (``None``).""" + run = mock.Mock(return_value=_git_answer("")) + with mock.patch("goga.topics.git.trees.subprocess.run", run): + content = read_ref_file("feat/a", ".goga/history/2026/feat-a/title.txt") + + assert content == "" + assert run.call_count == 1 From fa151112ec734dc4e03f390a3ba00986452b8eb6 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 01:48:09 +0000 Subject: [PATCH 105/229] feat: add remove_topic_dir to the history paths module (idempotent topic directory removal) --- goga/history/paths.py | 39 ++++++++++++++++++-- tests/history/test_paths.py | 72 ++++++++++++++++++++++++++++++++++--- 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/goga/history/paths.py b/goga/history/paths.py index 46dfdb8e..f2db7a25 100644 --- a/goga/history/paths.py +++ b/goga/history/paths.py @@ -3,12 +3,14 @@ The routines declared in the cell CODEMANIFEST with ``location: paths.py``: the public history-root composer (the private helper delegates to it), the two pure path composers (topic directory and artifact file), the read-only -occupancy oracle, and the idempotent directory creator. Composers never touch -the filesystem — creation belongs to ``ensure_topic_dir`` alone. +occupancy oracle, the idempotent directory creator, and the idempotent +directory remover. Composers never touch the filesystem — creation belongs +to ``ensure_topic_dir`` alone, deletion to ``remove_topic_dir`` alone. """ from __future__ import annotations +import shutil from pathlib import Path, PurePath from .naming import current_year, normalize_topic_slug @@ -127,3 +129,36 @@ def ensure_topic_dir(name: str, year: str | None = None) -> Path: topic_dir = resolve_topic_dir(name, year) topic_dir.mkdir(parents=True, exist_ok=True) return topic_dir + + +def remove_topic_dir(name: str, year: str | None = None) -> bool: + """Delete the directory of a history topic of a year. + + The whole topic directory goes — every artifact including nested + directories such as ``completed/`` — and nothing else: sibling topic + directories and the year directory itself stay untouched. Deciding + whether a topic deserves deletion belongs to the caller; this routine + only executes the decision. A pure filesystem mutation — no git branch, + ref, or index is touched. A stray file named like the slug does not + occupy a topic (the ``topic_exists`` semantics), so it yields False + and stays in place. + + Args: + name: Topic input — a branch name or an already-normalized slug. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + True when the topic directory existed and was deleted, False when + it was absent. + + Raises: + ValueError: The name normalizes to an empty slug (the directory + composer's error). + OSError: Propagated from ``rmtree`` — unexpected OS failures are + not swallowed. + """ + topic_dir = resolve_topic_dir(name, year) + if not topic_dir.is_dir(): + return False + shutil.rmtree(topic_dir) + return True diff --git a/tests/history/test_paths.py b/tests/history/test_paths.py index b85c8637..5a2fb636 100644 --- a/tests/history/test_paths.py +++ b/tests/history/test_paths.py @@ -6,11 +6,13 @@ - ``resolve_topic_file(topic: str, filename: str, year: str | None = None) -> Path`` - ``topic_exists(topic: str, year: str | None = None) -> bool`` - ``ensure_topic_dir(name: str, year: str | None = None) -> Path`` +- ``remove_topic_dir(name: str, year: str | None = None) -> bool`` The path composers are pure with respect to the filesystem; ``ensure_topic_dir`` -is the only mutating routine. The single mock target is ``naming.datetime`` -(the mandated bare-``now()`` point), patched at the import site; filesystem -fixtures use ``tmp_path`` + ``monkeypatch.chdir``. +and ``remove_topic_dir`` are the mutating routines — creation and deletion. +The single mock target is ``naming.datetime`` (the mandated bare-``now()`` +point), patched at the import site; filesystem fixtures use ``tmp_path`` + +``monkeypatch.chdir``. """ from __future__ import annotations @@ -25,6 +27,7 @@ from goga.history import naming, paths from goga.history.paths import ( ensure_topic_dir, + remove_topic_dir, resolve_history_root, resolve_topic_dir, resolve_topic_file, @@ -45,17 +48,19 @@ def now() -> datetime: class TestPathsContract: def test_routines_are_importable_from_module_and_callable(self) -> None: - """All five routines are importable from ``goga.history.paths`` and callable.""" + """All six routines are importable from ``goga.history.paths`` and callable.""" assert callable(resolve_history_root) assert callable(resolve_topic_dir) assert callable(resolve_topic_file) assert callable(topic_exists) assert callable(ensure_topic_dir) + assert callable(remove_topic_dir) assert paths.resolve_history_root is resolve_history_root assert paths.resolve_topic_dir is resolve_topic_dir assert paths.resolve_topic_file is resolve_topic_file assert paths.topic_exists is topic_exists assert paths.ensure_topic_dir is ensure_topic_dir + assert paths.remove_topic_dir is remove_topic_dir def test_facade_reexports_the_paths_names(self) -> None: """The paths routines are importable from the domain facade.""" @@ -132,6 +137,20 @@ def test_ensure_topic_dir_signature(self) -> None: bound = inspect.signature(ensure_topic_dir).bind(name="X", year="2025") assert bound.arguments == {"name": "X", "year": "2025"} + def test_remove_topic_dir_signature(self) -> None: + """``remove_topic_dir(name: str, year: str | None = None) -> bool`` — year is a kwarg.""" + signature = inspect.signature(remove_topic_dir) + assert list(signature.parameters) == ["name", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(remove_topic_dir) + assert hints == {"name": str, "year": str | None, "return": bool} + bound = inspect.signature(remove_topic_dir).bind(name="X", year="2025") + assert bound.arguments == {"name": "X", "year": "2025"} + def test_history_root_helper_points_at_the_tree(self) -> None: """The private helper delegates to the public composer — one source of the root.""" assert paths._history_root() == Path(".goga") / "history" @@ -299,3 +318,48 @@ def test_ensure_topic_dir_stray_file_propagates_oserror( with pytest.raises(OSError, match="feat-x"): ensure_topic_dir("feat-x", year="2026") + + +class TestRemoveTopicDir: + def test_remove_topic_dir_deletes_whole_directory( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The whole directory goes — nested ``completed/`` with it; the year directory stays.""" + monkeypatch.chdir(tmp_path) + topic_dir = tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" + (topic_dir / "completed").mkdir(parents=True) + (topic_dir / "prd.md").write_text("problem", encoding="utf-8") + (topic_dir / "completed" / "plan.md").write_text("plan", encoding="utf-8") + + assert remove_topic_dir("Feature/Foo_Bar", "2026") is True + assert not topic_dir.exists() + assert not (topic_dir / "completed").exists() + assert (tmp_path / ".goga" / "history" / "2026").is_dir() + + def test_remove_topic_dir_absent_returns_false( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An absent directory is idempotent absence — False, not an error.""" + monkeypatch.chdir(tmp_path) + assert remove_topic_dir("absent-topic", "2026") is False + assert not (tmp_path / ".goga").exists() + + def test_remove_topic_dir_stray_file_returns_false( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A stray file named like the slug does not occupy a topic — it stays in place.""" + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + year_dir.mkdir(parents=True) + (year_dir / "feat-a").write_text("not a topic", encoding="utf-8") + + assert remove_topic_dir("feat-a", "2026") is False + assert (year_dir / "feat-a").is_file() + + def test_remove_topic_dir_empty_slug_raises( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An empty slug is the directory composer's clean error — nothing is deleted.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="normalizes to an empty topic slug"): + remove_topic_dir("", "2026") From abc520235f6d0e204a24c764e7eed75c0f4c12f3 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 01:54:27 +0000 Subject: [PATCH 106/229] feat: add prune_topics to the history cell (orphan cleanup of one year) --- goga/history/prune.py | 94 +++++++++++++ tests/history/test_prune.py | 266 ++++++++++++++++++++++++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 goga/history/prune.py create mode 100644 tests/history/test_prune.py diff --git a/goga/history/prune.py b/goga/history/prune.py new file mode 100644 index 00000000..0ee8f92d --- /dev/null +++ b/goga/history/prune.py @@ -0,0 +1,94 @@ +"""Orphan-topic cleanup for the history domain. + +The routine declared in the cell CODEMANIFEST with ``location: prune.py``: +the orphan computation and cleanup of one year of the history tree. This +module owns the orphan decision alone — the tree inventory comes from the +tree collector, the branch inventory from the nested git cell, and the +deletion itself from the directory remover. Filesystem-only: the single git +invocation of the flow is the read-only ref listing, and no branch, ref, or +index is mutated in any mode. +""" + +from __future__ import annotations + +from .git import list_branch_refs +from .naming import current_year, normalize_topic_slug +from .paths import remove_topic_dir +from .tree import collect_history_tree + + +def prune_topics(year: str | None = None, dry_run: bool = False) -> list[str]: + """Delete the orphan topics of one year — the topics no branch of the + repository inventory hosts. + + Args: + year: Optional year as four digits; ``None`` means the current year. + dry_run: ``True`` lists the orphan topics without deleting anything. + + Returns: + The slugs of the orphan topics sorted alphabetically — the deleted + ones, or the deletion candidates under ``dry_run``. + + Algorithm: + 1. Resolve the year — ``year`` when given, otherwise the current year + 2. Take the topics of the resolved year from the tree collected via + ``collect_history_tree`` — an absent year yields no topics + 3. Enumerate the repository branch inventory via ``list_branch_refs`` + 4. Build the hosted slug set: every local branch name normalized via + ``normalize_topic_slug``, and the short name of every + remote-tracking ref — the part after the first ``/`` — normalized + the same way + 5. The orphans are the year's topics whose slug is not in the hosted + set + 6. ``dry_run`` False -> delete every orphan directory via + ``remove_topic_dir``; True -> delete nothing + 7. Return the orphan slugs sorted alphabetically + + Requirements: + A topic is protected when at least one branch of the inventory + normalizes to its slug — the protection is year-independent, a + branch protects same-named topics of every year. + + Deletion is unconditional — no status protects a topic. + + Only the resolved year is affected — no other year is touched. + + Filesystem-only — no branch, ref, or index of git is mutated in any + mode. + + ``dry_run`` True mutates nothing at all. + + An empty result is an empty list — not an error. + + Constraints: + Do not assemble the status scale — statuses take no part in the + orphan decision. + + Do not spare topics by status or age. + + Do not print — output shaping belongs to the consumer. + + Raises: + ValueError: a topic directory name normalizes to an empty slug — + propagated from ``remove_topic_dir`` before the list is returned. + subprocess.CalledProcessError: a git infrastructure failure of the + ref listing (propagated — the caller wraps it). + FileNotFoundError: a missing git binary of the ref listing + (propagated — the caller wraps it). + OSError: unexpected filesystem failures of the deletion (propagated + — the caller wraps it). + """ + resolved_year = year or current_year() + year_topics: list[str] = [] + for history_year in collect_history_tree(): + if history_year.year == resolved_year: + year_topics = history_year.topics + break + hosted = { + normalize_topic_slug(ref.name.partition("/")[2] if ref.remote else ref.name) for ref in list_branch_refs() + } + orphans = sorted({normalize_topic_slug(topic) for topic in year_topics} - hosted) + if not dry_run: + for slug in orphans: + remove_topic_dir(slug, resolved_year) + return orphans diff --git a/tests/history/test_prune.py b/tests/history/test_prune.py new file mode 100644 index 00000000..52098697 --- /dev/null +++ b/tests/history/test_prune.py @@ -0,0 +1,266 @@ +"""Contract and logic tests for the routine declared in +``goga/history/CODEMANIFEST`` with ``location: prune.py``: + +- ``prune_topics(year: str | None = None, dry_run: bool = False) -> removed: list[str]`` + +The orphan decision is the unit under test. The tree inventory is real +(``tmp_path`` + ``monkeypatch.chdir``); the git boundary is mocked at the +import site — ``goga.history.prune.list_branch_refs`` — except in the +filesystem-only test, which intercepts ``subprocess.run`` of the nested git +cell; the clock is the mandated ``naming.datetime`` point patched with a +fixed date. The facade exposure of the routine belongs to the facade task. +""" + +from __future__ import annotations + +import inspect +import subprocess +import typing +from datetime import datetime +from pathlib import Path +from unittest import mock + +import pytest +from goga.history import naming, prune +from goga.history.git import BranchRef +from goga.history.naming import normalize_topic_slug +from goga.history.prune import prune_topics +from goga.topics.creation import check_branch_occupancy + + +class _FixedClock: + """Stand-in for ``datetime`` answering a fixed naive date.""" + + @staticmethod + def now() -> datetime: + return datetime(2026, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock + + +def _topic(root: Path, year: str, name: str, *artifacts: str) -> Path: + """Create one topic directory of a year, with optional artifact files.""" + topic_dir = root / ".goga" / "history" / year / name + topic_dir.mkdir(parents=True, exist_ok=True) + for artifact in artifacts: + path = topic_dir / artifact + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(artifact, encoding="utf-8") + return topic_dir + + +def _inventory(refs: list[BranchRef]) -> mock._patch[mock.Mock]: + """Patch the git boundary of the prune module with a fixed inventory.""" + return mock.patch("goga.history.prune.list_branch_refs", return_value=refs) + + +def _inventory_run(heads: str, remotes: str) -> mock.Mock: + """A ``subprocess.run`` stand-in answering the two ``for-each-ref`` calls.""" + answers = {"refs/heads": heads, "refs/remotes": remotes} + + def answering(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(command, 0, stdout=answers[command[3]]) + + return mock.Mock(side_effect=answering) + + +# --- Contract tests --- + + +class TestPruneContract: + def test_routine_is_importable_from_module_and_callable(self) -> None: + """``prune_topics`` is importable from ``goga.history.prune`` and callable.""" + assert callable(prune_topics) + assert prune.prune_topics is prune_topics + + def test_prune_topics_signature(self) -> None: + """``prune_topics(year: str | None = None, dry_run: bool = False) -> list[str]``.""" + signature = inspect.signature(prune_topics) + assert list(signature.parameters) == ["year", "dry_run"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + assert signature.parameters["dry_run"].default is False + hints = typing.get_type_hints(prune_topics) + assert hints == {"year": str | None, "dry_run": bool, "return": list[str]} + bound = inspect.signature(prune_topics).bind(year="2025", dry_run=True) + assert bound.arguments == {"year": "2025", "dry_run": True} + + +# --- Logic tests --- + + +class TestPruneTopics: + def test_prune_topics_deletes_orphans_keeps_hosted(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Orphans go — a done orphan on equal terms; the hosted topic of the year stays.""" + monkeypatch.chdir(tmp_path) + hosted = _topic(tmp_path, "2026", "feat-a", "prd.md") + done = _topic(tmp_path, "2026", "done-c", "completed/plan.md") + orphan = _topic(tmp_path, "2026", "orphan-b", "prd.md") + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/feat/a", remote=True), + ] + with _inventory(inventory): + removed = prune_topics("2026") + assert removed == ["done-c", "orphan-b"] + assert hosted.is_dir() + assert not done.exists() + assert not orphan.exists() + + def test_prune_remote_short_name_protects(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The short name of a remote-tracking ref protects without a local branch.""" + monkeypatch.chdir(tmp_path) + topic_dir = _topic(tmp_path, "2026", "feat-a", "prd.md") + with _inventory([BranchRef(name="origin/feat/a", remote=True)]): + assert prune_topics("2026") == [] + assert topic_dir.is_dir() + + def test_prune_protection_is_year_independent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A branch protects same-named topics of every year — the default and an explicit year.""" + inventory = [BranchRef(name="feat/a", remote=False)] + + def build(root: Path) -> tuple[Path, Path]: + return _topic(root, "2025", "feat-a", "prd.md"), _topic(root, "2026", "feat-a", "prd.md") + + current_year_root = tmp_path / "current" + explicit_year_root = tmp_path / "explicit" + old_current, new_current = build(current_year_root) + old_explicit, new_explicit = build(explicit_year_root) + with mock.patch.object(naming, "datetime", _FixedClock), _inventory(inventory): + monkeypatch.chdir(current_year_root) + assert prune_topics() == [] + monkeypatch.chdir(explicit_year_root) + assert prune_topics("2025") == [] + assert old_current.is_dir() + assert new_current.is_dir() + assert old_explicit.is_dir() + assert new_explicit.is_dir() + + def test_prune_dry_run_lists_what_real_run_deletes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The dry pass lists exactly what the real pass deletes — and deletes nothing itself.""" + dry_root = tmp_path / "dry" + wet_root = tmp_path / "wet" + + def build(root: Path) -> tuple[Path, Path]: + return _topic(root, "2026", "orphan-b", "prd.md"), _topic(root, "2026", "done-c", "completed/plan.md") + + dry_orphan, dry_done = build(dry_root) + wet_orphan, wet_done = build(wet_root) + with _inventory([]): + monkeypatch.chdir(dry_root) + assert prune_topics("2026", dry_run=True) == ["done-c", "orphan-b"] + monkeypatch.chdir(wet_root) + assert prune_topics("2026") == ["done-c", "orphan-b"] + assert dry_orphan.is_dir() + assert dry_done.is_dir() + assert not wet_orphan.exists() + assert not wet_done.exists() + + def test_prune_topics_returns_sorted_unique_slugs(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The result is the sorted orphan slug set — no duplicates, dry or wet alike.""" + monkeypatch.chdir(tmp_path) + _topic(tmp_path, "2026", "b-orphan", "prd.md") + _topic(tmp_path, "2026", "a-orphan", "prd.md") + with _inventory([]): + assert prune_topics("2026", dry_run=True) == ["a-orphan", "b-orphan"] + + +class TestPruneTopicsOracle: + def test_prune_oracle_matches_check_branch_occupancy(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The orphan decision agrees with the creation occupancy oracle on exact branch names.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/hot/b", remote=True), + BranchRef(name="main", remote=False), + ] + for entered in ["feat/a", "hot/b", "main", "other"]: + slug = normalize_topic_slug(entered) + # (1) the occupancy oracle on the empty tree — only the git oracles answer + with mock.patch("goga.topics.creation.list_branch_refs", return_value=inventory): + occupied = check_branch_occupancy(entered, slug, "2026") is not None + # (2) the topic directory of the slug comes to exist + _topic(tmp_path, "2026", slug) + # (3) the prune oracle measures the same names + with _inventory(inventory): + candidates = prune_topics("2026", dry_run=True) + assert occupied == (slug not in candidates), entered + + +class TestPruneTopicsNegatives: + def test_prune_topics_absent_year_returns_empty_list(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An absent year yields no topics — nothing is deleted, nothing is touched.""" + monkeypatch.chdir(tmp_path) + kept = _topic(tmp_path, "2026", "feat-a", "prd.md") + with ( + _inventory([BranchRef(name="feat/a", remote=False)]), + mock.patch("goga.history.prune.remove_topic_dir") as remover, + ): + assert prune_topics("1999") == [] + remover.assert_not_called() + assert kept.is_dir() + + def test_prune_topics_never_mutates_git(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The only git invocations of the flow are the read-only ref listings — wet and dry alike.""" + monkeypatch.chdir(tmp_path) + orphan = _topic(tmp_path, "2026", "orphan-b", "prd.md") + done = _topic(tmp_path, "2026", "done-c", "completed/plan.md") + runner = _inventory_run(heads="main\n", remotes="") + with mock.patch("goga.history.git.refs.subprocess.run", runner): + assert prune_topics("2026", dry_run=True) == ["done-c", "orphan-b"] + assert orphan.is_dir() + assert done.is_dir() + assert prune_topics("2026") == ["done-c", "orphan-b"] + assert not orphan.exists() + assert not done.exists() + assert runner.call_count == 4 # two ref listings per pass, one dry and one wet + for call in runner.call_args_list: + command = call.args[0] + assert command[:2] == ["git", "for-each-ref"] + assert not {"branch", "push", "update-ref", "checkout", "rm"} & set(command) + + +class TestPruneTopicsEdges: + def test_prune_topics_empty_tree_returns_empty_list(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A missing history root is an empty result — not an error, and nothing is created.""" + monkeypatch.chdir(tmp_path) + with _inventory([]): + assert prune_topics() == [] + assert not (tmp_path / ".goga").exists() + + def test_prune_topics_only_resolved_year_touched(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An explicit year scopes the cleanup — other years stay untouched.""" + monkeypatch.chdir(tmp_path) + old = _topic(tmp_path, "2025", "orphan-old", "prd.md") + new = _topic(tmp_path, "2026", "orphan-new", "prd.md") + with mock.patch.object(naming, "datetime", _FixedClock), _inventory([]): + assert prune_topics("2025") == ["orphan-old"] + assert not old.exists() + assert (tmp_path / ".goga" / "history" / "2025").is_dir() # the emptied year directory stays + assert new.is_dir() + + def test_prune_topics_normalizes_tree_names_for_protection( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A manual unnormalized directory is protected through its normalized slug.""" + monkeypatch.chdir(tmp_path) + manual = _topic(tmp_path, "2026", "Feature_Foo", "prd.md") + twin = _topic(tmp_path, "2026", "feature-foo", "prd.md") + with _inventory([BranchRef(name="feature/foo", remote=False)]): + assert prune_topics("2026") == [] + assert manual.is_dir() + assert twin.is_dir() + + def test_prune_topics_unnormalized_orphan_dir_stays(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A manual unnormalized orphan is listed but unreachable for deletion — wet and dry agree.""" + dry_root = tmp_path / "dry" + wet_root = tmp_path / "wet" + dry_manual = _topic(dry_root, "2026", "Feature_Foo", "prd.md") + wet_manual = _topic(wet_root, "2026", "Feature_Foo", "prd.md") + with _inventory([]): + monkeypatch.chdir(dry_root) + assert prune_topics("2026", dry_run=True) == ["feature-foo"] + monkeypatch.chdir(wet_root) + assert prune_topics("2026") == ["feature-foo"] + assert dry_manual.is_dir() + assert wet_manual.is_dir() From 74c673f06384748e8a682a1243d54049bdae2013 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 01:57:28 +0000 Subject: [PATCH 107/229] feat: re-export the branch inventory and orphan cleanup on the history facade (21 names) --- goga/history/__init__.py | 15 +++++++++++---- tests/history/test_facade.py | 28 ++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/goga/history/__init__.py b/goga/history/__init__.py index f4ff5d3d..c9c3f945 100644 --- a/goga/history/__init__.py +++ b/goga/history/__init__.py @@ -1,27 +1,31 @@ """History domain cell — the single owner of the ``.goga/history/`` tree. Topic identity (the slug grammar and the current year), topic addressing -(directory and artifact file paths, existence, creation), the topic status -listing, and tree traversal. The git branch reader lives in the nested leaf -cell ``goga.history.git`` and the status scale in the ``goga.history.statuses`` +(directory and artifact file paths, existence, creation, and removal), the +topic status listing, tree traversal, and orphan cleanup. The git branch +reader and the branch inventory live in the nested leaf cell +``goga.history.git`` and the status scale in the ``goga.history.statuses`` subcell — both re-exported on this facade, the embeddings declared in ``goga/history/CODEMANIFEST``. """ -from .git import resolve_current_branch_name +from .git import BranchRef, list_branch_refs, resolve_current_branch_name from .naming import current_year, normalize_topic_slug from .paths import ( ensure_topic_dir, + remove_topic_dir, resolve_history_root, resolve_topic_dir, resolve_topic_file, topic_exists, ) +from .prune import prune_topics from .status import TopicRecord, collect_topic_statuses, resolve_topic_status from .statuses import Stage, StatusRegistry, StatusScale, assemble_status_scale from .tree import HistoryYear, collect_history_tree __all__: list[str] = [ + "BranchRef", "HistoryYear", "Stage", "StatusRegistry", @@ -32,7 +36,10 @@ "collect_topic_statuses", "current_year", "ensure_topic_dir", + "list_branch_refs", "normalize_topic_slug", + "prune_topics", + "remove_topic_dir", "resolve_current_branch_name", "resolve_history_root", "resolve_topic_dir", diff --git a/tests/history/test_facade.py b/tests/history/test_facade.py index 06c5c11b..91e6512d 100644 --- a/tests/history/test_facade.py +++ b/tests/history/test_facade.py @@ -1,11 +1,13 @@ """Facade contract test for the ``goga/history`` domain cell. -The cell CODEMANIFEST declares seventeen facade names: the domain types and -routines of the ``naming``/``paths``/``status``/``tree`` modules, the git -branch reader embedded from the nested ``goga.history.git`` leaf cell, and the -four status scale names embedded from the ``goga.history.statuses`` subcell -(the ``->`` re-exports). The former single-status enum ``TopicStatus`` is -deleted by the contract — the multi-status scale replaces it. +The cell CODEMANIFEST declares twenty-one facade names: the domain types and +routines of the ``naming``/``paths``/``status``/``tree``/``prune`` modules +(topic addressing including the idempotent directory remover, and the orphan +cleanup), the git branch reader and the branch inventory embedded from the +nested ``goga.history.git`` leaf cell, and the four status scale names +embedded from the ``goga.history.statuses`` subcell (the ``->`` +re-exports). The former single-status enum ``TopicStatus`` is deleted by the +contract — the multi-status scale replaces it. """ from __future__ import annotations @@ -13,6 +15,7 @@ import goga.history _HISTORY_FACADE_ALL = [ + "BranchRef", "HistoryYear", "Stage", "StatusRegistry", @@ -23,7 +26,10 @@ "collect_topic_statuses", "current_year", "ensure_topic_dir", + "list_branch_refs", "normalize_topic_slug", + "prune_topics", + "remove_topic_dir", "resolve_current_branch_name", "resolve_history_root", "resolve_topic_dir", @@ -34,9 +40,10 @@ class TestHistoryFacade: - def test_history_facade_exports_seventeen_names(self) -> None: - """The facade ``__all__`` is exactly the seventeen contract names, alphabetical.""" + def test_history_facade_exports_twenty_one_names(self) -> None: + """The facade ``__all__`` is exactly the twenty-one contract names, alphabetical.""" assert goga.history.__all__ == _HISTORY_FACADE_ALL + assert len(goga.history.__all__) == 21 for name in _HISTORY_FACADE_ALL: assert hasattr(goga.history, name), f"{name} is not defined on goga.history" @@ -47,6 +54,11 @@ def test_history_facade_embeds_the_git_branch_reader(self) -> None: is goga.history.git.resolve_current_branch_name ) + def test_history_facade_embeds_the_git_branch_inventory(self) -> None: + """The embedded inventory names are the git leaf cell's objects, not copies.""" + assert goga.history.BranchRef is goga.history.git.BranchRef + assert goga.history.list_branch_refs is goga.history.git.list_branch_refs + def test_history_facade_embeds_the_status_scale(self) -> None: """The embedded scale names are the statuses subcell's objects, not copies.""" assert goga.history.StatusScale is goga.history.statuses.StatusScale From 99bde138064bb09509cf5cfff97b1a4ef241a649 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 02:06:33 +0000 Subject: [PATCH 108/229] feat: add BoardRecord.title and title reading to collect_topic_board --- goga/topics/board.py | 115 +++++++++++++++++++++------- goga/topics/switching.py | 2 +- tests/topics/test_board.py | 149 ++++++++++++++++++++++++++++++++----- 3 files changed, 222 insertions(+), 44 deletions(-) diff --git a/goga/topics/board.py b/goga/topics/board.py index faa782aa..5a8138dd 100644 --- a/goga/topics/board.py +++ b/goga/topics/board.py @@ -1,19 +1,21 @@ """The topic board of the topics domain. The entities declared in the cell CODEMANIFEST with ``location: board.py``: -one row of the board — a topic hosted by one branch — and the read-only -collector that merges the branch inventory, the ref trees of one year, and -the working copy of the current branch into the sorted inventory. Git access -follows the ``refs-and-switching`` patterns of the nested git cell; topic -identity, addressing, and statuses belong to the history facade. Git -infrastructure failures and the fatal scale-assembly import failure surface -as ``click.ClickException`` — the clean-error boundary of the domain. +one row of the board — a topic hosted by one branch with its title — and the +read-only collector that merges the branch inventory, the ref trees of one +year, and the working copy of the current branch into the sorted inventory +of statuses and titles. Git access follows the ``refs-and-switching`` +patterns of the nested git cell; topic identity, addressing, and statuses +belong to the history facade. Git infrastructure failures and the fatal +scale-assembly import failure surface as ``click.ClickException`` — the +clean-error boundary of the domain. """ from __future__ import annotations import subprocess from dataclasses import dataclass +from pathlib import Path import click @@ -28,11 +30,14 @@ resolve_topic_status, topic_exists, ) -from .git import BranchRef, list_branch_refs, read_ref_tree_paths +from .git import BranchRef, list_branch_refs, read_ref_file, read_ref_tree_paths # One board row under construction — whether the hosting ref is -# remote-tracking and the row's maximal statuses. -_Row = tuple[bool, list[str]] +# remote-tracking, the row's maximal statuses, and the row's title. +_Row = tuple[bool, list[str], str | None] + +# The topic title file — the artifact of the ``new`` status entry. +_TITLE_FILE = "title.txt" # The minimum part count of a topic path — ``.goga/history/<year>/<slug>/<artifact>``. _TOPIC_PATH_PARTS = 5 @@ -49,6 +54,8 @@ class BoardRecord: scale order. current: ``True`` when the row hosts the current working branch. remote: ``True`` when the hosting ref is remote-tracking. + title: The first line of the topic title file, or ``None`` when the + topic has no title file. """ topic: str @@ -56,12 +63,13 @@ class BoardRecord: statuses: list[str] current: bool remote: bool + title: str | None = None def collect_topic_board( year: str | None = None, remote: bool = False ) -> list[BoardRecord]: - """Collect the cross-branch topic inventory of one year. + """Collect the cross-branch topic inventory of one year with titles. Args: year: Optional year as four digits; ``None`` means the current year. @@ -91,17 +99,25 @@ def collect_topic_board( artifact paths and compute the maximal statuses — the working copy via ``resolve_topic_status``, every other ref via the ``StatusScale`` - 6. Collapse a local branch and its remote twin into one row — the + 6. Read the title of every hosted topic — the working copy from the + title file ``title.txt`` of its directory, every other ref from + the title file of its ref tree via ``read_ref_file``; the value is + the first line of the file, ``None`` when it is absent + 7. Collapse a local branch and its remote twin into one row — the local branch wins; different branches hosting one slug stay separate rows - 7. Mark the row hosting the current branch - 8. Sort by scale order of the first maximal status, then + 8. Mark the row hosting the current branch + 9. Sort by scale order of the first maximal status, then alphabetically by topic, and return the records Requirements: The current branch is read from the working copy — uncommitted progress is visible; remote mode shows it through its remote twin. + A multi-line title file yields its first line; an empty title file + yields an empty string — presence differs from absence. The title + never affects the sort order. + Constraints: Do not render — output shaping belongs to the consumer. Do not cross the year boundary — other years are invisible here. @@ -138,19 +154,25 @@ def _board_records(year: str | None, remote: bool) -> list[BoardRecord]: inventory = list_branch_refs() current = resolve_current_branch_name() refs = [ref for ref in inventory if ref.remote] if remote else inventory + prefix = _history_prefix() topics_by_ref = _year_topics_by_ref(refs, resolved_year) rows: dict[tuple[str, str], _Row] = {} for ref in refs: if remote or current is None or ref.name != current: for slug, artifacts in topics_by_ref[ref.name].items(): - rows[(slug, ref.name)] = (ref.remote, scale.maximal_present(artifacts)) + title_path = f"{prefix}{resolved_year}/{slug}/{_TITLE_FILE}" + rows[(slug, ref.name)] = ( + ref.remote, + scale.maximal_present(artifacts), + _first_line(read_ref_file(ref.name, title_path)), + ) continue hosted = _current_branch_topic(current, resolved_year, scale) if hosted is None: continue - slug, statuses = hosted - rows[(slug, ref.name)] = (False, statuses) + slug, statuses, title = hosted + rows[(slug, ref.name)] = (False, statuses, title) records = [ BoardRecord( @@ -159,14 +181,26 @@ def _board_records(year: str | None, remote: bool) -> list[BoardRecord]: statuses=statuses, current=_marks_current(branch, current, remote), remote=is_remote, + title=title, ) - for (slug, branch), (is_remote, statuses) in _collapse_remote_twins(rows).items() + for (slug, branch), (is_remote, statuses, title) in _collapse_remote_twins(rows).items() ] scale_order = {stage.name: index for index, stage in enumerate(scale.stages)} records.sort(key=lambda record: (scale_order[record.statuses[0]], record.topic)) return records +def _history_prefix() -> str: + """Return the history root as a git path prefix. + + The prefix carries the trailing slash and is always posix — git + pathspecs, ``ls-tree`` output, and ``show`` paths are forward-slashed on + Windows too; a native-separator path would match nothing and silently + empty the board. + """ + return f"{resolve_history_root().as_posix()}/" + + def _year_topics_by_ref(refs: list[BranchRef], year: str) -> dict[str, dict[str, list[str]]]: """Read the topics of one year hosted by every given ref. @@ -183,10 +217,7 @@ def _year_topics_by_ref(refs: list[BranchRef], year: str) -> dict[str, dict[str, ...]}`` with the artifact paths relative to the topic directory, ready for ``StatusScale.maximal_present``. """ - # ``as_posix`` — git pathspecs and ``ls-tree`` output are always - # forward-slashed, on Windows too; a native-separator path would match - # nothing and silently empty the board. - prefix = f"{resolve_history_root().as_posix()}/" + prefix = _history_prefix() return {ref.name: _year_topics(read_ref_tree_paths(ref.name, prefix), year) for ref in refs} @@ -213,7 +244,7 @@ def _year_topics(paths: list[str], year: str) -> dict[str, list[str]]: def _current_branch_topic( current: str, year: str, scale: StatusScale -) -> tuple[str, list[str]] | None: +) -> tuple[str, list[str], str | None] | None: """Read the current branch's own topic from the working copy. The slug guard runs first: ``resolve_topic_dir`` and ``topic_exists`` @@ -227,15 +258,47 @@ def _current_branch_topic( scale: The assembled status scale. Returns: - The current branch's slug with its maximal statuses, or ``None`` - when the branch hosts no topic of the year. + The current branch's slug with its maximal statuses and its title, + or ``None`` when the branch hosts no topic of the year. """ slug = normalize_topic_slug(current) if slug == "": return None if not topic_exists(current, year): return None - return slug, resolve_topic_status(resolve_topic_dir(current, year), scale) + topic_dir = resolve_topic_dir(current, year) + title = _first_line(_read_working(topic_dir / _TITLE_FILE)) + return slug, resolve_topic_status(topic_dir, scale), title + + +def _first_line(content: str | None) -> str | None: + """Take the first line of a title file's content. + + Args: + content: The title file content, or ``None`` when the file is + absent. + + Returns: + The first line, ``""`` for an empty file, ``None`` for an absent + file — presence differs from absence. + """ + if content is None: + return None + lines = content.splitlines() + return lines[0] if lines else "" + + +def _read_working(path: Path) -> str | None: + """Read one file of the working copy. + + Args: + path: The file path to read. + + Returns: + The UTF-8 file content, or ``None`` when the file is absent — + uncommitted progress is visible, a missing file is not an error. + """ + return path.read_text(encoding="utf-8") if path.is_file() else None def _collapse_remote_twins(rows: dict[tuple[str, str], _Row]) -> dict[tuple[str, str], _Row]: diff --git a/goga/topics/switching.py b/goga/topics/switching.py index e6b83e88..9b02df09 100644 --- a/goga/topics/switching.py +++ b/goga/topics/switching.py @@ -244,7 +244,7 @@ def _hosted_candidates( if working_copy is None: hosted.append((ref, None, [])) else: - slug, statuses = working_copy + slug, statuses, _title = working_copy hosted.append((ref, slug, statuses)) continue topics = topics_by_ref[ref.name] diff --git a/tests/topics/test_board.py b/tests/topics/test_board.py index c2b1deaa..a0a82e96 100644 --- a/tests/topics/test_board.py +++ b/tests/topics/test_board.py @@ -1,8 +1,8 @@ """Contract and logic tests for the entities declared in ``goga/topics/CODEMANIFEST`` with ``location: board.py``: -- ``BoardRecord(topic, branch, statuses, current, remote)`` — one row of the - topic board, a topic hosted by one branch +- ``BoardRecord(topic, branch, statuses, current, remote, title)`` — one row + of the topic board, a topic hosted by one branch with its title - ``collect_topic_board(year, remote)`` — the read-only cross-branch topic inventory of one year @@ -41,18 +41,38 @@ def read(ref: str, prefix: str) -> list[str]: return read -def _wire_board( +def _files_reader(files: dict[tuple[str, str], str]) -> Callable[..., str | None]: + """A ``read_ref_file`` stand-in answering by ``(ref, path)``. + + A key missing from the dict answers ``None`` — the mirror of the + ``read_ref_file`` absence contract. + """ + + def read(ref: str, path: str) -> str | None: + return files.get((ref, path)) + + return read + + +def _wire_board( # noqa: PLR0913, PLR0917 — the five board patch points plus the scenario files + monkeypatch: pytest.MonkeyPatch, scale: StatusScale, inventory: list[BranchRef], trees: dict[str, list[str]], current: str | None, + files: dict[tuple[str, str], str] | None = None, ) -> None: - """Patch the board's import points: scale, git inventory, trees, branch.""" + """Patch the board's import points: scale, git, trees, branch, files. + + Without ``files`` every ref title reads as ``None`` — no title file at + any ref. + """ monkeypatch.setattr(board, "assemble_status_scale", lambda: scale) monkeypatch.setattr(board, "list_branch_refs", lambda: inventory) monkeypatch.setattr(board, "resolve_current_branch_name", lambda: current) monkeypatch.setattr(board, "read_ref_tree_paths", _trees_reader(trees)) + monkeypatch.setattr(board, "read_ref_file", _files_reader(files or {})) def _working_copy_topic(cwd: Path, year: str, slug: str, artifacts: list[str]) -> None: @@ -63,6 +83,13 @@ def _working_copy_topic(cwd: Path, year: str, slug: str, artifacts: list[str]) - path.write_text("artifact", encoding="utf-8") +def _working_title(cwd: Path, year: str, slug: str, content: str) -> None: + """Write the working-copy title file of a topic with the given content.""" + path = cwd / ".goga" / "history" / year / slug / "title.txt" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + def _base_inventory() -> list[BranchRef]: """The design-scenario inventory: two locals, two remote-tracking refs.""" return [ @@ -83,10 +110,17 @@ def _base_trees() -> dict[str, list[str]]: } -def _rows(records: list[BoardRecord]) -> list[tuple[str, str, list[str], bool, bool]]: - """The records as plain tuples — topic, branch, statuses, current, remote.""" +def _rows(records: list[BoardRecord]) -> list[tuple[str, str, list[str], bool, bool, str | None]]: + """The records as plain tuples — topic, branch, statuses, current, remote, title.""" return [ - (record.topic, record.branch, record.statuses, record.current, record.remote) + ( + record.topic, + record.branch, + record.statuses, + record.current, + record.remote, + record.title, + ) for record in records ] @@ -105,7 +139,7 @@ def test_entities_are_importable_from_the_cell_facade(self) -> None: assert "collect_topic_board" in cell.__all__ def test_board_record_is_a_frozen_kw_only_dataclass(self) -> None: - """``@dataclass(frozen=True, kw_only=True)`` with the five declared fields.""" + """``@dataclass(frozen=True, kw_only=True)`` with the six declared fields.""" assert dataclasses.is_dataclass(BoardRecord) assert BoardRecord.__dataclass_params__.frozen is True assert BoardRecord.__dataclass_params__.kw_only is True @@ -115,6 +149,7 @@ def test_board_record_is_a_frozen_kw_only_dataclass(self) -> None: "statuses": list[str], "current": bool, "remote": bool, + "title": str | None, } record = BoardRecord( topic="feat-a", branch="feat/a", statuses=["planned"], current=True, remote=False @@ -124,11 +159,32 @@ def test_board_record_is_a_frozen_kw_only_dataclass(self) -> None: assert record.statuses == ["planned"] assert record.current is True assert record.remote is False + assert record.title is None with pytest.raises(dataclasses.FrozenInstanceError): record.topic = "other" # type: ignore[misc] with pytest.raises(TypeError): BoardRecord("feat-a", "feat/a", ["planned"], True, False) # type: ignore[misc] + def test_board_record_declares_title_field(self) -> None: + """The title field: ``str | None``, sixth, defaulting to ``None``.""" + hints = typing.get_type_hints(BoardRecord) + assert hints["title"] == str | None + assert [field.name for field in dataclasses.fields(BoardRecord)] == [ + "topic", + "branch", + "statuses", + "current", + "remote", + "title", + ] + # The default keeps every pre-title constructor valid. + record = BoardRecord(topic="a", branch="b", statuses=[], current=False, remote=False) + assert record.title is None + titled = BoardRecord( + topic="a", branch="b", statuses=[], current=False, remote=False, title="Payment retry" + ) + assert titled.title == "Payment retry" + def test_collect_topic_board_signature(self) -> None: """``collect_topic_board(year=None, remote=False) -> list[BoardRecord]``.""" signature = inspect.signature(collect_topic_board) @@ -161,8 +217,8 @@ def test_collect_topic_board_local_collapses_twin_and_marks_current( records = collect_topic_board("2026", remote=False) assert _rows(records) == [ - ("feat-b", "origin/feat/b", ["defined"], False, True), - ("feat-a", "feat/a", ["planned"], True, False), + ("feat-b", "origin/feat/b", ["defined"], False, True, None), + ("feat-a", "feat/a", ["planned"], True, False, None), ] # The remote twin collapsed into the local row — the local branch wins. assert "origin/feat/a" not in [record.branch for record in records] @@ -186,8 +242,8 @@ def test_collect_topic_board_remote_mode_twin_current( records = collect_topic_board("2026", remote=True) assert _rows(records) == [ - ("feat-b", "origin/feat/b", ["defined"], False, True), - ("feat-a", "origin/feat/a", ["planned"], True, True), + ("feat-b", "origin/feat/b", ["defined"], False, True, None), + ("feat-a", "origin/feat/a", ["planned"], True, True, None), ] assert [record.branch for record in records] == ["origin/feat/b", "origin/feat/a"] # Remote mode never reads the working copy — the current branch shows @@ -207,6 +263,65 @@ def test_collect_topic_board_year_without_topics_empty( assert collect_topic_board("2030") == [] + def test_collect_topic_board_reads_titles_local_and_ref( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Titles: the working copy for the current branch, ref trees for the rest.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + _working_title(tmp_path, "2026", "feat-a", "Local title\nsecond\n") + trees = { + **_base_trees(), + # Without a year topic on main there is no main row — and the + # absent-title case stays unmeasured. + "main": [".goga/history/2026/main-only/prd.md", "README.md"], + } + files = {("origin/feat/b", ".goga/history/2026/feat-b/title.txt"): "Remote title\n"} + _wire_board(monkeypatch, builtin_scale, _base_inventory(), trees, "feat/a", files) + + records = collect_topic_board("2026") + + assert _rows(records) == [ + ("feat-b", "origin/feat/b", ["defined"], False, True, "Remote title"), + ("main-only", "main", ["defined"], False, False, None), + ("feat-a", "feat/a", ["planned"], True, False, "Local title"), + ] + + def test_collect_topic_board_title_first_line_and_empty( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A multi-line title yields its first line; empty stays empty; absent is None.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + _working_title(tmp_path, "2026", "feat-a", "A\nB\n") + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="feat/b", remote=False), + BranchRef(name="feat/c", remote=False), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "feat/b": [".goga/history/2026/feat-b/plan.md"], + "feat/c": [".goga/history/2026/feat-c/plan.md"], + } + files = {("feat/b", ".goga/history/2026/feat-b/title.txt"): ""} + _wire_board(monkeypatch, builtin_scale, inventory, trees, "feat/a", files) + + records = collect_topic_board("2026") + + # All planned — the order is the slug alphabet, never the titles. + assert _rows(records) == [ + ("feat-a", "feat/a", ["planned"], True, False, "A"), + ("feat-b", "feat/b", ["planned"], False, False, ""), + ("feat-c", "feat/c", ["planned"], False, False, None), + ] + def test_current_branch_empty_slug_hosts_no_topic( self, builtin_scale: StatusScale, @@ -223,7 +338,7 @@ def test_current_branch_empty_slug_hosts_no_topic( records = collect_topic_board("2026") - assert _rows(records) == [("feat-a", "feat/a", ["planned"], False, False)] + assert _rows(records) == [("feat-a", "feat/a", ["planned"], False, False, None)] # The empty-slug guard runs before the existence oracle — the board is # never crashed by the branch that cannot host a topic. assert exists.call_count == 0 @@ -243,8 +358,8 @@ def test_collect_topic_board_no_current_branch( records = collect_topic_board("2026") assert _rows(records) == [ - ("feat-b", "origin/feat/b", ["defined"], False, True), - ("feat-a", "feat/a", ["planned"], False, False), + ("feat-b", "origin/feat/b", ["defined"], False, True, None), + ("feat-a", "feat/a", ["planned"], False, False, None), ] assert all(not record.current for record in records) # Without a current branch the working copy is not read at all. @@ -274,10 +389,10 @@ def test_board_sees_only_committed_artifacts_on_other_refs( # The current branch reads the working copy — the uncommitted plan.md # is visible. - assert local_rows == [("feat-a", "feat/a", ["planned"], True, False)] + assert local_rows == [("feat-a", "feat/a", ["planned"], True, False, None)] # The same work through its remote twin reads the ref tree — the # uncommitted artifact is invisible there. - assert remote_rows == [("feat-a", "origin/feat/a", ["empty"], True, True)] + assert remote_rows == [("feat-a", "origin/feat/a", ["empty"], True, True, None)] class TestBoardInfrastructureBoundary: From d8db6ab7d9cace740378c5a83033108d71174a06 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 02:12:25 +0000 Subject: [PATCH 109/229] feat: add optional title to create_topic writing title.txt --- goga/topics/creation.py | 77 ++++++++++++++++++++++------- tests/topics/test_creation.py | 91 +++++++++++++++++++++++++++++++++-- 2 files changed, 145 insertions(+), 23 deletions(-) diff --git a/goga/topics/creation.py b/goga/topics/creation.py index 549e2519..07e8fc90 100644 --- a/goga/topics/creation.py +++ b/goga/topics/creation.py @@ -3,12 +3,13 @@ The entities declared in the cell CODEMANIFEST with ``location: creation.py``: the three-oracle occupancy check of a fresh-work name and the orchestrator that creates the branch — named exactly as entered -— together with its topic directory of the year. Topic identity and -addressing belong to the history facade; the bounded git mutation belongs to -the nested git cell. Git infrastructure failures surface as -``click.ClickException`` — the clean-error boundary of the domain; the -interactive moments follow the ``click`` practice. The status scale is never -assembled here — creation is not a status consumer. +— together with its topic directory of the year and, when a title is given, +its topic title file. Topic identity and addressing belong to the history +facade; the bounded git mutation belongs to the nested git cell. Git +infrastructure failures surface as ``click.ClickException`` — the +clean-error boundary of the domain; the interactive moments follow the +``click`` practice. The status scale is never assembled here — creation is +not a status consumer. """ from __future__ import annotations @@ -23,6 +24,7 @@ ensure_topic_dir, normalize_topic_slug, resolve_current_branch_name, + resolve_topic_file, topic_exists, ) from .git import create_and_switch_branch, list_branch_refs @@ -78,13 +80,16 @@ def check_branch_occupancy( raise click.ClickException(f"git is not available: {exc}") from exc -def create_topic(branch_name: str, year: str | None = None) -> str: - """Create fresh work — a branch with the name as entered and its topic - directory of the year. +def create_topic( + branch_name: str, year: str | None = None, title: str | None = None +) -> str: + """Create fresh work — a branch with the name as entered, its topic + directory of the year, and an optional topic title. Args: branch_name: Branch name as entered by the user. year: Optional year as four digits; ``None`` means the current year. + title: Optional topic title; ``None`` writes no title file. Returns: One line describing the outcome — the created work, or the @@ -96,19 +101,28 @@ def create_topic(branch_name: str, year: str | None = None) -> str: interactive terminal and restart, or fail with the reason otherwise 3. The current branch — read via ``resolve_current_branch_name`` — - hosts the same slug -> idempotent success, no mutation, no - occupancy check + hosts the same slug -> the idempotent path: a ``title`` given + writes the topic title file ``title.txt`` of the ensured topic + directory; no ``title`` is a success without mutation; no + occupancy check, no switch 4. ``check_branch_occupancy`` reports a conflict -> print the reason with a hint to the board, prompt for a new name on an interactive terminal and restart, or fail otherwise 5. Free name -> create the branch named exactly as entered and - switch to it via ``create_and_switch_branch``, and create the - topic directory via ``ensure_topic_dir`` of the year + switch to it via ``create_and_switch_branch``, create the topic + directory via ``ensure_topic_dir`` of the year, and a ``title`` + given writes the title file ``title.txt`` of the topic directory 6. Return the single result line Requirements: The branch keeps the name as entered; the topic directory takes the slug — the two may deliberately differ. + The title file carries ``title`` as entered plus a single trailing + newline, encoded UTF-8. + The title file is written only when ``title`` is given — ``None`` + never creates and never overwrites it; an explicit ``title`` creates + the file or overwrites it. + The topic directory exists before the title file is written. An aborted re-ask leaves the repository untouched. The caller stays on the new branch. @@ -116,7 +130,8 @@ def create_topic(branch_name: str, year: str | None = None) -> str: Do not validate branch-name characters — git owns name validity. Do not auto-pick suffixed names on a conflict — the user re-asks or aborts. - Do not write artifact files inside the topic directory. + Do not write artifact files other than the topic title file inside + the topic directory. Raises: click.ClickException: an unresolved empty slug or occupancy conflict @@ -126,7 +141,7 @@ def create_topic(branch_name: str, year: str | None = None) -> str: left untouched. """ try: - return _create_topic(branch_name, year) + return _create_topic(branch_name, year, title) except subprocess.CalledProcessError as exc: detail = (exc.stderr or "").strip() or str(exc) raise click.ClickException(f"git failed: {detail}") from exc @@ -135,8 +150,11 @@ def create_topic(branch_name: str, year: str | None = None) -> str: except OSError as exc: # ``ensure_topic_dir`` propagates the mkdir failures — a stray file # named like the slug occupies no topic for the oracle, so the - # failure can only surface here, after the branch was created. - raise click.ClickException(f"cannot create topic directory: {exc}") from exc + # failure can only surface here, after the branch was created. The + # title write shares the boundary: one clean error for both. + raise click.ClickException( + f"cannot create the topic directory or write the title file: {exc}" + ) from exc def _occupancy_conflict( @@ -165,12 +183,13 @@ def _occupancy_conflict( return None -def _create_topic(branch_name: str, year: str | None) -> str: +def _create_topic(branch_name: str, year: str | None, title: str | None) -> str: """Run the traced creation procedure — the unwrapped orchestration. Args: branch_name: Branch name as entered by the user. year: Optional year as four digits; ``None`` means the current year. + title: Optional topic title; ``None`` writes no title file. Returns: The single result line of the outcome. @@ -186,6 +205,9 @@ def _create_topic(branch_name: str, year: str | None) -> str: current = resolve_current_branch_name() if current is not None and normalize_topic_slug(current) == slug: + if title is not None: + ensure_topic_dir(branch_name, resolved_year) + _write_title(branch_name, resolved_year, title) return f"Branch {current} already hosts topic {resolved_year}/{slug}" conflict = check_branch_occupancy(branch_name, slug, resolved_year) @@ -195,9 +217,28 @@ def _create_topic(branch_name: str, year: str | None) -> str: create_and_switch_branch(branch_name) ensure_topic_dir(branch_name, resolved_year) + if title is not None: + _write_title(branch_name, resolved_year, title) return f"Created branch {branch_name} and topic {resolved_year}/{slug}" +def _write_title(name: str, year: str, title: str) -> None: + """Write the topic title file of a topic directory. + + The file carries the title as entered plus a single trailing newline, + encoded UTF-8 — created when absent, overwritten when present. The topic + directory must already exist; only directories are created here. + + Args: + name: Topic input — a branch name or an already-normalized slug. + year: Year as four digits. + title: Topic title as entered by the user. + """ + resolve_topic_file(name, "title.txt", year).write_text( + f"{title}\n", encoding="utf-8" + ) + + def _reask(reason: str, hint: str = "") -> str: """Handle an unusable name: re-ask on a terminal, abort otherwise. diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index 0c646f37..8f3419ac 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -3,7 +3,8 @@ - ``check_branch_occupancy(branch_name, slug, year)`` — the three-oracle occupancy check of a fresh-work name -- ``create_topic(branch_name, year)`` — the fresh-work creation procedure +- ``create_topic(branch_name, year, title)`` — the fresh-work creation + procedure with its optional topic title file The git boundary is mocked at the import point per the ``convention`` practice — no git binary and no repository are touched. The filesystem @@ -109,16 +110,22 @@ def test_check_branch_occupancy_signature(self) -> None: } def test_create_topic_signature(self) -> None: - """``create_topic(branch_name, year=None) -> str``.""" + """``create_topic(branch_name, year=None, title=None) -> str``.""" signature = inspect.signature(create_topic) - assert list(signature.parameters) == ["branch_name", "year"] + assert list(signature.parameters) == ["branch_name", "year", "title"] assert all( parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None + assert signature.parameters["title"].default is None hints = typing.get_type_hints(create_topic) - assert hints == {"branch_name": str, "year": str | None, "return": str} + assert hints == { + "branch_name": str, + "year": str | None, + "title": str | None, + "return": str, + } def test_no_cleanliness_probe_in_creation(self) -> None: """Creation owns no cleanliness policy — no probe is imported.""" @@ -235,6 +242,22 @@ def test_create_topic_default_year_is_current( create_and_switch.assert_called_once_with("Feature/Foo_Bar") assert (tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar").is_dir() + def test_create_topic_with_title_fresh_path( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A free name with a title: the branch, the directory, the title file.""" + monkeypatch.chdir(tmp_path) + create_and_switch = _wire_inventory(monkeypatch, [], current="main") + + result = create_topic("Feature/Foo_Bar", "2026", "Payment retry") + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + create_and_switch.assert_called_once_with("Feature/Foo_Bar") + title_file = ( + tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" / "title.txt" + ) + assert title_file.read_bytes() == b"Payment retry\n" + def test_create_topic_idempotent_current_host( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -255,6 +278,21 @@ def test_create_topic_idempotent_current_host( create_and_switch.assert_not_called() ensure_dir.assert_not_called() + def test_create_topic_with_title_idempotent_path( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The current host with an explicit title: ensure, overwrite, no switch.""" + monkeypatch.chdir(tmp_path) + topic_dir = _topic_dir(tmp_path, "2026", "feature-foo") + (topic_dir / "title.txt").write_text("Old\n", encoding="utf-8") + create_and_switch = _wire_inventory(monkeypatch, [], current="feature-foo") + + result = create_topic("feature-foo", "2026", "New title") + + assert result == "Branch feature-foo already hosts topic 2026/feature-foo" + create_and_switch.assert_not_called() + assert (topic_dir / "title.txt").read_text(encoding="utf-8") == "New title\n" + def test_create_topic_occupied_non_interactive_clean_error( self, tmp_path: Path, @@ -357,6 +395,46 @@ def test_create_topic_reask_abort_leaves_repository_untouched( create_and_switch.assert_not_called() assert not (tmp_path / ".goga").exists() + def test_create_topic_title_write_failure_is_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A failing title write becomes the generalized clean error.""" + monkeypatch.chdir(tmp_path) + create_and_switch = _wire_inventory(monkeypatch, [], current="main") + monkeypatch.setattr( + creation, + "resolve_topic_file", + mock.Mock(side_effect=OSError("disk full")), + ) + + with pytest.raises(click.ClickException) as raised: + create_topic("Feature/Foo_Bar", "2026", "T") + + assert ( + "cannot create the topic directory or write the title file" + in raised.value.message + ) + # The traced order — the branch mutation runs before the title write. + create_and_switch.assert_called_once_with("Feature/Foo_Bar") + + def test_create_topic_title_survives_reask( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The title is a procedure parameter — a re-asked name keeps it.""" + monkeypatch.chdir(tmp_path) + prompt = _interactive(monkeypatch, ["Feature/Foo_Bar"]) + create_and_switch = _wire_inventory(monkeypatch, [], current="main") + + result = create_topic("ББ", "2026", "T") + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + create_and_switch.assert_called_once_with("Feature/Foo_Bar") + assert prompt.call_count == 1 + title_file = ( + tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" / "title.txt" + ) + assert title_file.read_text(encoding="utf-8") == "T\n" + # --- Infrastructure boundary --- @@ -447,7 +525,10 @@ def test_stray_file_at_topic_path_surfaces_as_clean_error( with pytest.raises(click.ClickException) as raised: create_topic("feat-x", year="2026") - assert "cannot create topic directory" in raised.value.message + assert ( + "cannot create the topic directory or write the title file" + in raised.value.message + ) assert "feat-x" in raised.value.message # The traced order — the branch mutation runs before the directory. create_and_switch.assert_called_once_with("feat-x") From b94e6186b24c506e4f4f9c37de0279537f4951d1 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 02:18:01 +0000 Subject: [PATCH 110/229] feat: add the prune subcommand to goga history --- goga/commands/history/history.py | 60 ++++++++++++++--- tests/commands/history/test_history.py | 40 +++++++++-- .../commands/history/test_history_command.py | 67 +++++++++++++++++++ 3 files changed, 154 insertions(+), 13 deletions(-) diff --git a/goga/commands/history/history.py b/goga/commands/history/history.py index 450461ed..9535f12b 100644 --- a/goga/commands/history/history.py +++ b/goga/commands/history/history.py @@ -1,18 +1,20 @@ """The ``goga history`` command group — the CLI surface of the history domain. The click group declared in the cell CODEMANIFEST with ``location: -history.py``: the ``list``/``status``/``path``/``ensure`` subcommands over the -``.goga/history/`` tree. The group is a thin wrapper — it resolves the inputs, -delegates every computation to the domain routines of ``goga.history``, and -renders the results through the ``render`` module. No path building, no slug -grammar, and no status resolution live here. Domain errors surface as clean -CLI errors: a ``ValueError`` from the domain and an undetermined git branch -become ``click.ClickException`` (stderr, exit 1, no traceback) — no fallback -topic names, no silent skips. +history.py``: the ``list``/``status``/``path``/``ensure``/``prune`` +subcommands over the ``.goga/history/`` tree. The group is a thin wrapper — +it resolves the inputs, delegates every computation to the domain routines +of ``goga.history``, and renders the results through the ``render`` module. +No path building, no slug grammar, and no status resolution live here. +Domain errors surface as clean CLI errors: a ``ValueError`` from the domain +and an undetermined git branch become ``click.ClickException`` (stderr, +exit 1, no traceback) — no fallback topic names, no silent skips. """ from __future__ import annotations +import subprocess + import click from ...history import ( @@ -21,6 +23,7 @@ collect_topic_statuses, ensure_topic_dir, normalize_topic_slug, + prune_topics, resolve_current_branch_name, resolve_topic_dir, resolve_topic_file, @@ -172,3 +175,44 @@ def ensure(ctx: click.Context, name: str | None = None) -> None: except ValueError as exc: raise click.ClickException(str(exc)) from exc ctx.exit(0) + + +@history.command("prune") +@click.argument("year", required=False) +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="List the deletion candidates without deleting anything.", +) +@click.pass_context +def prune(ctx: click.Context, year: str | None = None, dry_run: bool = False) -> None: + """Delete the orphan topics of one year — the topics no branch hosts. + + A local branch or a remote-tracking ref whose short name normalizes to + the topic slug protects it, in every year; every other topic of YEAR is + an orphan and goes. YEAR defaults to the current year — only that year + is touched. Every removed topic is printed as one slug per line, and + nothing else; an empty result prints nothing and exits 0. The deletion + is filesystem-only (no branch, ref, or index of git is touched) and + unconditional — no status protects a topic. It is also irreversible: + the history tree is not in git, so a deleted topic directory cannot be + recovered. Run the command with --dry-run first to preview the + candidates. + """ + try: + removed = prune_topics(year, dry_run) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + except OSError as exc: + # FileNotFoundError is matched above — the git-less binary never + # lands here; this wraps the rmtree failures of the deletion. + raise click.ClickException(f"cannot delete topic directory: {exc}") from exc + for slug in removed: + click.echo(slug) + ctx.exit(0) diff --git a/tests/commands/history/test_history.py b/tests/commands/history/test_history.py index d6289219..dc11d969 100644 --- a/tests/commands/history/test_history.py +++ b/tests/commands/history/test_history.py @@ -1,7 +1,7 @@ """Contract and logic tests for the entity declared in ``goga/commands/history/CODEMANIFEST`` with ``location: history.py``: -the ``history`` click group with the ``list``/``status``/``path``/``ensure`` -subcommands. +the ``history`` click group with the ``list``/``status``/``path``/``ensure``/ +``prune`` subcommands. The group is a thin wrapper: inputs are resolved here, every computation is delegated to the ``goga.history`` domain, and output goes through the @@ -22,6 +22,7 @@ import pytest from click.testing import CliRunner from goga.commands.history import history, render_history_tree, render_topic_statuses +from goga.history import prune_topics # goga.commands.history.history is shadowed in the package __init__ by the # history click group, so attribute access through the package gives the @@ -49,9 +50,13 @@ def test_history_is_a_click_group(self) -> None: """history is a click.Group container for the subcommands.""" assert isinstance(history, click.Group) - def test_history_registers_four_subcommands(self) -> None: - """The group carries exactly the four declared subcommands.""" - assert sorted(history.commands) == ["ensure", "list", "path", "status"] + def test_history_registers_five_subcommands(self) -> None: + """The group carries exactly the five declared subcommands.""" + assert sorted(history.commands) == ["ensure", "list", "path", "prune", "status"] + + def test_history_module_binds_domain_prune_topics(self) -> None: + """The command module imports the domain cleanup routine at its site.""" + assert _history_module.prune_topics is prune_topics def test_history_group_carries_no_options(self) -> None: """Every subcommand owns its arguments — the group has none.""" @@ -101,6 +106,21 @@ def test_ensure_callback_signature(self) -> None: callback = history.commands["ensure"].callback assert list(inspect.signature(callback).parameters) == ["ctx", "name"] + def test_prune_callback_signature(self) -> None: + """``prune(ctx, year, dry_run)`` with the declared defaults.""" + callback = history.commands["prune"].callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["ctx", "year", "dry_run"] + assert signature.parameters["year"].default is None + assert signature.parameters["dry_run"].default is False + hints = typing.get_type_hints(callback) + assert hints == { + "ctx": click.Context, + "year": str | None, + "dry_run": bool, + "return": type(None), + } + def test_status_options(self) -> None: """status: optional YEAR positional, -t/--topic, repeatable -s/--status.""" command = history.commands["status"] @@ -132,6 +152,16 @@ def test_ensure_argument(self) -> None: name_argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "name") assert name_argument.required is False + def test_prune_argument_and_option(self) -> None: + """prune: optional YEAR positional, --dry-run flag.""" + command = history.commands["prune"] + year_argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "year") + assert year_argument.required is False + dry_run_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "dry_run") + assert "--dry-run" in dry_run_option.opts + assert dry_run_option.is_flag is True + assert dry_run_option.default is False + # --- Logic tests (negative paths, via CliRunner) --- diff --git a/tests/commands/history/test_history_command.py b/tests/commands/history/test_history_command.py index 580805b1..7e473049 100644 --- a/tests/commands/history/test_history_command.py +++ b/tests/commands/history/test_history_command.py @@ -16,6 +16,7 @@ from __future__ import annotations import inspect +import subprocess import sys from datetime import datetime from pathlib import Path @@ -285,6 +286,72 @@ def test_history_ensure_explicit_name_creates_dir( assert (tmp_path / ".goga" / "history" / "2031" / "feature-foo-bar").is_dir() +class TestHistoryPrune: + def test_history_prune_command_prints_slugs(self) -> None: + """prune echoes one slug per line and forwards --dry-run to the domain.""" + runner = CliRunner() + with mock.patch.object( + _history_module, "prune_topics", return_value=["done-c", "orphan-b"] + ) as prune_mock: + result = runner.invoke(history, ["prune", "--dry-run"]) + + assert result.exit_code == 0 + assert result.output == "done-c\norphan-b\n" + prune_mock.assert_called_once_with(None, True) + + def test_history_prune_command_passes_year(self) -> None: + """prune forwards the YEAR positional; an empty result prints nothing.""" + runner = CliRunner() + with mock.patch.object(_history_module, "prune_topics", return_value=[]) as prune_mock: + result = runner.invoke(history, ["prune", "2025"]) + + assert result.exit_code == 0 + assert result.output == "" + prune_mock.assert_called_once_with("2025", False) + + @pytest.mark.parametrize( + ("failure", "message"), + [ + (subprocess.CalledProcessError(1, ["git"], stderr="boom"), "git failed: boom"), + (FileNotFoundError("git"), "git is not available"), + (OSError("disk quota"), "cannot delete topic directory"), + ], + ) + def test_history_prune_git_failure_is_clean_error(self, failure: Exception, message: str) -> None: + """A domain failure surfaces as a clean error — exit 1, stderr, no traceback.""" + runner = CliRunner() + with mock.patch.object(_history_module, "prune_topics", side_effect=failure): + result = runner.invoke(history, ["prune"]) + + assert result.exit_code == 1 + assert result.stdout == "" + assert message in result.stderr + assert "Traceback" not in result.stderr + + def test_history_prune_empty_slug_dir_is_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A manual empty-slug directory aborts the cleanup before any deletion.""" + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "orphan-a").mkdir(parents=True) + (year_dir / "orphan-a" / "prd.md").write_text("prd\n", encoding="utf-8") + (year_dir / "ББ").mkdir() + (year_dir / "ББ" / "prd.md").write_text("prd\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + # The empty slug sorts first, so remove_topic_dir("") raises the + # domain ValueError before the list is returned — the echo loop never + # runs and nothing is deleted. + with mock.patch("goga.history.prune.list_branch_refs", return_value=[]): + result = CliRunner().invoke(history, ["prune", "2026"]) + + assert result.exit_code == 1 + assert result.stdout == "" + assert "normalizes to an empty topic slug" in result.stderr + assert "Traceback" not in result.stderr + assert (year_dir / "orphan-a").exists() + + # --- Edge cases --- From 31eb131973d36fcd958a1fac50213800b3ec963e Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 02:22:06 +0000 Subject: [PATCH 111/229] feat: generalize render_topic_board to the k-column grid with the info title column --- goga/commands/topics/render.py | 137 ++++++++++++----------- tests/commands/topics/test_render.py | 159 ++++++++++++++++++++++++++- 2 files changed, 221 insertions(+), 75 deletions(-) diff --git a/goga/commands/topics/render.py b/goga/commands/topics/render.py index 3cd9a688..1fbaa098 100644 --- a/goga/commands/topics/render.py +++ b/goga/commands/topics/render.py @@ -2,70 +2,68 @@ The entity declared in the cell CODEMANIFEST with ``location: render.py``: the board renderer — the collected board records as a three-column table of -topic, branch, and statuses. Pure output: the records print as given, never -sorted, filtered, or recomputed; the domain owns the collection and the -ordering. +topic, branch, and statuses, or as a four-column table with the title +column between branch and statuses under ``info``. Pure output: the records +print as given, never sorted, filtered, or recomputed; the domain owns the +collection and the ordering. """ from __future__ import annotations -from typing import NamedTuple - import click from ...topics import BoardRecord -# The fixed grid overhead — three pipe characters and six padding spaces. -_GRID_OVERHEAD = 9 +# The fixed grid overhead per column — one pipe and two padding spaces; the +# leading pipe replaces the pipe of the first column, so a table of k text +# columns carries 3*k overhead columns in total. # The minimum of every column before truncation applies. _MIN_COLUMN = 8 -# The usable-content floor of the thirds layout — below it the degenerate -# minimum-width layout wins over the width cap. -_USABLE_FLOOR = 3 * _MIN_COLUMN # The current-row marker — a prefix inside the topic cell. _CURRENT_MARKER = "* " # The truncation marker — a single ellipsis character. _ELLIPSIS = "…" -class _Columns(NamedTuple): - """The caps of the three board columns, in grid order.""" - - topic: int - branch: int - statuses: int - - -def render_topic_board(records: list[BoardRecord], width: int) -> None: - """Render the board as a three-column table: topic, branch, statuses. +def render_topic_board(records: list[BoardRecord], width: int, info: bool = False) -> None: + """Render the board as a table: topic, branch, and statuses — under + ``info`` the title column sits between branch and statuses. Args: records: The collected board records — already sorted by the domain. width: The measured terminal width in columns. + info: ``True`` adds the title column and switches to the + four-column width rule. Algorithm: - 1. Compute the column widths from ``width`` — the width rule of the - requirements + 1. Compute the column widths from ``width`` and the record content + per the width rule of the requirements — the three-column rule + without ``info``, the four-column rule with it 2. Print one header row and one separator row with column and row - dividers - 3. Print each record: the topic truncated with an ellipsis when it - exceeds its column, the branch truncated the same way, and the - statuses wrapped onto continuation lines without affecting the - column widths + dividers — the column order is topic, branch, title, statuses + under ``info`` + 3. Print each record: every text column truncated with an ellipsis + when it exceeds its column, and the statuses wrapped onto + continuation lines without affecting the column widths 4. Mark the record hosting the current branch with an asterisk; the remote prefix of a remote host stays visible in the branch column 5. An empty ``records`` prints nothing Requirements: - Topic and branch get an equal share first — each capped at one third - of ``width`` minus the dividers — and statuses take the remainder; - every column keeps a minimum of 8 columns before truncation applies. - The truncation marker is a single ellipsis character; an overlong - status segment is truncated like the other columns. The table never - exceeds ``width``, with one documented exception: when ``width`` is - below 33, every column keeps its minimum of 8 and the table may - exceed ``width`` — minimum readability wins over the width cap on - ultra-narrow terminals. + The three-column rule gives topic and branch an equal share first — + each capped at one third of ``width`` minus the dividers — and + statuses the remainder; the four-column rule under ``info`` gives + topic, branch, and title an equal share — each capped at one quarter + of ``width`` minus the dividers — and statuses the non-negative + remainder. Every column keeps a minimum of 8 columns before + truncation applies. A title of ``None`` or an empty string renders + an empty cell. The truncation marker is a single ellipsis character; + an overlong status segment is truncated like the other columns. The + table never exceeds ``width``, with one documented exception: below + the narrow threshold of the active column rule — 33 columns for the + thirds, 44 for the quarters — every column keeps its minimum of 8 + and the table may exceed ``width``; minimum readability wins over + the width cap on ultra-narrow terminals. Constraints: Read-only on ``records`` — do not mutate, do not re-sort, do not @@ -73,71 +71,72 @@ def render_topic_board(records: list[BoardRecord], width: int) -> None: """ if not records: return - columns = _column_widths(width) - click.echo(_row_line(("Topic", "Branch", "Statuses"), columns)) - click.echo(_separator(columns)) + columns_count = 4 if info else 3 + caps = _column_widths(width, columns_count) + header = ("Topic", "Branch", "Title", "Statuses") if info else ("Topic", "Branch", "Statuses") + click.echo(_row_line(header, caps)) + click.echo(_separator(caps)) for record in records: topic_text = f"{_CURRENT_MARKER}{record.topic}" if record.current else record.topic + leading = ( + (topic_text, record.branch, record.title or "") if info else (topic_text, record.branch) + ) segments = [f"[{status}]" for status in record.statuses] - for index, statuses_line in enumerate(_wrap_segments(segments, columns.statuses)): - cells = ( - topic_text if index == 0 else "", - record.branch if index == 0 else "", - statuses_line, - ) - click.echo(_row_line(cells, columns)) + for index, statuses_line in enumerate(_wrap_segments(segments, caps[-1])): + cells = (*(cell if index == 0 else "" for cell in leading), statuses_line) + click.echo(_row_line(cells, caps)) -def _column_widths(width: int) -> _Columns: +def _column_widths(width: int, columns_count: int) -> tuple[int, ...]: """Resolve the column widths of the grid for one terminal width. Args: width: The measured terminal width in columns. + columns_count: The number of text columns of the grid — 3 or 4. Returns: - The caps of the topic, branch, and statuses columns. With at least - 24 usable columns topic and branch take an equal third each and - statuses the remainder; below that every column keeps its minimum - of 8 and the table may exceed ``width``. + The caps of every column in grid order. The text columns take an + equal share of the usable width and statuses the non-negative + remainder; when the minimums no longer fit, every column keeps its + minimum of 8 and the table may exceed ``width``. """ - usable = width - _GRID_OVERHEAD - if usable < _USABLE_FLOOR: - return _Columns(_MIN_COLUMN, _MIN_COLUMN, _MIN_COLUMN) - topic_cap = usable // 3 - return _Columns(topic_cap, topic_cap, usable - 2 * topic_cap) + usable = width - 3 * columns_count + if usable < columns_count * _MIN_COLUMN: + return (_MIN_COLUMN,) * columns_count + cap = usable // columns_count + return (cap,) * (columns_count - 1) + (usable - (columns_count - 1) * cap,) -def _row_line(cells: tuple[str, str, str], columns: _Columns) -> str: +def _row_line(cells: tuple[str, ...], caps: tuple[int, ...]) -> str: """Build one grid row — every cell fitted to its column. - The fixed overhead of the grid is three pipes and six padding spaces: - the leading pipe, the two column separators, and the right padding of - the statuses cell — the table closes on the padded column, not on a + The fixed overhead of the grid is one pipe and two padding spaces per + column: the leading pipe, the column separators, and the right padding + of the last cell — the table closes on the padded column, not on a trailing pipe. Args: - cells: The topic, branch, and statuses cell texts of this grid - line — the continuation lines pass the first two empty. - columns: The caps of the three columns. + cells: The cell texts of this grid line in grid order — the + continuation lines pass the text columns empty. + caps: The caps of every column. Returns: The grid line with the cells truncated, padded, and divided. """ - topic, branch, statuses = cells - return f"| {_fit(topic, columns.topic)} | {_fit(branch, columns.branch)} | {_fit(statuses, columns.statuses)} " + return f"| {' | '.join(_fit(text, cap) for text, cap in zip(cells, caps, strict=True))} " -def _separator(columns: _Columns) -> str: +def _separator(caps: tuple[int, ...]) -> str: """Build the row divider of the grid. Args: - columns: The caps of the three columns. + caps: The caps of every column. Returns: The separator row — one dash run per column under its padding, joined by the pipes of the grid. """ - return f"|{'-' * (columns.topic + 2)}|{'-' * (columns.branch + 2)}|{'-' * (columns.statuses + 2)}" + return "|" + "|".join("-" * (cap + 2) for cap in caps) def _fit(text: str, cap: int) -> str: @@ -181,7 +180,7 @@ def _wrap_segments(segments: list[str], statuses_w: int) -> list[str]: The statuses cell content per grid line — a greedy fill that keeps every segment whole; a single segment longer than the column is truncated with the ellipsis like the other columns. The grid lives - on: the continuation lines carry empty topic and branch cells. + on: the continuation lines carry empty text cells. """ lines: list[str] = [] current = "" diff --git a/tests/commands/topics/test_render.py b/tests/commands/topics/test_render.py index c571fcaf..64f97841 100644 --- a/tests/commands/topics/test_render.py +++ b/tests/commands/topics/test_render.py @@ -1,12 +1,14 @@ """Contract and logic tests for the entities declared in ``goga/commands/topics/CODEMANIFEST`` with ``location: render.py``: -- ``render_topic_board(records: list[BoardRecord], width: int)`` +- ``render_topic_board(records: list[BoardRecord], width: int, info: bool = False)`` The board renderer is pure output: the collected records print as given — no sorting, no filtering, no mutation — as a three-column table of topic, -branch, and statuses whose widths follow the P9 arithmetic. Output is -captured with ``capsys``. +branch, and statuses, or a four-column table with the title column between +branch and statuses under ``info``; the widths follow the thirds or the +quarters arithmetic of the active column rule. Output is captured with +``capsys``. """ from __future__ import annotations @@ -28,14 +30,15 @@ def test_entity_is_importable_from_facade_and_callable(self) -> None: assert callable(render_topic_board) def test_render_topic_board_signature(self) -> None: - """``render_topic_board(records: list[BoardRecord], width: int) -> None``.""" + """``render_topic_board(records: list[BoardRecord], width: int, info: bool = False)``.""" signature = inspect.signature(render_topic_board) - assert list(signature.parameters) == ["records", "width"] + assert list(signature.parameters) == ["records", "width", "info"] assert all( parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) + assert signature.parameters["info"].default is False hints = typing.get_type_hints(render_topic_board) - assert hints == {"records": list[BoardRecord], "width": int, "return": type(None)} + assert hints == {"records": list[BoardRecord], "width": int, "info": bool, "return": type(None)} def test_render_topic_board_empty_input_prints_nothing(self, capsys: pytest.CaptureFixture[str]) -> None: """An empty board renders not a single line — header included.""" @@ -158,3 +161,147 @@ def test_render_topic_board_two_segments_fit_one_line(self, capsys: pytest.Captu assert len(lines) == 3 assert "[defined] [planned]" in lines[2] assert all(len(line) <= 80 for line in lines) + + def test_render_topic_board_three_columns_unchanged_without_info( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """Regression gate — without ``info`` the three-column output is byte-identical.""" + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["defined", "planned"], + title="Payment retry", + current=False, + remote=False, + ), + BoardRecord( + topic="a-very-long-topic-name", + branch="feat/x", + statuses=["done"], + title=None, + current=False, + remote=False, + ), + ] + render_topic_board(records, 100) + first = capsys.readouterr().out + render_topic_board(records, 100, info=False) + second = capsys.readouterr().out + # The explicit False and the default produce the very same bytes. + assert first == second + lines = first.splitlines() + # usable = 91, so topic_cap = branch_cap = 30 and statuses_w = 31; + # the header keeps the three columns — the title stays invisible. + assert lines[0].startswith("| Topic") + assert "Title" not in lines[0] + assert "Statuses" in lines[0] + assert all(len(line) <= 100 for line in lines) + assert "Payment retry" not in first + + @pytest.mark.parametrize("width", [33, 32]) + def test_render_topic_board_three_column_narrow_threshold( + self, capsys: pytest.CaptureFixture[str], width: int + ) -> None: + """Widths 33 and 32 without ``info`` stay on the 8/8/8 minimum thirds.""" + records = [ + BoardRecord(topic="feat-a", branch="feat/a", statuses=["done"], title="T", current=False, remote=False) + ] + render_topic_board(records, width) + lines = capsys.readouterr().out.splitlines() + # The narrow threshold of the three-column rule is 33 usable columns — + # both boundaries resolve to the 8/8/8 minimum layout. + assert all(len(line) == 33 for line in lines) + assert lines[0].startswith("| Topic") + assert "Title" not in lines[0] + + +class TestRenderTopicBoardInfo: + def test_render_topic_board_info_four_columns(self, capsys: pytest.CaptureFixture[str]) -> None: + """Width 100 under ``info`` — quarters of 22, the title column visible.""" + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["planned"], + title="Short title", + current=False, + remote=False, + ), + BoardRecord( + topic="feat-b", + branch="feat/b", + statuses=["done"], + title="an-overlong-title-that-exceeds-the-cap", + current=False, + remote=False, + ), + ] + render_topic_board(records, 100, info=True) + lines = capsys.readouterr().out.splitlines() + # usable = 88, so every column takes a quarter — 22/22/22/22. + assert lines[0].startswith("| Topic") + assert "Branch" in lines[0] + assert "Title" in lines[0] + assert "Statuses" in lines[0] + for line in lines: + assert line.count("|") == 4 + assert all(len(line) <= 100 for line in lines) + assert "Short title" in lines[2] + # The 38-column title exceeds its cap of 22 — truncated with the ellipsis. + assert "…" in lines[3] + assert "[planned]" in lines[2] + assert "[done]" in lines[3] + + def test_render_topic_board_info_none_and_empty_title_cells( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """Titles of None and of the empty string render an empty padded cell.""" + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["planned"], + title=None, + current=False, + remote=False, + ), + BoardRecord( + topic="feat-b", + branch="feat/b", + statuses=["planned"], + title="", + current=False, + remote=False, + ), + ] + render_topic_board(records, 100, info=True) + lines = capsys.readouterr().out.splitlines() + # Both empty titles render the whitespace padding of a 22-column cell. + for line in lines[2:4]: + cells = line[2:-1].split(" | ") + assert len(cells) == 4 + assert cells[2] == " " * 22 + assert len(line) == 100 + + @pytest.mark.parametrize("width", [44, 43]) + def test_render_topic_board_info_boundary_widths_44_43( + self, capsys: pytest.CaptureFixture[str], width: int + ) -> None: + """Widths 44 and 43 under ``info`` — the narrow threshold of the quarters.""" + records = [ + BoardRecord(topic="feat-a", branch="feat/a", statuses=["done"], title="T", current=False, remote=False) + ] + render_topic_board(records, width, info=True) + lines = capsys.readouterr().out.splitlines() + # usable = width - 12: 44 gives exactly 32 = 8x4 — the table fits the + # width; 43 gives 31 < 32 — the documented one-column overflow. + assert all(len(line) == 44 for line in lines) + assert lines[0].startswith("| Topic") + assert "Title" in lines[0] + assert "[done]" in lines[2] + + def test_render_topic_board_info_empty_records_print_nothing(self, capsys: pytest.CaptureFixture[str]) -> None: + """An empty board under ``info`` renders not a single line — header included.""" + render_topic_board([], 100, info=True) + assert capsys.readouterr().out == "" From f0de190b9d94e96841dd0cf85e2d81f398f35786 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 02:26:35 +0000 Subject: [PATCH 112/229] feat: add --info/-i to topics status and --title/-t to topics create --- goga/commands/topics/topics.py | 40 ++++--- tests/commands/topics/test_topics_command.py | 104 ++++++++++++++++--- 2 files changed, 120 insertions(+), 24 deletions(-) diff --git a/goga/commands/topics/topics.py b/goga/commands/topics/topics.py index c36fb068..e9053200 100644 --- a/goga/commands/topics/topics.py +++ b/goga/commands/topics/topics.py @@ -49,35 +49,51 @@ def topics(ctx: click.Context, year: str | None = None) -> None: default=False, help="Read remote-tracking refs instead of local branches.", ) +@click.option( + "--info", + "-i", + is_flag=True, + default=False, + help="Add the title column to the table.", +) @click.pass_obj -def status(scope: _TopicsScope, remote: bool = False) -> None: +def status(scope: _TopicsScope, remote: bool = False, info: bool = False) -> None: """Print the board — the cross-branch topic inventory of the scoped year. One three-column table row per topic: topic, branch, statuses — the row of the current branch carries an asterisk and the statuses wrap onto - continuation lines when they overflow. --remote/-r reads remote-tracking - refs instead of local branches. An empty board prints nothing and exits - 0 — it is not an error. The year defaults to the current one and is - never printed. + continuation lines when they overflow. --info/-i adds the title column + — the first line of the topic's title file — between branch and + statuses. --remote/-r reads remote-tracking refs instead of local + branches. An empty board prints nothing and exits 0 — it is not an + error. The year defaults to the current one and is never printed. """ records = collect_topic_board(scope.year, remote) - render_topic_board(records, shutil.get_terminal_size().columns) + render_topic_board(records, shutil.get_terminal_size().columns, info) click.get_current_context().exit(0) @topics.command("create") @click.argument("branch_name") +@click.option( + "--title", + "-t", + default=None, + help="Topic title — writes title.txt in the topic directory.", +) @click.pass_obj -def create(scope: _TopicsScope, branch_name: str) -> None: +def create(scope: _TopicsScope, branch_name: str, title: str | None = None) -> None: """Create fresh work — a branch with the name as entered and its topic directory. The branch name is taken verbatim; the topic directory of the scoped - year is created from its slug. The current branch already hosting the - same slug is an idempotent success. Occupied names and empty slugs - re-ask on an interactive terminal and fail with a clean error - otherwise. One result line on stdout. + year is created from its slug. An explicit --title/-t also writes the + topic title file title.txt — the text as entered plus one trailing + newline; without it no title file is written. The current branch + already hosting the same slug is an idempotent success. Occupied names + and empty slugs re-ask on an interactive terminal and fail with a clean + error otherwise. One result line on stdout. """ - line = create_topic(branch_name, scope.year) + line = create_topic(branch_name, scope.year, title) click.echo(line) click.get_current_context().exit(0) diff --git a/tests/commands/topics/test_topics_command.py b/tests/commands/topics/test_topics_command.py index 7bc57510..a7ebecd7 100644 --- a/tests/commands/topics/test_topics_command.py +++ b/tests/commands/topics/test_topics_command.py @@ -5,16 +5,20 @@ The group is a thin wrapper: the ``--year/-y`` option builds the scope every subcommand shares, and each subcommand delegates its computation to the -``goga.topics`` domain — the board collection and rendering for ``status``, -the creation and switching procedures for ``create``/``switch``. The logic -tests mock the domain at its import site in the command module and drive -the CLI surface through ``CliRunner``; a pinned ``COLUMNS`` keeps the -measured terminal width deterministic. +``goga.topics`` domain — the board collection and rendering for ``status`` +(the ``--info/-i`` flag adds the title column to the rendered table), the +creation (``--title/-t`` writes the topic title file) and switching +procedures for ``create``/``switch``. The logic tests mock the domain at its +import site in the command module and drive the CLI surface through +``CliRunner``; a pinned ``COLUMNS`` keeps the measured terminal width +deterministic. """ from __future__ import annotations import inspect +import os +import shutil import sys from unittest import mock @@ -75,11 +79,12 @@ def test_scope_is_a_kw_only_dataclass_with_year(self) -> None: assert _topics_module._TopicsScope().year is None def test_status_callback_signature(self) -> None: - """``status(scope, remote=False)`` — the scope object and the flag.""" + """``status(scope, remote=False, info=False)`` — the scope object and the flags.""" callback = topics.commands["status"].callback signature = inspect.signature(callback) - assert list(signature.parameters) == ["scope", "remote"] + assert list(signature.parameters) == ["scope", "remote", "info"] assert signature.parameters["remote"].default is False + assert signature.parameters["info"].default is False def test_status_carries_the_remote_flag(self) -> None: """status: --remote/-r flag, defaulting to False.""" @@ -90,16 +95,36 @@ def test_status_carries_the_remote_flag(self) -> None: assert remote_option.is_flag is True assert remote_option.default is False + def test_status_carries_the_info_flag(self) -> None: + """status: --info/-i flag, defaulting to False.""" + command = topics.commands["status"] + info_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "info") + assert "-i" in info_option.opts + assert "--info" in info_option.opts + assert info_option.is_flag is True + assert info_option.default is False + def test_create_carries_the_name_positional(self) -> None: """create: the required branch_name positional.""" command = topics.commands["create"] argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "branch_name") assert argument.required is True + def test_create_carries_the_title_option(self) -> None: + """create: --title/-t option, defaulting to None.""" + command = topics.commands["create"] + title_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "title") + assert "-t" in title_option.opts + assert "--title" in title_option.opts + assert title_option.is_flag is False + assert title_option.default is None + def test_create_callback_signature(self) -> None: - """``create(scope, branch_name)``.""" + """``create(scope, branch_name, title=None)``.""" callback = topics.commands["create"].callback - assert list(inspect.signature(callback).parameters) == ["scope", "branch_name"] + signature = inspect.signature(callback) + assert list(signature.parameters) == ["scope", "branch_name", "title"] + assert signature.parameters["title"].default is None def test_switch_carries_the_identifier_positional(self) -> None: """switch: the required identifier positional.""" @@ -132,7 +157,7 @@ def test_topics_group_help_and_year_scope(self) -> None: mock_create.return_value = "Created branch X and topic 2025/x" scoped = runner.invoke(topics, ["--year", "2025", "create", "X"]) assert scoped.exit_code == 0 - mock_create.assert_called_once_with("X", "2025") + mock_create.assert_called_once_with("X", "2025", None) @pytest.mark.parametrize("subcommand", ["status", "create", "switch"]) def test_subcommand_help_follows_the_cli_docstring_rule(self, subcommand: str) -> None: @@ -149,7 +174,7 @@ def test_year_defaults_to_none_for_the_domain(self) -> None: mock_create.return_value = "Created branch X and topic 2026/x" result = CliRunner().invoke(topics, ["create", "X"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("X", None) + mock_create.assert_called_once_with("X", None, None) class TestTopicsStatus: @@ -190,6 +215,45 @@ def test_status_short_forms_bind_the_same_values(self) -> None: assert result.exit_code == 0 mock_collect.assert_called_once_with("2024", True) + def test_topics_status_info_flag_reaches_renderer(self, monkeypatch: pytest.MonkeyPatch) -> None: + """--info reaches the renderer — the table gains the Title column.""" + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["planned"], + current=True, + remote=False, + title="Payment retry", + ), + ] + monkeypatch.setattr(shutil, "get_terminal_size", lambda: os.terminal_size((100, 24))) + with mock.patch.object(_topics_module, "collect_topic_board", return_value=records): + result = CliRunner().invoke(topics, ["status", "--info"]) + assert result.exit_code == 0 + header = result.output.splitlines()[0] + assert "Title" in header + assert "Topic" in header + assert "Branch" in header + assert "Statuses" in header + assert "Payment retry" in result.output + + def test_topics_status_info_short_form_binds_the_same_table(self) -> None: + """-i renders the same four-column table as --info.""" + records = [ + BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=False, remote=False, title="T"), + ] + with ( + mock.patch.object(_topics_module, "collect_topic_board", return_value=records), + mock.patch.dict("os.environ", {"COLUMNS": "100"}), + ): + short = CliRunner().invoke(topics, ["status", "-i"]) + long = CliRunner().invoke(topics, ["status", "--info"]) + assert short.exit_code == 0 + assert long.exit_code == 0 + assert short.output == long.output + assert "Title" in short.output.splitlines()[0] + def test_status_empty_board_prints_nothing_exit_zero(self) -> None: """An empty board is not an error — nothing on stdout, exit 0.""" with ( @@ -242,9 +306,25 @@ def test_create_echoes_the_domain_result_line(self) -> None: ) as mock_create: result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("Feature/Foo_Bar", None) + mock_create.assert_called_once_with("Feature/Foo_Bar", None, None) assert result.output.splitlines() == ["Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar"] + def test_topics_create_title_option_reaches_domain(self) -> None: + """-t hands the domain (name, scoped year, title) verbatim.""" + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "-t", "Payment retry"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with("Feature/Foo_Bar", None, "Payment retry") + assert result.output == "line\n" + + def test_topics_create_title_long_form_binds_the_same_value(self) -> None: + """--title behaves exactly like -t.""" + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + result = CliRunner().invoke(topics, ["create", "feat-a", "--title", "T"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with("feat-a", None, "T") + assert result.output == "line\n" + def test_switch_echoes_the_domain_result_line(self) -> None: """switch echoes the single result line and exits 0.""" with mock.patch.object( From 210777105b3605ca6a95f032cca8d27a22cd4aba Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 02:29:52 +0000 Subject: [PATCH 113/229] feat: add the title-to-new-status integration test --- .../commands/history/test_history_command.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/commands/history/test_history_command.py b/tests/commands/history/test_history_command.py index 7e473049..afaaba04 100644 --- a/tests/commands/history/test_history_command.py +++ b/tests/commands/history/test_history_command.py @@ -200,6 +200,41 @@ def test_history_status_repeatable_status_filter( assert result.output.splitlines() == ["done-topic [done]", "planned-topic [planned]"] assert "defined-topic" not in result.output + def test_history_status_filter_new_selects_titled_topics( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """-s new selects the topics a title file puts into the built-in new status.""" + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "feat-a").mkdir(parents=True) + (year_dir / "feat-a" / "title.txt").write_text("Payment retry\n", encoding="utf-8") + (year_dir / "feat-b").mkdir() + (year_dir / "feat-b" / "prd.md").write_text("prd\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(assembly_module, "packages_distributions", lambda: {}) + + result = CliRunner().invoke(history, ["status", "2026", "-s", "new"]) + + assert result.exit_code == 0 + assert result.output.splitlines() == ["feat-a [new]"] + assert "feat-b" not in result.output + + def test_history_status_filter_new_skips_defined_topics( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A topic with prd.md is defined, not new — the maximal status wins through the CLI.""" + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "feat-b").mkdir(parents=True) + (year_dir / "feat-b" / "title.txt").write_text("Title\n", encoding="utf-8") + (year_dir / "feat-b" / "prd.md").write_text("prd\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(assembly_module, "packages_distributions", lambda: {}) + + result = CliRunner().invoke(history, ["status", "2026", "-s", "new"]) + + assert result.exit_code == 0 + assert result.output == "" + assert (year_dir / "feat-b" / "title.txt").exists() + class TestHistoryPath: def test_history_path_prints_file_path_only( From 7a3077702c5589a2a6ed0847a50a0d820caee9d9 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 02:52:42 +0000 Subject: [PATCH 114/229] fix: address code review findings --- README.md | 6 +- docs/cli/history.md | 22 +++++- docs/cli/index.md | 2 +- docs/cli/topics.md | 18 +++-- goga/topics/board.py | 5 +- goga/topics/git/trees.py | 6 +- tests/commands/topics/test_render.py | 26 +++++++ tests/integration/test_topic_workflows.py | 93 +++++++++++++++++++++++ tests/topics/git/test_trees.py | 14 ++++ tests/topics/test_board.py | 48 ++++++++++++ tests/topics/test_creation.py | 31 ++++++++ 11 files changed, 258 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 9066dfac..86a3b6f5 100644 --- a/README.md +++ b/README.md @@ -143,12 +143,16 @@ Work is organized as **topics** — one directory per piece of work under `.goga ```bash goga topics status # the board: every topic of the year across branches goga topics status --remote # same board over remote-tracking refs +goga topics status --info # the board with the title column (first line of title.txt) goga topics create feat/x # fresh work: the branch verbatim + its topic directory +goga topics create feat/x -t "Payment retry" # same, and writes title.txt (status: new) goga topics switch feat-x # onto the branch hosting that work (branch, slug, or prefix) goga topics --year 2025 status # the board of an explicit year ``` -The board is a three-column table — topic, branch, statuses — with `*` marking the current branch and a local branch absorbing its remote twin. Each topic carries its **maximal statuses** in scale order: `empty → defined → discovered → backlog → designed → specified → planned → done`, deepening as `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. A topic can carry several statuses at once (`goga history status` prints them; `-s` filters by any of them). +The board is a three-column table — topic, branch, statuses, plus a Title column under `--info` — with `*` marking the current branch and a local branch absorbing its remote twin. Each topic carries its **maximal statuses** in scale order: `empty → new → defined → discovered → backlog → designed → specified → planned → done`, deepening as `title.txt`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. A topic can carry several statuses at once (`goga history status` prints them; `-s` filters by any of them). + +Topics no branch hosts anymore are orphans — `goga history prune --dry-run` lists the orphans of a year, and `goga history prune [YEAR]` deletes them (irreversibly: the history tree is not in git). To resume work inside a pipeline, pass the identifier to the run — `goga pipeline development -t feat/x` switches to the hosting branch first (creating a local branch from its remote-tracking ref when needed) and is an idempotent no-op when you are already on it. Fresh work is started with `goga topics create`, not `-t`. diff --git a/docs/cli/history.md b/docs/cli/history.md index 91cce42d..13c1d6de 100644 --- a/docs/cli/history.md +++ b/docs/cli/history.md @@ -2,7 +2,7 @@ Work with the `.goga/history/` tree — its per-year topics, their statuses, and their paths. -`goga history` is a Click group with four subcommands (`list`, `status`, `path`, `ensure`) over the history domain. Everything is host-side and read-only except `ensure`; domain errors surface as clean one-line errors (exit 1, no traceback). +`goga history` is a Click group with five subcommands (`list`, `status`, `path`, `ensure`, `prune`) over the history domain. Everything is host-side; all of it is read-only except `ensure` (creates a directory) and `prune` (deletes topic directories). Domain errors surface as clean one-line errors (exit 1, no traceback). ## Synopsis @@ -11,6 +11,7 @@ goga history list goga history status [YEAR] [-t TOPIC] [-s STATUS]... goga history path [TOPIC] [-f FILENAME] [-y YEAR] goga history ensure [NAME] +goga history prune [YEAR] [--dry-run] ``` ## `goga history list` @@ -41,6 +42,7 @@ A topic carries its **maximal present statuses** in scale order — one brackete | Status | Artifact | | |---|---|---| | `empty` | — | no artifact yet | +| `new` | `title.txt` | written by `goga topics create --title` | | `defined` | `prd.md` | | | `discovered` | `adr.md` | | | `backlog` | `task.md` | | @@ -80,12 +82,26 @@ TOPIC defaults to the current git branch (taken raw, as a branch name or a slug Creates the topic directory of the current year, idempotently: parents are created as needed and an existing directory is a success, not a conflict. NAME defaults to the current git branch. Prints nothing on stdout — the exit code carries the result. Only directories: no artifact file is created, and occupancy is not reported. +## `goga history prune` + +Deletes the orphan topics of one year — the topics no branch of the repository inventory hosts — and prints one slug per line; an empty result prints nothing and exits 0. + +```bash +goga history prune --dry-run # list the deletion candidates, delete nothing +goga history prune # the current year +goga history prune 2025 # one explicit year +``` + +- A topic is protected when a local branch, or a remote-tracking ref whose short name (the part after the first `/`) normalizes to the topic slug, hosts it — in every year, not just YEAR. +- Deletion is unconditional — no status protects a topic, a `done` orphan goes too — and irreversible: the history tree is not in git, so a deleted topic directory cannot be recovered. Run with `--dry-run` first. +- Filesystem-only: no branch, ref, or index of git is touched — the only git call is the read-only ref listing. + ## Exit Codes | Code | Meaning | |------|---------| -| `0` | Success — the tree, statuses, or path printed, or the directory ensured | -| `1` | A clean domain error: an unknown `-s` status name, an empty topic filter or slug, an undeterminable current branch where a topic default is needed, or a broken `goga_tool_*` package failing to import during status-scale assembly | +| `0` | Success — the tree, statuses, or path printed, the directory ensured, or the orphans pruned (possibly none) | +| `1` | A clean domain error: an unknown `-s` status name, an empty topic filter or slug, an undeterminable current branch where a topic default is needed, a broken `goga_tool_*` package failing to import during status-scale assembly, or a prune failure (a git failure of the ref listing, a missing git binary, a topic directory that cannot be deleted, or a directory name that normalizes to an empty slug) | | `2` | A usage error (unknown option, too many arguments) | ## Notes diff --git a/docs/cli/index.md b/docs/cli/index.md index 3fdbe5a5..6908a1dc 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -36,7 +36,7 @@ python -m goga --help | [`goga upgrade`](upgrade.md) | Upgrade goga and re-sync connected agents | | [`goga usages`](usages.md) | Sync cell-level usages from declared git dependencies and check their status against the remote | | [`goga pipeline`](pipeline.md) | Run a goga pipeline, or inspect the available ones (`--list`, `--info`) | -| [`goga history`](history.md) | Work with the `.goga/history/` tree (`list`, `status`, `path`, `ensure`) | +| [`goga history`](history.md) | Work with the `.goga/history/` tree (`list`, `status`, `path`, `ensure`, `prune`) | | [`goga topics`](topics.md) | Work with the topics of one year (`status` board, `create`, `switch`) | | [`goga tool`](tool.md) | Dynamic tool package invocation | diff --git a/docs/cli/topics.md b/docs/cli/topics.md index 6f355fb4..c3c85d50 100644 --- a/docs/cli/topics.md +++ b/docs/cli/topics.md @@ -7,8 +7,8 @@ Work with the topics of one year — the cross-branch inventory, fresh-work crea ## Synopsis ```bash -goga topics [--year YYYY] status [--remote] -goga topics [--year YYYY] create BRANCH_NAME +goga topics [--year YYYY] status [--remote] [--info] +goga topics [--year YYYY] create BRANCH_NAME [--title TITLE] goga topics [--year YYYY] switch IDENTIFIER ``` @@ -31,10 +31,11 @@ Prints the board — the cross-branch topic inventory of the scoped year — as - A local branch and its remote twin collapse to one row — the local branch wins; a topic hosted only by a remote-tracking ref keeps its row with the remote name in the branch column. - Rows sort by scale order of the first maximal status, then alphabetically by topic. - `--remote`/`-r` reads remote-tracking refs instead of local branches; the current branch shows through its remote twin. -- The statuses column wraps onto continuation lines when the segments overflow the terminal width; the table never exceeds the width except on terminals below 33 columns, where every column keeps a minimum of 8. +- `--info`/`-i` adds the title column between branch and statuses — the first line of the topic's `title.txt`, an empty cell when the topic has none. The working copy reads the file directly; every other row reads it from the branch's tree (no checkout). +- The statuses column wraps onto continuation lines when the segments overflow the terminal width; the table never exceeds the width except on terminals below the narrow threshold of the active column rule — 33 columns for the three-column table, 44 with `--info` — where every column keeps a minimum of 8. - An empty board prints nothing and exits 0 — a year without topics is not an error. -The statuses are the topic's **maximal present statuses** in scale order — `empty, defined, discovered, backlog, designed, specified, planned, done`, deepening as `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. Tool packages can add their own statuses, shown qualified (`mkdocs.published`); see [Tools](../tools.md). +The statuses are the topic's **maximal present statuses** in scale order — `empty, new, defined, discovered, backlog, designed, specified, planned, done`, deepening as `title.txt`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. Tool packages can add their own statuses, shown qualified (`mkdocs.published`); see [Tools](../tools.md). ## `goga topics create` @@ -43,11 +44,16 @@ Creates fresh work — a branch named exactly as entered, plus the topic directo ```bash goga topics create Feature/Foo_Bar # Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar + +goga topics create Feature/Foo_Bar --title "Payment retry" +# Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar +# (.goga/history/2026/feature-foo-bar/title.txt now carries "Payment retry") ``` - The branch name is taken verbatim (`git switch -c`); git itself rejects invalid names. -- The topic directory is `.goga/history/<YYYY>/<slug>/`, where the slug is the normalized name (lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed: `Feature/Foo_Bar` → `feature-foo-bar`). No artifact file is written. -- The current branch already hosting the same slug is an idempotent success — `Branch <name> already hosts topic <YYYY>/<slug>` — with nothing touched. +- The topic directory is `.goga/history/<YYYY>/<slug>/`, where the slug is the normalized name (lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed: `Feature/Foo_Bar` → `feature-foo-bar`). No artifact file is written unless `--title` is given. +- `-t`/`--title` writes the topic title file `title.txt` in the topic directory — the title as entered plus one trailing newline, UTF-8 — which marks the topic `new` on the status scale and feeds the `--info` column of the board. +- The current branch already hosting the same slug is an idempotent success — `Branch <name> already hosts topic <YYYY>/<slug>` — with nothing touched, except that an explicit `--title` creates or overwrites the title file. - Occupancy is probed against three oracles in order: a local branch with the entered name, a remote-tracking branch with the entered name (local refs only — no network), and an existing `.goga/history/<YYYY>/<slug>/` directory (only a directory occupies a topic). - An occupied name or a name that normalizes to an empty slug (a fully non-ASCII name) prints the reason and prompts for a new name on an interactive terminal, restarting with it; with no terminal it exits 1 with the reason (and a hint to `goga topics status` for occupied names). Ctrl-C at the prompt aborts with nothing created. diff --git a/goga/topics/board.py b/goga/topics/board.py index 5a8138dd..021e10fc 100644 --- a/goga/topics/board.py +++ b/goga/topics/board.py @@ -297,8 +297,11 @@ def _read_working(path: Path) -> str | None: Returns: The UTF-8 file content, or ``None`` when the file is absent — uncommitted progress is visible, a missing file is not an error. + A file a hand edit left outside UTF-8 decodes with the replacement + character instead of raising — the title is display data, never a + reason to fail the board. """ - return path.read_text(encoding="utf-8") if path.is_file() else None + return path.read_text(encoding="utf-8", errors="replace") if path.is_file() else None def _collapse_remote_twins(rows: dict[tuple[str, str], _Row]) -> dict[tuple[str, str], _Row]: diff --git a/goga/topics/git/trees.py b/goga/topics/git/trees.py index ef6cc733..8791b0f6 100644 --- a/goga/topics/git/trees.py +++ b/goga/topics/git/trees.py @@ -88,7 +88,10 @@ def read_ref_file(ref: str, path: str) -> str | None: The content is returned as-is — no interpretation, no transformation. The content is UTF-8 by the creation contract, so the invocation decodes UTF-8 explicitly — locale decoding breaks - on non-ASCII content under the C/POSIX locale. + on non-ASCII content under the C/POSIX locale. A file a hand edit + left outside UTF-8 decodes with the replacement character instead + of raising — the content is display data, never a reason to fail + the reader. Constraints: Do not materialize the tree — no checkout, no worktree, no temp @@ -108,6 +111,7 @@ def read_ref_file(ref: str, path: str) -> str | None: capture_output=True, text=True, encoding="utf-8", + errors="replace", env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, ) except subprocess.CalledProcessError: diff --git a/tests/commands/topics/test_render.py b/tests/commands/topics/test_render.py index 64f97841..d10766b8 100644 --- a/tests/commands/topics/test_render.py +++ b/tests/commands/topics/test_render.py @@ -301,6 +301,32 @@ def test_render_topic_board_info_boundary_widths_44_43( assert "Title" in lines[0] assert "[done]" in lines[2] + def test_render_topic_board_info_wraps_statuses_with_empty_leading_cells( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """Wrapped statuses under ``info`` continue on three empty leading cells.""" + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["done", "planned"], + title="T", + current=False, + remote=False, + ) + ] + render_topic_board(records, 44, info=True) + lines = capsys.readouterr().out.splitlines() + # usable = 32 = 8x4 — the minimum quarters; "[done]" and the + # truncated "[planned]" cannot share the 8-column statuses cell. + assert len(lines) == 4 + assert "[done]" in lines[2] + assert "[planne…" in lines[3] + # The continuation row keeps the grid: topic, branch, and title are + # the empty padding of their columns — 10 columns per leading cell. + assert lines[3].startswith(f"|{' ' * 10}|{' ' * 10}|{' ' * 10}|") + assert len(lines[3]) == 44 + def test_render_topic_board_info_empty_records_print_nothing(self, capsys: pytest.CaptureFixture[str]) -> None: """An empty board under ``info`` renders not a single line — header included.""" render_topic_board([], 100, info=True) diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index b1afa9a5..94d49356 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -427,3 +427,96 @@ def test_create_topic_occupied_local_branch_reasks_non_interactively( assert _current_branch(tmp_path) == "feat-a" assert not (tmp_path / ".goga" / "history" / "2025" / "feat-b").exists() + + +@requires_git +class TestTopicsStatusTitles: + """The title column of ``goga topics status --info`` over real reads.""" + + def test_board_survives_hand_edited_non_utf8_titles( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Titles outside UTF-8 render with the replacement character. + + The working-copy title reads through pathlib and the ref-tree title + through ``git show`` — neither may raise through the clean-error + boundary, or one hand-edited file would break the whole board. + """ + _init_topic_repo(tmp_path) + # The committed side: feat-b's title lives in its ref tree only. + _git(tmp_path, "switch", "-q", "feat-b") + (tmp_path / ".goga" / "history" / "2025" / "feat-b" / "title.txt").write_bytes( + b"Rem\xffote\n" + ) + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "feat-b title") + _git(tmp_path, "switch", "-q", "feat-a") + # The uncommitted side: the current branch's working-copy title. + (tmp_path / ".goga" / "history" / "2025" / "feat-a" / "title.txt").write_bytes( + b"Pay\xffment\n" + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("COLUMNS", "120") + + result = CliRunner().invoke(topics, ["--year", "2025", "status", "--info"]) + + assert result.exit_code == 0 + assert "Pay�ment" in result.output + assert "Rem�ote" in result.output + + +@requires_git +class TestHistoryPrune: + """``goga history prune`` over the real branch inventory and tree.""" + + def test_prune_over_real_git_deletes_orphans_keeps_hosted( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The real ``for-each-ref`` inventory protects hosted topics; the orphans go.""" + _init_topic_repo(tmp_path) + # The switch back onto feat-a removed feat-b's directory from the + # working tree — restore it untracked, so both hosted topics are + # live candidates the real inventory must protect. + _write(tmp_path, ".goga/history/2025/feat-b/prd.md") + _write(tmp_path, ".goga/history/2025/orphan-c/prd.md") + _write(tmp_path, ".goga/history/2025/done-d/completed/plan.md") + monkeypatch.chdir(tmp_path) + + dry = CliRunner().invoke(history, ["prune", "2025", "--dry-run"]) + + assert dry.exit_code == 0 + assert dry.output.splitlines() == ["done-d", "orphan-c"] + assert (tmp_path / ".goga/history/2025/orphan-c/prd.md").exists() + + wet = CliRunner().invoke(history, ["prune", "2025"]) + + assert wet.exit_code == 0 + assert wet.output.splitlines() == ["done-d", "orphan-c"] + assert not (tmp_path / ".goga/history/2025/orphan-c").exists() + assert not (tmp_path / ".goga/history/2025/done-d").exists() + # The branch-hosted topics survive; a done orphan goes regardless. + assert (tmp_path / ".goga/history/2025/feat-a").is_dir() + assert (tmp_path / ".goga/history/2025/feat-b").is_dir() + + def test_prune_remote_only_host_protects_over_real_git( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A remote-tracking ref alone protects its topic — no local branch needed.""" + _init_topic_repo(tmp_path) + # A throwaway branch supplies the tree, then keeps only its remote twin. + _git(tmp_path, "switch", "-q", "-c", "throwaway") + _write(tmp_path, ".goga/history/2025/remote-only/prd.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "remote-only topic") + _git(tmp_path, "update-ref", "refs/remotes/origin/remote-only", "HEAD") + _git(tmp_path, "switch", "-q", "feat-a") + _git(tmp_path, "branch", "-qD", "throwaway") + # The topic directory is present in the working tree, untracked. + _write(tmp_path, ".goga/history/2025/remote-only/prd.md") + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["prune", "2025", "--dry-run"]) + + assert result.exit_code == 0 + assert result.output == "" + assert (tmp_path / ".goga/history/2025/remote-only/prd.md").exists() diff --git a/tests/topics/git/test_trees.py b/tests/topics/git/test_trees.py index 2d18d82c..817c853e 100644 --- a/tests/topics/git/test_trees.py +++ b/tests/topics/git/test_trees.py @@ -144,6 +144,20 @@ def test_read_ref_file_absent_file_returns_none(self) -> None: assert content is None + def test_read_ref_file_decodes_invalid_bytes_with_replacement(self) -> None: + """A hand-edited non-UTF-8 file never crashes the read. + + The invocation decodes with the replacement policy — the content is + display data (the title column), so an undecodable byte degrades to + U+FFFD instead of raising through the board's clean-error boundary. + """ + run = mock.Mock(return_value=_git_answer("Pay�ment\n")) + with mock.patch("goga.topics.git.trees.subprocess.run", run): + content = read_ref_file("feat/a", ".goga/history/2026/feat-a/title.txt") + + assert content == "Pay�ment\n" + assert run.call_args.kwargs["errors"] == "replace" + def test_read_ref_file_empty_file_returns_empty_string(self) -> None: """An empty file is present — ``""`` differs from absence (``None``).""" run = mock.Mock(return_value=_git_answer("")) diff --git a/tests/topics/test_board.py b/tests/topics/test_board.py index a0a82e96..c2c4a94a 100644 --- a/tests/topics/test_board.py +++ b/tests/topics/test_board.py @@ -322,6 +322,54 @@ def test_collect_topic_board_title_first_line_and_empty( ("feat-c", "feat/c", ["planned"], False, False, None), ] + def test_collect_topic_board_title_only_topic_is_new( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A topic whose only artifact is the title file carries ``new``.""" + monkeypatch.chdir(tmp_path) + _working_title(tmp_path, "2026", "feat-a", "Local title\n") + trees = { + **_base_trees(), + "feat/a": [".goga/history/2026/feat-a/title.txt"], + "origin/feat/b": [".goga/history/2026/feat-b/title.txt"], + } + files = {("origin/feat/b", ".goga/history/2026/feat-b/title.txt"): "Remote title\n"} + _wire_board(monkeypatch, builtin_scale, _base_inventory(), trees, "feat/a", files) + + records = collect_topic_board("2026") + + # title.txt is the artifact of new — on the working-copy path and on + # the ref-tree path alike; the titles ride along. + assert _rows(records) == [ + ("feat-a", "feat/a", ["new"], True, False, "Local title"), + ("feat-b", "origin/feat/b", ["new"], False, True, "Remote title"), + ] + + def test_collect_topic_board_survives_undecodable_working_title( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A hand-edited non-UTF-8 working title degrades — the board lives.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + title_path = tmp_path / ".goga" / "history" / "2026" / "feat-a" / "title.txt" + title_path.write_bytes(b"Pay\xffment\n") + _wire_board(monkeypatch, builtin_scale, _base_inventory(), _base_trees(), "feat/a") + + records = collect_topic_board("2026") + + # The read replaces the undecodable byte instead of raising through + # the clean-error boundary — the title is display data. + assert _rows(records) == [ + ("feat-b", "origin/feat/b", ["defined"], False, True, None), + ("feat-a", "feat/a", ["planned"], True, False, "Pay�ment"), + ] + def test_current_branch_empty_slug_hosts_no_topic( self, builtin_scale: StatusScale, diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index 8f3419ac..40e98834 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -293,6 +293,37 @@ def test_create_topic_with_title_idempotent_path( create_and_switch.assert_not_called() assert (topic_dir / "title.txt").read_text(encoding="utf-8") == "New title\n" + def test_create_topic_empty_title_writes_bare_newline( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An explicit empty title writes the file — the empty string is not None.""" + monkeypatch.chdir(tmp_path) + create_and_switch = _wire_inventory(monkeypatch, [], current="main") + + result = create_topic("feat-a", "2026", "") + + assert result == "Created branch feat-a and topic 2026/feat-a" + create_and_switch.assert_called_once_with("feat-a") + title_file = tmp_path / ".goga" / "history" / "2026" / "feat-a" / "title.txt" + # The explicit empty title creates the file — one bare newline, which + # earns the new status and renders as an empty title cell. + assert title_file.read_bytes() == b"\n" + + def test_create_topic_empty_title_overwrites_on_idempotent_path( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An explicit empty title overwrites an existing title on the current host.""" + monkeypatch.chdir(tmp_path) + topic_dir = _topic_dir(tmp_path, "2026", "feature-foo") + (topic_dir / "title.txt").write_text("Old\n", encoding="utf-8") + create_and_switch = _wire_inventory(monkeypatch, [], current="feature-foo") + + result = create_topic("feature-foo", "2026", "") + + assert result == "Branch feature-foo already hosts topic 2026/feature-foo" + create_and_switch.assert_not_called() + assert (topic_dir / "title.txt").read_bytes() == b"\n" + def test_create_topic_occupied_non_interactive_clean_error( self, tmp_path: Path, From 228c2cf6c0f3bc22fce8fcd413c8cf016bf24377 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 10:56:29 +0000 Subject: [PATCH 115/229] fix: address acceptance review findings Contracts & coverage audit of the prune/title/new-status branch. Manifests and docstrings aligned to the deliberate code behavior (no behavior changes): anchor-resolution scope and the empty-stage filepath of the status scale, the read_ref_file error breadth, the prune error contract and the year wording, the content-blind board width rule, the normalized prune return caveat, and the missing failure-mode lines. Cell usage files updated to match (branch-inventory pattern documented, name-to-artifact mapping, error lists, board title sources). Coverage gaps closed with tests: the title=None negative contract of create_topic (CRITICAL), list_branch_refs error propagation, the no-short-circuit inventory query of prune, and the stale eight-entry status fixture brought to the nine-entry axis. Gates: 4840 tests green, ruff clean, goga lint 65 cells / 0 errors. --- .../history/.usages/history-command.md | 8 +++-- goga/commands/history/CODEMANIFEST | 33 ++++++++++++------ .../commands/topics/.usages/topics-command.md | 19 ++++++----- goga/commands/topics/CODEMANIFEST | 21 ++++++------ goga/commands/topics/render.py | 7 ++-- goga/history/.usages/prune.md | 34 ++++++++++++++++--- goga/history/.usages/topic-paths.md | 2 ++ goga/history/.usages/topic-statuses.md | 9 +++-- goga/history/CODEMANIFEST | 9 +++-- goga/history/git/CODEMANIFEST | 3 ++ goga/history/git/refs.py | 4 ++- goga/history/prune.py | 10 ++++-- goga/history/statuses/CODEMANIFEST | 25 ++++++++++---- goga/topics/.usages/creating.md | 3 +- goga/topics/.usages/ensuring.md | 4 ++- goga/topics/.usages/topic-board.md | 9 ++--- goga/topics/CODEMANIFEST | 21 ++++++++---- goga/topics/git/.usages/refs-and-switching.md | 5 +-- goga/topics/git/CODEMANIFEST | 16 ++++++--- goga/topics/git/trees.py | 11 ++++-- tests/history/git/test_refs.py | 19 +++++++++++ tests/history/test_prune.py | 22 ++++++++++++ tests/history/test_status.py | 4 ++- tests/topics/test_creation.py | 30 ++++++++++++++++ 24 files changed, 254 insertions(+), 74 deletions(-) diff --git a/goga/commands/history/.usages/history-command.md b/goga/commands/history/.usages/history-command.md index 8bbb0afa..94ec6280 100644 --- a/goga/commands/history/.usages/history-command.md +++ b/goga/commands/history/.usages/history-command.md @@ -17,7 +17,7 @@ artifacts. - Read-only. An empty history prints nothing, exit 0. -## Reading the statuses of a year +## goga history status [YEAR] [-t TOPIC] [-s STATUS] goga history status goga history status 2025 @@ -29,6 +29,9 @@ in scale order — for example "release-1-3-0 [done] [mkdocs.published]". Status filters take qualified status names: built-in names bare, tool statuses as <tool>.<name>; a record matches when any of its maximal statuses is one of the requested names. An unknown name is a clean error. +`-t/--topic` keeps the topics whose slug contains the normalized filter +as a substring; it combines with `-s/--status` by AND, and a filter that +normalizes to an empty slug is a clean error. The year is never printed; an empty result prints nothing and exits 0. ## goga history path [TOPIC] [-f FILENAME] [-y YEAR] @@ -80,4 +83,5 @@ is printed; an empty result prints nothing and exits 0. Every failure is a clean message on stderr with a non-zero exit and no fallback values: git unavailable / not a repository / detached HEAD, a topic that normalizes to an empty slug, a filename without an extension, an -unknown status name. +unknown status name, a tool package of the status scale that fails to +import (status), a topic directory that cannot be deleted (prune). diff --git a/goga/commands/history/CODEMANIFEST b/goga/commands/history/CODEMANIFEST index 06243c49..a0e8cfd3 100644 --- a/goga/commands/history/CODEMANIFEST +++ b/goga/commands/history/CODEMANIFEST @@ -64,6 +64,10 @@ Annotations: | - ensure — an optional NAME positional - prune — an optional YEAR positional, --dry-run + The surfaces carry the CLI names; the Python attribute of a callback + may carry a suffix to avoid shadowing a builtin (list -> list_topics) — + the CLI name is the contract. + Apply the `convention` CLI command docstring rule for the --help text (rendered verbatim by Click; omit Args/Returns/Raises). methods: @@ -91,8 +95,9 @@ Annotations: | Subcommand goga history status: print the flat list of topics of one year, each line "topic [status] [status] ...". - `year`: optional YEAR positional — four digits; None means the current - year; the year is never printed + `year`: optional YEAR positional — four digits, the recognized form of + a history year; None means the current year; the year is + never printed `topic`: --topic/-t value — a substring filter; the value is normalized via `normalize_topic_slug` before matching `statuses`: -s/--status values (repeatable, multiple=True) — qualified @@ -105,9 +110,10 @@ Annotations: | the color rules. Algorithm: - 1. Assemble the status scale via `assemble_status_scale` once and - validate every name in `statuses` against it — an unknown name is - a clean error (stderr, non-zero exit) + 1. Assemble the status scale via `assemble_status_scale` once — a + broken tool package of the assembly is a clean error (stderr, + non-zero exit) — and validate every name in `statuses` against it; + an unknown name is a clean error (stderr, non-zero exit) 2. A `topic` value that normalizes to an empty slug is a clean error (stderr, non-zero exit) — an empty filter would silently match every topic and is rejected instead @@ -142,7 +148,8 @@ Annotations: | means the current git branch `filename`: -f/--file value — an artifact filename with an extension; without the flag the topic directory is printed - `year`: --year/-y value — four digits; None means the current year + `year`: --year/-y value — four digits, the recognized form of a + history year; None means the current year `exit_code`: 0 on success, 1 on error Apply the `topic-paths` practice for the path contracts of the domain. @@ -195,8 +202,10 @@ Annotations: | Subcommand goga history prune: delete the orphan topics of one year — the topics no branch of the repository inventory hosts. - `year`: optional YEAR positional — four digits; None means the current - year + `year`: optional YEAR positional — four digits, the recognized form of + a history year; None means the current year; a year with no + matching directory of the tree yields an empty result — the + domain enumerates four-digit year directories only `dry_run`: the --dry-run flag — list the deletion candidates without deleting anything `exit_code`: 0 on success (an empty result included), 1 on error @@ -207,8 +216,12 @@ Annotations: | Algorithm: 1. Run the orphan cleanup via `prune_topics` with `year` and `dry_run` - 2. Echo one slug per line of the returned list - 3. An empty result prints nothing — exit 0 + 2. A domain ValueError surfaces as a clean CLI error; a git + CalledProcessError surfaces as git failed, a missing git binary + as git is not available, and an OSError of the deletion as + cannot delete topic directory + 3. Echo one slug per line of the returned list + 4. An empty result prints nothing — exit 0 Requirements: - The deletion is irreversible — the dry pass is the safe preview diff --git a/goga/commands/topics/.usages/topics-command.md b/goga/commands/topics/.usages/topics-command.md index d63a67c2..8b5ad2c8 100644 --- a/goga/commands/topics/.usages/topics-command.md +++ b/goga/commands/topics/.usages/topics-command.md @@ -6,7 +6,7 @@ facade that registers the group. The group scopes every subcommand to one year (--year/-y, default the current year); the status subcommand reads remote-tracking refs with ---remote/-r. +--remote/-r and adds the title column with --info/-i. ## Boarding all work @@ -18,11 +18,13 @@ current year); the status subcommand reads remote-tracking refs with Prints a three-column table — topic, branch, statuses — with column and row separators fitted to the terminal width. `--info/-i` adds the title column: topic, branch, title, and statuses share the width — each of -the first three capped at a quarter of it — and the title shows the -first line of the topic's `title.txt` read from the ref trees without -checkout; a topic without a title file shows an empty cell. The current -branch row carries `*`; remote hosts keep their remote prefix. A topic's -statuses are all its maximal statuses, wrapped onto continuation lines. +the first three capped at a quarter of it minus the dividers — and the +title shows the first line of the topic's `title.txt`, read from the +ref trees without checkout (the current row from the working copy); a +topic without a title file shows an empty cell. Overlong cells are +truncated with an ellipsis. The current branch row carries `*` in its +topic cell; remote hosts keep their remote prefix. A topic's statuses +are all its maximal statuses, wrapped onto continuation lines. An empty board prints nothing and exits 0. ## Creating fresh work @@ -35,8 +37,9 @@ Creates the branch with the name as entered, switches to it, and creates the topic directory of the scoped year. An explicit `--title/-t` also writes the topic title file `title.txt` — the text as entered plus a trailing newline; on the idempotent re-run (the current branch already -hosts the same slug) the title file is created or overwritten and -nothing else mutates. Without `-t` no title file is written. Occupied +hosts the same slug) the topic directory is ensured and the title file +is created or overwritten — nothing else mutates, no switch happens. +Without `-t` no title file is written. Occupied names and empty slugs trigger a re-ask on an interactive terminal, or a clean error with a hint otherwise. diff --git a/goga/commands/topics/CODEMANIFEST b/goga/commands/topics/CODEMANIFEST index fd151265..3f896afc 100644 --- a/goga/commands/topics/CODEMANIFEST +++ b/goga/commands/topics/CODEMANIFEST @@ -35,16 +35,16 @@ Annotations: | --- -"topics(year: str | None)": +"topics(year: str | None = None)": location: topics.py annotations: | The goga topics command group — a click.Group container for the topics subcommands, exported via __all__ and registered in the root application group. The group carries the year scope every subcommand shares. - `year`: the --year/-y group option — exactly one year as four digits; - None means the current year; a search across years does not - exist + `year`: the --year/-y group option — exactly one year, four digits the + recognized form; None means the current year; a search across + years does not exist Use the `click` practice for the group decorator, the group option, and the subcommand registration. @@ -83,9 +83,9 @@ Annotations: | - Read-only — nothing is created, written, or switched Constraints: - - Do not print the year, the artifacts, or a header — the table - carries topic, branch, the title column under `info`, and - statuses only + - Do not print the year or the artifacts, and no heading line + outside the table — the table carries topic, branch, the title + column under `info`, and statuses only "create(branch_name: str, title: str | None = None) -> exit_code: int": | Subcommand goga topics create: create fresh work — a branch with the name as entered, its topic directory of the scoped year, and an @@ -143,9 +143,10 @@ Annotations: | Apply the `click` practice for echo. Algorithm: - 1. Compute the column widths from `width` and the record content per - the width rule of the requirements — the three-column rule without - `info`, the four-column rule with it + 1. Compute the column widths from `width` alone per the width rule of + the requirements — the three-column rule without `info`, the + four-column rule with it; the grid is fixed and independent of the + record content 2. Print one header row and one separator row with column and row dividers — the column order is topic, branch, title, statuses under `info` diff --git a/goga/commands/topics/render.py b/goga/commands/topics/render.py index 1fbaa098..ed2c92b6 100644 --- a/goga/commands/topics/render.py +++ b/goga/commands/topics/render.py @@ -36,9 +36,10 @@ def render_topic_board(records: list[BoardRecord], width: int, info: bool = Fals four-column width rule. Algorithm: - 1. Compute the column widths from ``width`` and the record content - per the width rule of the requirements — the three-column rule - without ``info``, the four-column rule with it + 1. Compute the column widths from ``width`` alone per the width + rule of the requirements — the three-column rule without + ``info``, the four-column rule with it; the grid is fixed and + independent of the record content 2. Print one header row and one separator row with column and row dividers — the column order is topic, branch, title, statuses under ``info`` diff --git a/goga/history/.usages/prune.md b/goga/history/.usages/prune.md index cf52916e..b87b74c3 100644 --- a/goga/history/.usages/prune.md +++ b/goga/history/.usages/prune.md @@ -5,8 +5,9 @@ facade. For consumers that maintain the tree: CLI cleanup commands, maintenance scripts. A topic is an orphan when no branch of the repository inventory hosts -its slug: a local branch named so, or a remote-tracking ref whose short -name — the part after the first "/" — normalizes to it. The protection +its slug: a local branch whose name normalizes to it, or a +remote-tracking ref whose short name — the part after the first "/" — +normalizes to it. The protection is year-independent: a branch protects same-named topics of every year. Deletion is unconditional — no status protects a topic, and the whole topic directory goes with all of its artifacts. The tree lives outside @@ -26,16 +27,41 @@ print("\n".join(removed)) - One slug per result entry, sorted alphabetically; an empty result is an empty list — not an error. +- The slugs come out normalized: a manually unnormalized directory name + is listed yet stays on disk — only the normalized twin path is deleted. + Treat the list as the candidate set, not a receipt of deletions. - `year` defaults to the current year; other years are never touched. - Filesystem-only: no branch, ref, or index of git is mutated in any - mode. + mode. The ref listing is the one git call of the flow; its failures — + a git infrastructure error or a missing git binary — propagate to the + caller, so wrap them where a clean message is required. + +## Reading the branch inventory + +The protection oracle of the prune is the full branch inventory, read +with `list_branch_refs`: + +```python +from goga.history import BranchRef, list_branch_refs + +refs = list_branch_refs() +for ref in refs: + print(ref.name, "remote" if ref.remote else "local") +``` + +- Local branches and remote-tracking refs come back in one list, + sorted by display name; `name` is `<remote>/<branch>` for a + remote-tracking ref, and a local branch and its remote twin stay two + distinct refs. +- Read-only and offline — the refs as they exist locally; a git failure + of the listing propagates to the caller. ## Deleting one topic directory ```python from goga.history import remove_topic_dir -removed = remove_topic_dir("release-1-3-0", year="2025") +existed = remove_topic_dir("release-1-3-0", year="2025") ``` - True when the directory existed and was deleted, False when it was diff --git a/goga/history/.usages/topic-paths.md b/goga/history/.usages/topic-paths.md index cddfb175..8fb19d49 100644 --- a/goga/history/.usages/topic-paths.md +++ b/goga/history/.usages/topic-paths.md @@ -31,6 +31,8 @@ from goga.history import resolve_topic_file plan = resolve_topic_file("history-commands", "plan.md") # -> .goga/history/2026/history-commands/plan.md +title = resolve_topic_file("feature-foo", "title.txt") +# -> .goga/history/2026/feature-foo/title.txt ``` - The filename is arbitrary but must carry an extension (`plan.md`); diff --git a/goga/history/.usages/topic-statuses.md b/goga/history/.usages/topic-statuses.md index bfbbe5b1..64ddd8d2 100644 --- a/goga/history/.usages/topic-statuses.md +++ b/goga/history/.usages/topic-statuses.md @@ -6,9 +6,12 @@ dashboards. A topic's status is the set of its maximal present statuses on the topic status scale. The built-in axis is fixed — empty, new, defined, discovered, -backlog, designed, specified, planned, done, marked by the artifacts -title.txt, prd.md, adr.md, task.md, arch.md, design.md, plan.md, -completed/plan.md inside the topic directory. Tool packages extend the scale +backlog, designed, specified, planned, done. `empty` is the floor for a +topic with no artifact at all; each of the other eight is marked by one +artifact inside the topic directory, in axis order — new by title.txt, +defined by prd.md, discovered by adr.md, backlog by task.md, designed by +arch.md, specified by design.md, planned by plan.md, done by +completed/plan.md. Tool packages extend the scale with qualified statuses `<tool>.<name>`, so one topic can carry several statuses at once — all of them are shown. diff --git a/goga/history/CODEMANIFEST b/goga/history/CODEMANIFEST index 9baf8a6c..53fb621a 100644 --- a/goga/history/CODEMANIFEST +++ b/goga/history/CODEMANIFEST @@ -378,10 +378,13 @@ Annotations: | Delete the orphan topics of one year — the topics no branch of the repository inventory hosts. - `year`: optional year as four digits; None means the current year + `year`: optional year as four digits; None and the empty string mean + the current year `dry_run`: True lists the orphan topics without deleting anything `removed`: the slugs of the orphan topics sorted alphabetically — the - deleted ones, or the deletion candidates under `dry_run` + deleted ones, or the deletion candidates under `dry_run`; + the slugs come out normalized — a manually unnormalized + directory name is listed yet not reachable for deletion Apply the `convention` practice for docstring style and intra-package imports. @@ -403,6 +406,8 @@ Annotations: | - A topic is protected when at least one branch of the inventory normalizes to its slug — the protection is year-independent, a branch protects same-named topics of every year + - The branch inventory is queried even when the resolved year holds no + topics; a git failure of the listing propagates to the caller - Deletion is unconditional — no status protects a topic - Only the resolved year is affected — no other year is touched - Filesystem-only — no branch, ref, or index of git is mutated in any diff --git a/goga/history/git/CODEMANIFEST b/goga/history/git/CODEMANIFEST index 948f62c5..ac58e2fe 100644 --- a/goga/history/git/CODEMANIFEST +++ b/goga/history/git/CODEMANIFEST @@ -67,6 +67,9 @@ Annotations: | Requirements: - Read-only — no ref is created, moved, or deleted - No network — remote-tracking refs as they exist locally + - A git infrastructure failure of the listing raises + subprocess.CalledProcessError, a missing git binary OSError — both + propagate to the caller Constraints: - Do not deduplicate — a local branch and its remote twin are two distinct diff --git a/goga/history/git/refs.py b/goga/history/git/refs.py index 4a579895..abee3777 100644 --- a/goga/history/git/refs.py +++ b/goga/history/git/refs.py @@ -48,7 +48,9 @@ def list_branch_refs() -> list[BranchRef]: Algorithm: 1. Ask git for the local branch refs 2. Ask git for the remote-tracking refs - 3. Merge both into one inventory sorted alphabetically by display + 3. Drop the ``<remote>/HEAD`` symrefs of the answers — they are + pointers, not branches + 4. Merge both into one inventory sorted alphabetically by display name Requirements: diff --git a/goga/history/prune.py b/goga/history/prune.py index 0ee8f92d..f5e995ab 100644 --- a/goga/history/prune.py +++ b/goga/history/prune.py @@ -22,12 +22,15 @@ def prune_topics(year: str | None = None, dry_run: bool = False) -> list[str]: repository inventory hosts. Args: - year: Optional year as four digits; ``None`` means the current year. + year: Optional year as four digits — ``None`` and the empty string + mean the current year. dry_run: ``True`` lists the orphan topics without deleting anything. Returns: The slugs of the orphan topics sorted alphabetically — the deleted - ones, or the deletion candidates under ``dry_run``. + ones, or the deletion candidates under ``dry_run``. The slugs come + out normalized: a manually unnormalized directory name is listed + yet not reachable for deletion. Algorithm: 1. Resolve the year — ``year`` when given, otherwise the current year @@ -49,6 +52,9 @@ def prune_topics(year: str | None = None, dry_run: bool = False) -> list[str]: normalizes to its slug — the protection is year-independent, a branch protects same-named topics of every year. + The branch inventory is queried even when the resolved year holds + no topics; a git failure of the listing propagates to the caller. + Deletion is unconditional — no status protects a topic. Only the resolved year is affected — no other year is touched. diff --git a/goga/history/statuses/CODEMANIFEST b/goga/history/statuses/CODEMANIFEST index bb875534..c93ad8c5 100644 --- a/goga/history/statuses/CODEMANIFEST +++ b/goga/history/statuses/CODEMANIFEST @@ -46,6 +46,9 @@ Annotations: | backlog, designed, specified, planned, done by the artifacts title.txt, prd.md, adr.md, task.md, arch.md, design.md, plan.md, completed/plan.md + - The built-in empty entry carries the empty artifact path — one name + more than the artifact list: it is never markable and surfaces as + the single status of a topic with no artifact present - A tool status never reorders or replaces a built-in one properties: "stages -> list[Stage]": | @@ -103,7 +106,8 @@ Annotations: | `name`: the qualified name — bare for built-in entries, <tool>.<name> for tool entries `filepath`: the artifact path relative to the topic directory; nested - paths allowed + paths allowed; empty only for the built-in empty entry — + such an entry is never marked present `before`: the qualified name of the entry this one precedes; None for built-in entries `after`: the qualified name of the entry this one follows; None for @@ -115,6 +119,8 @@ Annotations: | Requirements: - A tool entry carries at least one anchor; a built-in entry carries none — its position is the axis order + - A filepath of the empty string belongs to the built-in empty entry + alone — no tool entry may register the unmarked floor properties: "name -> str": | The qualified status name. @@ -200,11 +206,14 @@ Annotations: | 5. Call the callback of the `registration` practice with a registry scoped to the package 6. Any exception from the callback — a registration content error or a - crashed callback — skips that registration with a warning to stderr; - the package import failure of step 3 remains the only fatal case - 7. Resolve anchors and validate placement ranges; an unresolvable - anchor or an invalid range skips the registration with a warning to - stderr + crashed callback — ends that callback's registration with a warning + to stderr, keeping the entries it registered before failing; the + package import failure of step 3 remains the only fatal case + 7. Resolve the anchors of each surviving entry against the list + assembled by the moment the entry is processed — the built-in axis + plus the entries of the earlier packages and the earlier entries of + the current one; an anchor naming anything else, or an invalid + placement range, skips the registration with a warning to stderr 8. Assemble and return the scale Requirements: @@ -212,6 +221,10 @@ Annotations: | before any output and before any mutation - The scale assembles from the surviving registrations alone — one broken registration never cancels the rest + - Entries sharing an anchor form one continuous block in registration + order: an after-anchored entry lands at the end of its anchor's + block, a before-anchored entry right in front of its anchor, and + both anchors given define a range the entry must fit into - Package enumeration mirrors goga/connect: importlib.metadata .packages_distributions() filtered to top-level module names starting with goga_tool_, sorted alphabetically by top-level module name diff --git a/goga/topics/.usages/creating.md b/goga/topics/.usages/creating.md index 03ea1ba8..6c371b72 100644 --- a/goga/topics/.usages/creating.md +++ b/goga/topics/.usages/creating.md @@ -43,6 +43,7 @@ result = create_topic("Feature/Foo_Bar", title="Payment retry") - The current branch already hosting the same slug with an explicit title: the topic directory is ensured and `title.txt` is created or overwritten — nothing else mutates, no switch happens. -- Without a title the behavior carries no title file at all. +- Without a title no title file is written — an existing one is left + untouched. - `title.txt` marks the `new` status on the topic status scale; no other artifact is written — artifacts belong to their producers. diff --git a/goga/topics/.usages/ensuring.md b/goga/topics/.usages/ensuring.md index 96b04652..c487d699 100644 --- a/goga/topics/.usages/ensuring.md +++ b/goga/topics/.usages/ensuring.md @@ -25,7 +25,9 @@ print(result) # one line — the outcome - Nothing hosts the identifier -> fresh work: the branch is created with the name as entered, the repository switches to it, and the topic directory of the year is created from its slug — - `Created branch <name> and topic <year>/<slug>`. + `Created branch <name> and topic <year>/<slug>`. No title file is + written: the creation fallback takes no title — titled fresh work is + `create_topic` alone. - A hosted identifier -> the plain switch outcome: `Switched to branch <name>`, `Created branch <name> from <remote>/<name>`, or `Already on branch <name>` (idempotent, nothing touched). diff --git a/goga/topics/.usages/topic-board.md b/goga/topics/.usages/topic-board.md index b357513a..0a2af0b4 100644 --- a/goga/topics/.usages/topic-board.md +++ b/goga/topics/.usages/topic-board.md @@ -23,10 +23,11 @@ for record in records: ``` - One `BoardRecord` per hosted topic: the slug, the hosting branch display - name, the maximal status names in scale order, the current marker, and - the title — the first line of the topic's `title.txt`, or None when the - topic has none. The title is read from the ref trees without checkout; - rows hosted by other branches show their titles. + name, the maximal status names in scale order, the current and remote + markers, and the title — the first line of the topic's `title.txt`, or + None when the topic has none. Rows hosted by other branches read their + titles from the ref trees without checkout; the current branch's row + reads the working copy, so an uncommitted title edit shows at once. - A local branch and its remote twin collapse to one row — the local branch wins. Two different branches hosting one slug stay two rows. - Sorting: scale order of the first maximal status, then topic alphabet. diff --git a/goga/topics/CODEMANIFEST b/goga/topics/CODEMANIFEST index 20fed5a0..1d5c8631 100644 --- a/goga/topics/CODEMANIFEST +++ b/goga/topics/CODEMANIFEST @@ -60,7 +60,9 @@ Annotations: | title, and the combined ensure orchestration that switches onto hosted work and creates it when nothing hosts the identifier. Topic identity, addressing, and statuses belong to the history facade; git access - belongs to the topics git cell. Mutations are local-only and happen + belongs to the topics git cell. Git infrastructure failures and the + fatal scale-assembly ImportError surface as click.ClickException. + Mutations are local-only and happen strictly after every decision is made. Use relative imports. --- @@ -205,9 +207,11 @@ Annotations: | 3. Exact branch name match -> the candidates hosting that name 4. Exact slug match otherwise -> the branches hosting the slug, local branches first - 5. Prefix matches otherwise -> the branches whose name or hosted slug - starts with the input - 6. Return the candidates with their statuses + 5. Prefix matches otherwise -> the branches whose name starts with + the raw `identifier`, and — when the slug is non-empty — the + branches hosting a topic whose slug starts with it + 6. Collapse the tier to one entry per branch, then return the + candidates with their statuses Requirements: - Exact matches always precede prefix matches @@ -325,7 +329,7 @@ Annotations: | Algorithm: 1. Normalize `branch_name` into a slug via `normalize_topic_slug` 2. Empty slug -> input error: print the reason, prompt for a new name - on an interactive terminal and restart, or fail with the hint + on an interactive terminal and restart, or fail with the reason otherwise 3. The current branch — read via `resolve_current_branch_name` — hosts the same slug -> the idempotent path: a `title` given writes the @@ -346,12 +350,17 @@ Annotations: | - The branch keeps the name as entered; the topic directory takes the slug — the two may deliberately differ - The title file carries `title` as entered plus a single trailing - newline, encoded UTF-8 + newline, encoded UTF-8; an empty string writes the bare newline, and + the first-line read of the board yields the empty title - The title file is written only when `title` is given — None never creates and never overwrites it; an explicit `title` creates the file or overwrites it - The topic directory exists before the title file is written - An aborted re-ask leaves the repository untouched + - On the fresh path the branch is created and switched to before the + topic directory and the title file are written — a filesystem + failure of the writes leaves the caller on the new branch with the + directory or the title missing, reported as a clean error - The caller stays on the new branch Constraints: diff --git a/goga/topics/git/.usages/refs-and-switching.md b/goga/topics/git/.usages/refs-and-switching.md index aff9d1ec..f7400dbb 100644 --- a/goga/topics/git/.usages/refs-and-switching.md +++ b/goga/topics/git/.usages/refs-and-switching.md @@ -49,8 +49,9 @@ if content is not None: print(content.splitlines()[0] if content else "") ``` -- Returns the file content as text, or None when the file is absent at - the ref — absence is a normal condition, not an error. +- Returns the file content as text, or None when the file cannot be read + at the ref — an absent file is the normal case, not an error; any other + git failure reads the same way. - One git invocation per file; no checkout, no worktree, no temp directory — the working copy stays untouched. diff --git a/goga/topics/git/CODEMANIFEST b/goga/topics/git/CODEMANIFEST index fa7ec483..424a38b6 100644 --- a/goga/topics/git/CODEMANIFEST +++ b/goga/topics/git/CODEMANIFEST @@ -6,8 +6,10 @@ Usages: Read-only inspection (branch refs, ref tree paths and file contents, working tree state) plus host-side mutations (checkout of a local branch, creating a local branch from a remote-tracking ref, - create-and-switch to a new branch). Mock the subprocess call in tests - per `convention`. + create-and-switch to a new branch). A single-file content read decodes + UTF-8 explicitly and maps every git failure to None — the content is + display data; every other invocation propagates its git error. Mock + the subprocess call in tests per `convention`. Annotations: | The `convention` practice is used for: @@ -116,8 +118,8 @@ Annotations: | `ref`: the ref to read — a display branch name as carried by `BranchRef` `path`: the file path to read, relative to the repository root - `content`: the file content as text, or None when the file is absent at - the ref + `content`: the file content as text, or None when git cannot read the + file at the ref Apply the `git` practice for the invocation pattern. Apply the `convention` practice for docstring style and intra-package @@ -125,12 +127,16 @@ Annotations: | Algorithm: 1. Ask git for the content of `path` at the `ref` - 2. An absent file at the ref yields None — not an error + 2. A failed read yields None — not an error: the content is display + data, never a reason to fail the reader 3. Return the content as text Requirements: - One git invocation per file - Read-only — the working copy, the index, and .git stay untouched + - Every git failure of the read yields None — an absent file at the + ref, an unknown ref, or any other git error are indistinguishable + to the caller - The content is returned as-is — no interpretation, no transformation Constraints: diff --git a/goga/topics/git/trees.py b/goga/topics/git/trees.py index 8791b0f6..7ba0b45c 100644 --- a/goga/topics/git/trees.py +++ b/goga/topics/git/trees.py @@ -71,17 +71,22 @@ def read_ref_file(ref: str, path: str) -> str | None: path: The file path to read, relative to the repository root. Returns: - The file content as text, or None when the file is absent at - the ref. + The file content as text, or None when git cannot read the + file at the ref — an absent file or any other git failure. Algorithm: 1. Ask git for the content of ``path`` at the ``ref`` - 2. An absent file at the ref yields None — not an error + 2. A failed read yields None — not an error: the content is + display data, never a reason to fail the reader 3. Return the content as text Requirements: One git invocation per file. + Every git failure of the read yields None — an absent file at + the ref, an unknown ref, or any other git error are + indistinguishable to the caller. + Read-only — the working copy, the index, and ``.git`` stay untouched. diff --git a/tests/history/git/test_refs.py b/tests/history/git/test_refs.py index bb630c8f..4b825d3d 100644 --- a/tests/history/git/test_refs.py +++ b/tests/history/git/test_refs.py @@ -162,3 +162,22 @@ def test_list_branch_refs_empty_repository(self) -> None: assert refs == [] assert run.call_count == 2 + + def test_list_branch_refs_propagates_git_failure(self) -> None: + """A git infrastructure failure of the listing propagates unwrapped.""" + failure = subprocess.CalledProcessError(returncode=128, cmd=["git", "for-each-ref"]) + run = mock.Mock(side_effect=failure) + with ( + mock.patch("goga.history.git.refs.subprocess.run", run), + pytest.raises(subprocess.CalledProcessError), + ): + list_branch_refs() + + def test_list_branch_refs_propagates_missing_binary(self) -> None: + """A missing git binary surfaces as the OS-level error of the call.""" + run = mock.Mock(side_effect=FileNotFoundError("git")) + with ( + mock.patch("goga.history.git.refs.subprocess.run", run), + pytest.raises(FileNotFoundError, match="git"), + ): + list_branch_refs() diff --git a/tests/history/test_prune.py b/tests/history/test_prune.py index 52098697..144a493c 100644 --- a/tests/history/test_prune.py +++ b/tests/history/test_prune.py @@ -200,6 +200,28 @@ def test_prune_topics_absent_year_returns_empty_list(self, tmp_path: Path, monke remover.assert_not_called() assert kept.is_dir() + def test_prune_topics_queries_inventory_even_for_empty_year( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A year without topics still reads the branch inventory — no short-circuit.""" + monkeypatch.chdir(tmp_path) + with _inventory([]) as inventory: + assert prune_topics("1999") == [] + + inventory.assert_called_once_with() + + def test_prune_topics_propagates_inventory_git_failure( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A git failure of the ref listing propagates to the caller.""" + monkeypatch.chdir(tmp_path) + failure = subprocess.CalledProcessError(returncode=128, cmd=["git", "for-each-ref"]) + with ( + mock.patch("goga.history.prune.list_branch_refs", side_effect=failure), + pytest.raises(subprocess.CalledProcessError), + ): + prune_topics("2026") + def test_prune_topics_never_mutates_git(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The only git invocations of the flow are the read-only ref listings — wet and dry alike.""" monkeypatch.chdir(tmp_path) diff --git a/tests/history/test_status.py b/tests/history/test_status.py index 51ea558d..a54e0f71 100644 --- a/tests/history/test_status.py +++ b/tests/history/test_status.py @@ -42,10 +42,11 @@ def now() -> datetime: def _builtin_scale() -> StatusScale: - """Deterministic built-in scale — eight entries with the contract artifacts.""" + """Deterministic built-in scale — nine entries with the contract artifacts.""" return StatusScale( stages=[ Stage(name="empty", filepath=""), + Stage(name="new", filepath="title.txt"), Stage(name="defined", filepath="prd.md"), Stage(name="discovered", filepath="adr.md"), Stage(name="backlog", filepath="task.md"), @@ -140,6 +141,7 @@ class TestResolveTopicStatus: @pytest.mark.parametrize( ("artifact", "expected"), [ + ("title.txt", ["new"]), ("prd.md", ["defined"]), ("adr.md", ["discovered"]), ("task.md", ["backlog"]), diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index 40e98834..a8e72634 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -278,6 +278,36 @@ def test_create_topic_idempotent_current_host( create_and_switch.assert_not_called() ensure_dir.assert_not_called() + def test_create_topic_without_title_writes_no_title_file( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A free name without a title: the topic directory carries no title file.""" + monkeypatch.chdir(tmp_path) + _wire_inventory(monkeypatch, [], current="main") + monkeypatch.setattr(creation, "current_year", lambda: "2026") + + result = create_topic("Feature/Foo_Bar") + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + topic_dir = tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" + assert topic_dir.is_dir() + assert not (topic_dir / "title.txt").exists() + + def test_create_topic_without_title_leaves_existing_title_file( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The idempotent path without a title: an existing title file stays verbatim.""" + monkeypatch.chdir(tmp_path) + topic_dir = _topic_dir(tmp_path, "2026", "feature-foo") + (topic_dir / "title.txt").write_text("Old\n", encoding="utf-8") + create_and_switch = _wire_inventory(monkeypatch, [], current="feature-foo") + + result = create_topic("feature-foo") + + assert result == "Branch feature-foo already hosts topic 2026/feature-foo" + create_and_switch.assert_not_called() + assert (topic_dir / "title.txt").read_text(encoding="utf-8") == "Old\n" + def test_create_topic_with_title_idempotent_path( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From bf6db0ebda0ae4ed1087f8861fbe39864483a829 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 14:13:55 +0300 Subject: [PATCH 116/229] fix: build script in development workflow --- .goga/workflows/development.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.goga/workflows/development.yml b/.goga/workflows/development.yml index 7c650a96..9625a53d 100644 --- a/.goga/workflows/development.yml +++ b/.goga/workflows/development.yml @@ -41,7 +41,5 @@ extend: after: - commit-changes timeout: "8h" - script: | - plan=$(python3 -m goga history path -f plan.md) || exit 1 - python3 -m goga.build "$plan" + script: python3 -m goga.build "$(python3 -m goga history path -f plan.md)" after_script: rm -rf .ralphex From b39db673976a35fe4305d5d23de19666503df920 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 16:51:36 +0000 Subject: [PATCH 117/229] feat: declare the topics create --publish contracts across five cells --- .../commands/topics/.usages/topics-command.md | 33 ++- goga/commands/topics/CODEMANIFEST | 71 +++++- goga/config/.usages/project-configuration.md | 26 +++ goga/config/CODEMANIFEST | 18 +- goga/config/project/CODEMANIFEST | 77 ++++++- goga/topics/.usages/publishing.md | 56 +++++ goga/topics/CODEMANIFEST | 148 ++++++++++++- goga/topics/git/.usages/publishing.md | 87 ++++++++ goga/topics/git/CODEMANIFEST | 204 ++++++++++++++++-- 9 files changed, 666 insertions(+), 54 deletions(-) create mode 100644 goga/topics/.usages/publishing.md create mode 100644 goga/topics/git/.usages/publishing.md diff --git a/goga/commands/topics/.usages/topics-command.md b/goga/commands/topics/.usages/topics-command.md index 8b5ad2c8..a86e7ebb 100644 --- a/goga/commands/topics/.usages/topics-command.md +++ b/goga/commands/topics/.usages/topics-command.md @@ -6,7 +6,8 @@ facade that registers the group. The group scopes every subcommand to one year (--year/-y, default the current year); the status subcommand reads remote-tracking refs with ---remote/-r and adds the title column with --info/-i. +--remote/-r and adds the title column with --info/-i; the create subcommand +publishes fresh work without switching under --publish/-p. ## Boarding all work @@ -43,6 +44,36 @@ Without `-t` no title file is written. Occupied names and empty slugs trigger a re-ask on an interactive terminal, or a clean error with a hint otherwise. +## Creating and publishing fresh work + + goga topics create Feature/Foo_Bar --publish -t "Payment retry" + goga topics create Feature/Foo_Bar -p -t "Payment retry" --base-ref origin/release-1.3 + goga topics create Feature/Foo_Bar -p -t "Payment retry" -c "chore: new topic {slug}" + +Creates the branch off the configured base (topics.base_ref in +.goga/config.yml, overridden by --base-ref), commits the topic title file +on it without touching the working copy — the caller stays on their +branch, a dirty tree and a detached HEAD are both fine — and pushes the +branch to origin with upstream binding. The topic is visible on the +remote board with the new status. The result is one line: created and +published on the remote. + +- The title is required in this mode — the board reads the topic through + the title file. +- The commit message comes from topics.publish_commit (default + `goga: create topic {slug}`), overridden by --commit/-c; the {slug} + placeholder takes the topic slug, a template without it is used as is. +- An occupied name, an empty slug, or a slug already hosted by any branch + of the inventory re-asks on an interactive terminal, or fails with a + hint to the board. +- A failed publication rolls back fully — the branch is deleted and one + clean error names the reason; re-run after fixing the cause succeeds. +- The base must come from --base-ref or the configuration — nothing set is + a clean error with a configuration example; the base resolves as git + resolves it, no fetch happens. +- --base-ref or --commit without --publish is a clean error; a missing + origin or an unset git identity is a clean error. + ## Switching to existing work goga topics switch history-com diff --git a/goga/commands/topics/CODEMANIFEST b/goga/commands/topics/CODEMANIFEST index 3f896afc..f2c1bc45 100644 --- a/goga/commands/topics/CODEMANIFEST +++ b/goga/commands/topics/CODEMANIFEST @@ -4,11 +4,19 @@ Imports: - collect_topic_board - switch_topic - create_topic + - publish_topic Usages: - topic-board - switching - creating + - publishing From: goga/topics + - Types: + - load_project_config + - TopicsConfig + Usages: + - project-configuration + From: goga/config Usages: convention: .goga/usages/conventions.md @@ -27,6 +35,16 @@ Annotations: | arguments, and options of each subcommand, echo, and exit-code propagation. + Use the `publishing` practice for the fast creation-and-publication + contract of the domain. Use the `project-configuration` practice for the + schema of the topics section. + + The fast-creation flags resolve their values at this layer: a flag beats + the topics section — `TopicsConfig` — of the project configuration read + via `load_project_config`, which beats the built-in default; the + configuration is read on the publish path only, and only for values no + flag provided. + This cell is the CLI surface of the topics domain: a thin wrapper that resolves inputs, delegates every computation to the domain routines, and renders the board. No inventory walking, no switch resolution, no git @@ -51,7 +69,8 @@ Annotations: | Subcommand surfaces: - status — a --remote/-r flag, an --info/-i flag - - create — a NAME positional, a --title/-t option + - create — a NAME positional, a --title/-t option, a --publish/-p flag, + a --base-ref option, a --commit/-c option - switch — an IDENTIFIER positional Apply the `convention` CLI command docstring rule for the --help text @@ -86,29 +105,59 @@ Annotations: | - Do not print the year or the artifacts, and no heading line outside the table — the table carries topic, branch, the title column under `info`, and statuses only - "create(branch_name: str, title: str | None = None) -> exit_code: int": | + "create(branch_name: str, title: str | None = None, publish: bool = False, base_ref: str | None = None, commit_message: str | None = None) -> exit_code: int": | Subcommand goga topics create: create fresh work — a branch with the name as entered, its topic directory of the scoped year, and an - optional topic title. + optional topic title; under --publish the work is created off an + explicit base and published to origin without switching. `branch_name`: NAME positional — the branch name as entered - `title`: the --title/-t value — the topic title; None writes no title - file + `title`: the --title/-t value — the topic title; required under + --publish, optional otherwise + `publish`: the --publish/-p flag — the fast creation-and-publication + mode + `base_ref`: the --base-ref value — the base of the published branch; + beats the topics section of the configuration + `commit_message`: the --commit/-c value — the commit message template; + beats the topics section of the configuration `exit_code`: 0 on success, 1 on error - Apply the `creating` practice for the creation contract of the - domain. + Apply the `creating` practice for the creation contract of the domain. + Apply the `publishing` practice for the fast creation-and-publication + contract of the domain. + Apply the `project-configuration` practice for the topics section + schema. Apply the `click` practice for exit-code propagation. Algorithm: - 1. Delegate to `create_topic` with `branch_name`, the scoped year, - and `title` - 2. Echo the single result line - 3. Propagate the exit code + 1. `base_ref` or `commit_message` without `publish` -> clean error: + the publication-only options never act silently + 2. `publish` with no `title` -> clean error asking for the title + 3. The default path delegates to `create_topic` with `branch_name`, + the scoped year, and `title` + 4. The publish path resolves the base — `base_ref`, otherwise the + topics section of the configuration loaded via + `load_project_config`, otherwise a clean error naming the + configuration line and the flag — and the message template — + `commit_message`, otherwise the topics section, otherwise the + built-in default `goga: create topic {slug}` + 5. The publish path delegates to `publish_topic` with `branch_name`, + `title`, the resolved base, the resolved template, and the scoped + year + 6. Echo the single result line + 7. Propagate the exit code + + Requirements: + - The configuration is read on the publish path only, and only for + values no flag provided — the default path never reads it + - A missing configuration file counts as an unset value; a present + but invalid one surfaces its own clean error Constraints: - Do not validate the name at the CLI layer — the domain and git own that + - Do not switch branches or render the board here — every computation + belongs to the domain "switch(identifier: str) -> exit_code: int": | Subcommand goga topics switch: bring the repository onto the branch hosting the requested work. diff --git a/goga/config/.usages/project-configuration.md b/goga/config/.usages/project-configuration.md index ab9d963e..814b7a48 100644 --- a/goga/config/.usages/project-configuration.md +++ b/goga/config/.usages/project-configuration.md @@ -18,6 +18,7 @@ from goga.config import ( CodemanifestConfig, DepConfig, LintConfig, + TopicsConfig, load_project_config, ) ``` @@ -51,6 +52,11 @@ config = load_project_config() (integer); an empty `roles` list and an empty `env` mapping pass through verbatim — the empty-to-full-set (roles) and env-requires-agent (env) semantics belong to the consuming command +- Optional `topics` follows structural-only validation: `topics.base_ref` and + `topics.publish_commit` are strings when present — absent/YAML-null/empty/ + whitespace resolves to `None`; a present-but-non-mapping `topics` raises + `ValueError`. Rev resolvability, template grammar, and the default template + belong to the consuming command **Error handling**: @@ -164,6 +170,9 @@ lint: # optional linter section ignore: # list of exact relative paths to exclude - .venv/ # glob (**, *, ?) is NOT supported - build/dist +topics: # optional fast-creation section + base_ref: origin/main # str | absent — base of published topic branches + publish_commit: "goga: create topic {slug}" # str | absent — commit message template ``` ### Required Fields @@ -235,6 +244,9 @@ afm) that consume these fields. | `usages.<group>.<dep>.ref` | str | None | optional git ref (branch/tag/commit; absent → default branch) | | `lint` | mapping | None | Linter section (optional); when absent, config.lint is None | | `lint.ignore` | list | `[]` | List of exact relative paths excluded from AST traversal by `goga lint`. Glob is not supported | +| `topics` | mapping | None | Fast-creation section (structural validation only) | +| `topics.base_ref` | str | None | Base revision of published topic branches, verbatim | +| `topics.publish_commit` | str | None | Commit message template; the {slug} placeholder is optional, verbatim | ## Accessing Configuration Data @@ -294,8 +306,22 @@ config.build.review_executor.patience # int | None — external-review stop thr config.codemanifest # CodemanifestConfig | None config.codemanifest.usages # dict — {str: str} config.codemanifest.annotations # str | None + +# TopicsConfig fields — None when the `topics` section is absent +config.topics # TopicsConfig | None +config.topics.base_ref # str | None — base revision, verbatim +config.topics.publish_commit # str | None — commit message template, verbatim ``` +```yaml +topics: + base_ref: origin/main + publish_commit: "goga: create topic {slug}" +``` + +The default template and the `{slug}` substitution belong to the consuming +command (the create command). + The legacy `build.review_patience` key is no longer parsed — declare review patience as `build.review_executor.patience`. diff --git a/goga/config/CODEMANIFEST b/goga/config/CODEMANIFEST index 7b1e25d3..2b0d32d9 100644 --- a/goga/config/CODEMANIFEST +++ b/goga/config/CODEMANIFEST @@ -9,6 +9,7 @@ Imports: - CodemanifestConfig - DepConfig - LintConfig + - TopicsConfig From: goga/config/project - Types: - HomeConfig @@ -30,11 +31,12 @@ Annotations: | - Organizing the test infrastructure - Understanding the general principles and rules of development and testing in the project - This cell is a re-export facade: it embeds (re-exports) all configuration types - from goga/config/project (project configuration), goga/config/home - (home/docker configuration), and goga/config/git (git-environment - introspection — `resolve_project_name`) so consumers import a single entry - point (From: goga/config). It owns no behavior — all logic lives in the child + This cell is a re-export facade: it embeds (re-exports) all configuration + types from goga/config/project (project configuration, including the + topics fast-creation section), goga/config/home (home/docker + configuration), and goga/config/git (git-environment introspection — + `resolve_project_name`) so consumers import a single entry point + (From: goga/config). It owns no behavior — all logic lives in the child cells. --- @@ -52,6 +54,7 @@ Annotations: | ->DockerArgsConfig: {} ->load_home_config: {} ->resolve_project_name: {} +->TopicsConfig: {} --- @@ -59,6 +62,5 @@ Author: Goga CreatedAt: 24/07/26 Description: | Re-export facade — embeds project (goga/config/project), home - (goga/config/home), and git-introspection (goga/config/git — - `resolve_project_name`) configuration types so consumers import a single - entry point. Owns no behavior. + (goga/config/home), and git-introspection (goga/config/git) configuration + types so consumers import a single entry point. Owns no behavior. diff --git a/goga/config/project/CODEMANIFEST b/goga/config/project/CODEMANIFEST index 2997de48..0aa4f58e 100644 --- a/goga/config/project/CODEMANIFEST +++ b/goga/config/project/CODEMANIFEST @@ -38,6 +38,12 @@ Annotations: | verbatim (the empty-to-full-set and the env-requires-agent meanings belong to the consuming cell). + The optional topics section follows the same structural-only stance: the + loader enforces that topics.base_ref and topics.publish_commit are strings + when present — absent/YAML-null/empty/whitespace normalizes to None; rev + resolvability, template grammar, and the default template belong to the + consuming command. + --- "load_project_config() -> config: ProjectConfig": @@ -97,8 +103,13 @@ Annotations: | element is a string (otherwise ValueError). Perform NO semantic validation of path contents (glob, existence, normalization) — only the structural "list of strings" check. Construct a `LintConfig` from the resolved ignore list. - 10. Extract the optional commands mapping (defaults to empty) - 11. Extract the optional tools mapping via the `yaml` practice. When the key + 10. Extract the optional topics block. When the topics key is absent or + YAML-null → topics=None. Present but not a mapping → ValueError. + When a mapping → extract the optional base_ref and publish_commit: + absent/YAML-null/empty/whitespace → None; a non-string value → + ValueError. Construct a `TopicsConfig` from the resolved fields. + 11. Extract the optional commands mapping (defaults to empty) + 12. Extract the optional tools mapping via the `yaml` practice. When the key is absent or YAML-null → set tools to None. When the key is present but the value is not a mapping → raise ValueError. When the value is a mapping → validate structurally that every key is a string and every @@ -109,7 +120,7 @@ Annotations: | non-grammar strings pass through the loader verbatim. The loader is NOT the validation authority for the version grammar — that responsibility belongs to the consumer. - 12. Extract the optional usages block. When the usages key is absent → set + 13. Extract the optional usages block. When the usages key is absent → set usages to None. When present but not a mapping → raise ValueError. When a mapping → for each group (str key → mapping) and each dep (str key → mapping): - require dep.git (non-empty str) — KeyError when missing, ValueError when invalid @@ -124,8 +135,8 @@ Annotations: | .goga/usages/<group>/<dep>/ segments in the downstream usages-sync consumer - construct a `DepConfig`(git, ref, root) per dep, preserving group/dep as dict keys Build usages as dict[str, dict[str, DepConfig]]; empty mapping when present-but-empty - 13. Construct and return a `ProjectConfig` from all assembled parts, including - dockerfile, tools, usages, and lint + 14. Construct and return a `ProjectConfig` from all assembled parts, including + dockerfile, tools, usages, lint, and topics Requirements: - Top-level image is the Docker image (None is valid — consumers raise @@ -172,6 +183,11 @@ Annotations: | empty list; present-but-non-list or a non-string element → ValueError); no semantic validation of ignore contents at the loader level - Values are exposed as-is without default merge — consumers apply their own defaults + - topics is optional (None when absent/YAML-null; present-but-non-mapping + → ValueError); topics.base_ref and topics.publish_commit are optional + (absent/YAML-null/empty/whitespace → None; non-string → ValueError); + stored verbatim — rev resolvability, template grammar, and the + default template belong to the consumer Constraints: - Do not default image — None is a valid value, surface it to the caller @@ -198,12 +214,16 @@ Annotations: | at the loader level — semantics belong to the consumer - Do NOT validate review_executor.env semantics at the loader level — semantics belong to the consumer + - Do NOT validate topics.base_ref rev resolvability or + topics.publish_commit template semantics at the loader level — + structural typing only; the consumer applies the default template + - The final `ProjectConfig` assembly MUST include topics - Do NOT parse the legacy build.review_patience key — the field moved to build.review_executor.patience; a config declaring the old key is silently ignored (accepted breaking change: the field was never released) - The final `ProjectConfig` assembly MUST include lint -"ProjectConfig(lang: str, image: str | None, dockerfile: str | None, build: BuildConfig | None, pipeline: PipelineConfig | None, commands: dict, codemanifest: CodemanifestConfig | None, tools: dict[str, str] | None, usages: dict[str, dict[str, DepConfig]] | None = None, lint: LintConfig | None = None)": +"ProjectConfig(lang: str, image: str | None, dockerfile: str | None, build: BuildConfig | None, pipeline: PipelineConfig | None, commands: dict, codemanifest: CodemanifestConfig | None, tools: dict[str, str] | None, usages: dict[str, dict[str, DepConfig]] | None = None, lint: LintConfig | None = None, topics: TopicsConfig | None = None)": location: config.py annotations: | Root project configuration object. Constructed by load_project_config. @@ -230,6 +250,9 @@ Annotations: | `lint`: optional lint configuration; instance of `LintConfig` or None when the lint section is absent; defaults to None (kw_only); callers may omit lint= + `topics`: optional fast-creation configuration; instance of + `TopicsConfig` or None when the topics section is absent; + defaults to None (kw_only), callers may omit topics= properties: "lang -> str": | Project language. Sourced from the root language directive in .goga/config.yml. @@ -281,6 +304,10 @@ Annotations: | Optional lint configuration from .goga/config.yml. Instance of `LintConfig`, or None when the lint section is absent. Defaults to None (kw_only) so ProjectConfig(...) callers may omit lint=. + "topics -> TopicsConfig | None": | + Fast-creation configuration from .goga/config.yml. Instance of + `TopicsConfig`, or None when the topics section is absent. Defaults to + None (kw_only) so ProjectConfig(...) callers may omit topics=. "BuildConfig(task_executor: TaskExecutorConfig, worktree: bool | None, skip_finalize: bool | None, session_timeout: str | None, idle_timeout: str | None, wait: str | None, max_iterations: int | None, prompts_dir: str | None, agents_dir: str | None, codex_review: bool | None, review_executor: ReviewExecutorConfig | None, proxy: str | None, hosts: dict[str, str])": location: config.py @@ -509,6 +536,40 @@ Annotations: | List of exact relative paths excluded from AST traversal during goga lint. Empty list when lint.ignore is absent/empty. +"TopicsConfig(base_ref: str | None, publish_commit: str | None)": + location: config.py + annotations: | + Fast-creation configuration of the topics section of .goga/config.yml: + the base every published topic branch starts from and the commit + message template of the publication. + + `base_ref`: any revision string the base of a published branch resolves + from — verbatim, None when unset + `publish_commit`: the commit message template of the publication, with + or without the {slug} placeholder — verbatim, None + when unset + + Requirements: + - Immutable frozen dataclass (frozen=True, kw_only=True), per + `convention` + - Fields are stored verbatim — the empty-to-None normalization belongs + to `load_project_config`, the default template and the placeholder + substitution to the consumer + + Constraints: + - Do not resolve the revision string or validate the template grammar + at this level — structural typing belongs to `load_project_config`, + semantics to the consumer + properties: + "base_ref -> str | None": | + The base revision of a published topic branch, verbatim from + .goga/config.yml. None when the field is absent, YAML-null, or + empty/whitespace-only (normalized by `load_project_config`). + "publish_commit -> str | None": | + The commit message template of the publication, verbatim from + .goga/config.yml — the {slug} placeholder is optional. None when the + field is absent, YAML-null, or empty/whitespace-only. + "DepConfig(git: str, ref: str | None, root: str | None = None)": location: config.py annotations: | @@ -552,6 +613,4 @@ CreatedAt: 24/07/26 Description: | Project configuration model + loader for .goga/config.yml. Structural - validation only; semantic validation of version-grammar values, of the - `lint.ignore` paths, and of the `build.review_executor` fields is deferred - to the owning consumer. + validation only; semantic validation is deferred to the owning consumers. diff --git a/goga/topics/.usages/publishing.md b/goga/topics/.usages/publishing.md new file mode 100644 index 00000000..f94e9515 --- /dev/null +++ b/goga/topics/.usages/publishing.md @@ -0,0 +1,56 @@ +# topics — publishing fresh work + +How to create and publish a topic branch without leaving the current branch +with the `goga.topics` facade. For consumers that register new work on the +remote board while the user keeps working: the topics command group, +higher-level orchestration. + +`publish_topic` takes the branch name as entered, a required title, an +explicit base, and a commit message template. The branch keeps the name +verbatim; the topic directory takes the normalized slug of the year — the +two may deliberately differ. + +## Publishing fresh work + +```python +from goga.topics import publish_topic + +result = publish_topic( + "Feature/Foo_Bar", + "Payment retry", + "origin/main", + "goga: create topic {slug}", +) +print(result) # one line: created and published on the remote +``` + +- The caller stays on their branch: the working copy, the index, and HEAD + are untouched — a dirty tree and a detached HEAD do not interfere. +- The branch carries exactly one commit on top of the base: the title file + `title.txt` — the text as entered plus a trailing newline, UTF-8 — in the + topic directory of the year; the topic shows the `new` status. +- The message template replaces {slug} with the topic slug; a template + without the placeholder is used as is. +- A failed publication rolls back fully — the branch is deleted and one + clean error names the reason; a re-run after the cause is resolved + succeeds. +- The base resolves as git resolves it — a local branch is valid; no fetch + happens. + +## Occupancy + +- An occupied name, an empty slug, or a slug already hosted by any branch + of the inventory triggers a re-ask on an interactive terminal, or a clean + error with a hint to the board otherwise. +- `check_slug_occupancy` exposes the branch-tree oracle — the slug + duplicate check across the inventory; the three local oracles stay in + `check_branch_occupancy`. + +## Preconditions + +- The origin remote must be configured — a clean error otherwise, before + any mutation. +- The repository git identity must be set — an unset identity is a clean + git error. +- The current branch must not host the same slug — the fast path is only + for fresh work. diff --git a/goga/topics/CODEMANIFEST b/goga/topics/CODEMANIFEST index 1d5c8631..41ba44a6 100644 --- a/goga/topics/CODEMANIFEST +++ b/goga/topics/CODEMANIFEST @@ -24,8 +24,15 @@ Imports: - create_branch_from_remote_tracking - create_and_switch_branch - is_working_tree_clean + - resolve_ref_commit + - commit_file_on_base + - create_branch_at_commit + - delete_local_branch + - push_branch + - origin_configured Usages: - refs-and-switching + - publishing From: goga/topics/git Usages: @@ -52,18 +59,25 @@ Annotations: | Use the `refs-and-switching` practice for the git patterns of the topics git cell — the branch inventory, ref tree reading, and file reading without checkout. + Use the `publishing` practice for the quarantined commit building, + branch planting, publication, and rollback patterns of the topics git + cell. This cell owns the topics domain — the work-tracker view of the history tree: the cross-branch topic inventory of one year with per-topic statuses and titles, the switch-identifier resolution and switching orchestration, the fresh-work creation procedure with its optional topic - title, and the combined ensure orchestration that switches onto hosted - work and creates it when nothing hosts the identifier. Topic identity, - addressing, and statuses belong to the history facade; git access - belongs to the topics git cell. Git infrastructure failures and the - fatal scale-assembly ImportError surface as click.ClickException. - Mutations are local-only and happen - strictly after every decision is made. Use relative imports. + title, the fast creation-and-publication procedure — a committed branch + off an explicit base without switching, pushed to origin, rolled back + fully on a failed publication — and the combined ensure orchestration + that switches onto hosted work or creates it when nothing hosts the + identifier. Topic identity, addressing, and statuses belong to the + history facade; git access belongs to the topics git cell. Git + infrastructure failures and the fatal scale-assembly ImportError surface + as click.ClickException. Mutations are local-only and happen strictly + after every decision is made — the publication push of the fast + procedure is the single network exception; no fetch ever happens. Use + relative imports. --- @@ -404,6 +418,123 @@ Annotations: | - Do not resolve remote state over the network — the local inventory only +"publish_topic(branch_name: str, title: str, base_ref: str, commit_message: str, year: str | None = None) -> result: str": + location: publishing.py + annotations: | + Create fresh work and publish it — a branch off an explicit base carrying + one commit with the topic title, pushed to origin, while the caller stays + on their branch. + + `branch_name`: the branch name as entered by the user + `title`: the topic title — written to the title file as entered plus a + single trailing newline + `base_ref`: the base revision the branch starts from — any revision + string, resolved as git resolves it + `commit_message`: the commit message template — the {slug} placeholder + is replaced with the topic slug; a template without + the placeholder is used as is + `year`: optional year as four digits; None means the current year + `result`: one line describing the outcome + + Apply the `click` practice for the re-ask prompt and the + non-interactive detection. + Apply the `topic-paths` practice for the slug, current-branch, and + title-file path patterns. + Apply the `refs-and-switching` practice for the occupancy inventory and + tree-reading patterns. + Apply the `publishing` practice for the quarantined commit building, + branch planting, publication, and rollback patterns. + + Algorithm: + 1. Normalize `branch_name` into a slug via `normalize_topic_slug` + 2. Empty slug -> input error: print the reason, prompt for a new name on + an interactive terminal and restart the fast cycle, or fail with the + reason otherwise + 3. The current branch — read via `resolve_current_branch_name` — hosts + the same slug -> clean error without mutations: the fast path is only + for fresh work + 4. Probe the occupancy oracles in order — `check_branch_occupancy` + first, then `check_slug_occupancy`; the first conflict wins -> print + the reason with a hint to the board, prompt for a new name + on an interactive terminal and restart the fast cycle, or fail + otherwise + 5. `origin_configured` reads False -> clean error with the reason + 6. Resolve `base_ref` into its commit via `resolve_ref_commit` — an + unresolvable base is a clean error with the reason, before any + mutation + 7. Build the publication commit via `commit_file_on_base` — the parent + commit, the title file path resolved via `resolve_topic_file` as a + repository-root-relative posix string, the title content, and the + applied `commit_message` + 8. Plant the branch named exactly as entered via + `create_branch_at_commit` + 9. Publish via `push_branch`; a failed publication deletes the branch + via `delete_local_branch` and surfaces one clean error carrying the + reason + 10. Return the single result line + + Requirements: + - The working copy, the index, and HEAD stay untouched — the caller + stays on their branch whatever its state; a dirty tree and a detached + HEAD do not interfere + - Every decision is made before the first mutation; the mutation + sequence is the commit build, the branch plant, and the push + - The push to origin is the only network operation; no fetch ever + happens + - A failed publication rolls back fully — the planted branch is deleted + and nothing else was ever mutated; a re-run after the cause is + resolved succeeds + - The title file carries `title` as entered plus a single trailing + newline, encoded UTF-8 — the sole artifact of the topic directory + - The result is exactly one line + + Constraints: + - Do not validate branch-name characters — git owns name validity + - Do not auto-pick suffixed names on a conflict — the user re-asks or + aborts + - Do not write artifact files other than the topic title file inside + the topic directory + - Do not switch the caller's branch — the caller keeps their working + state + +"check_slug_occupancy(slug: str, year: str | None = None) -> conflict: str | None": + location: creation.py + annotations: | + Decide whether the topic slug of the year is already hosted by any + branch of the inventory — read from the branch trees, without checkout. + + `slug`: the normalized topic slug — checked against the topic directory + of the year across every branch tree + `year`: optional year as four digits; None means the current year + `conflict`: human-readable reason naming the hosting branch, or None + when no branch hosts the slug + + Apply the `topic-paths` practice for the year and tree-root patterns. + Apply the `refs-and-switching` practice for the inventory and + tree-reading patterns. + + Algorithm: + 1. Resolve the year — `year` when given, otherwise the current year via + `current_year` + 2. Compose the topic directory prefix of the slug under the root + resolved via `resolve_history_root` + 3. Probe every ref of `list_branch_refs` via `read_ref_tree_paths` — + the first ref whose tree carries paths under the prefix is the + conflict + 4. No ref hosts the slug -> None + + Requirements: + - The first occupied ref wins and names the conflict; remaining refs + are not probed + - Read-only — no ref or directory is created; no checkout, no worktree + - One git invocation per ref + + Constraints: + - Do not resolve remote state over the network — the local inventory + only + - Do not probe the working copy — a topic living only on disk is the + file oracle's domain + --- Author: Goga @@ -411,5 +542,4 @@ CreatedAt: 29/08/26 Description: | The topics domain — the cross-branch topic inventory with titles, switch resolution and orchestration, fresh-work creation with an optional title, - and the combined ensure orchestration that switches onto hosted work or - creates it. + fast creation with publication, and the combined ensure orchestration. diff --git a/goga/topics/git/.usages/publishing.md b/goga/topics/git/.usages/publishing.md new file mode 100644 index 00000000..b51fcb0e --- /dev/null +++ b/goga/topics/git/.usages/publishing.md @@ -0,0 +1,87 @@ +# topics/git — building and publishing a branch + +How to create a committed branch off a base and publish it to origin with +the `goga.topics.git` facade, without touching the working copy. For +consumers that start work on behalf of the user while the user stays on +their branch: the topics domain, higher-level orchestration. + +The quarantined path never touches the working copy, the repository index, +or HEAD — a dirty tree and a detached HEAD do not interfere. The push to +origin is the only network operation; everything else is local. Every +policy decision — when to roll back, what a conflict means — belongs to +the caller. + +## Resolving a base + +```python +from goga.topics.git import resolve_ref_commit + +commit = resolve_ref_commit("origin/main") # any rev string +``` + +- The revision resolves as git resolves it — a branch, a remote-tracking + ref, a tag, or a hash; a local branch is a valid base. +- An unresolvable revision is a clean error carrying the git reason — + resolve the base before any mutation. +- Read-only, no fetch — a stale remote-tracking base is the caller's + accepted condition. + +## Building a commit without the working copy + +```python +from goga.topics.git import commit_file_on_base + +commit = commit_file_on_base( + base, # from resolve_ref_commit + ".goga/history/2026/feature-foo/title.txt", # repo-root-relative + "Payment retry\n", # content as text + "goga: create topic feature-foo", # final message +) +``` + +- One commit that adds exactly one file on top of the parent — the tree is + built in a temporary index isolated from the repository; nothing + persists after the call and no temporary directories appear outside + .git. +- The message is final — placeholders belong to the caller. +- The commit carries the repository git identity; an unset identity is a + clean git error. +- The commit exists only as a hash — no branch points to it until the + caller plants one. + +## Planting and publishing a branch + +```python +from goga.topics.git import ( + create_branch_at_commit, + origin_configured, + push_branch, +) + +if not origin_configured(): + ... # clean error: origin is not configured — before any mutation + +create_branch_at_commit("Feature/Foo_Bar", commit) # name verbatim, no switch +push_branch("Feature/Foo_Bar") # push -u origin +``` + +- `create_branch_at_commit` takes the branch name exactly as entered and + leaves the working copy on its current branch. +- `push_branch` publishes exactly the named branch and binds its upstream + — later push and pull need no arguments; the local branch stays in the + repository. +- Probe `origin_configured` before creating anything — the probe is + read-only and never raises. + +## Rolling back + +```python +from goga.topics.git import delete_local_branch + +delete_local_branch("Feature/Foo_Bar") # full rollback of a failed publication +``` + +- `delete_local_branch` removes the local branch ref; the working copy, the + index, and HEAD stay untouched — nothing else was ever mutated. +- The cell never decides to roll back — a git failure of the push + propagates with its message; the caller owns the failure policy. diff --git a/goga/topics/git/CODEMANIFEST b/goga/topics/git/CODEMANIFEST index 424a38b6..2b8f9a96 100644 --- a/goga/topics/git/CODEMANIFEST +++ b/goga/topics/git/CODEMANIFEST @@ -4,12 +4,17 @@ Usages: External git binary invoked via subprocess.run (check=True, capture_output=True). Set GIT_TERMINAL_PROMPT=0 in the env to suppress interactive prompts. Read-only inspection (branch refs, ref tree paths and file contents, - working tree state) plus host-side mutations (checkout of a local - branch, creating a local branch from a remote-tracking ref, - create-and-switch to a new branch). A single-file content read decodes - UTF-8 explicitly and maps every git failure to None — the content is - display data; every other invocation propagates its git error. Mock - the subprocess call in tests per `convention`. + revision resolution, working tree state, the origin remote probe), + host-side mutations (checkout of a local branch, creating a local branch + from a remote-tracking ref, create-and-switch to a new branch), and + quarantined publication (building a commit in a temporary index held by + the GIT_INDEX_FILE env var of a single invocation — read-tree of the + base, hash-object -w of the blob, update-index --add, write-tree, + commit-tree -p; creating a branch at a commit and deleting a branch via + update-ref; pushing a branch to origin with -u). A single-file content + read decodes UTF-8 explicitly and maps every git failure to None — the + content is display data; every other invocation propagates its git + error. Mock the subprocess call in tests per `convention`. Annotations: | The `convention` practice is used for: @@ -19,14 +24,19 @@ Annotations: | - Organizing the test infrastructure - Understanding the general principles and rules of development and testing in the project - This cell owns git access for the topics domain: enumerating branch refs, - reading the file paths and the file contents of a ref tree, and the - bounded set of host-side branch mutations — checking out a local branch, - creating a local branch from a remote-tracking ref, creating and - switching to a new branch, and the working-tree cleanliness probe. It is - environment access, not topic logic — every decision belongs to the - caller. All git access flows through the `git` practice; mock the - subprocess call in tests per `convention`. Use relative imports. + This cell owns git access for the topics domain: enumerating branch + refs, reading the file paths and the file contents of a ref tree, + resolving a revision into its commit, and the bounded set of host-side + branch mutations — checking out a local branch, creating a local branch + from a remote-tracking ref, creating and switching to a new branch, + creating a branch at a commit without switching, deleting a local branch, + and the working-tree cleanliness probe. The quarantined creation path + builds its commits in a temporary index isolated from the working copy — + the working tree, the index, and HEAD stay untouched; pushing a branch to + origin is the single network operation of the domain. It is environment + access, not topic logic — every decision belongs to the caller. All git + access flows through the `git` practice; mock the subprocess call in + tests per `convention`. Use relative imports. --- @@ -235,10 +245,172 @@ Annotations: | Constraints: - Do not act on a dirty tree — the caller owns the policy +"resolve_ref_commit(ref: str) -> commit: str": + location: publish.py + annotations: | + Resolve a revision string into the commit it names. + + `ref`: any revision string — a branch name, a remote-tracking ref, a + tag, or a commit hash, resolved as git resolves it + `commit`: the commit hash the revision names + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git to resolve `ref` into its commit + 2. An unresolvable revision surfaces as a clean error carrying the git + reason + + Requirements: + - Read-only — no ref is created, moved, or deleted + - No network — the revision resolves against the local repository state + + Constraints: + - Do not fetch — a stale remote-tracking base is the caller's accepted + condition + - Do not decide what a usable base is — the caller owns the policy + +"commit_file_on_base(base: str, path: str, content: str, message: str) -> commit: str": + location: publish.py + annotations: | + Build one commit that adds a single file on top of a parent commit — + without touching the working copy. + + `base`: the parent commit hash + `path`: the file path to add, relative to the repository root + `content`: the file content as text + `message`: the final commit message + `commit`: the hash of the built commit + + Apply the `git` practice for the quarantined index pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Build the tree of the commit in a temporary index quarantined from + the repository — the parent tree, the new blob of `path` staged over + it, and the resulting tree written out + 2. Ask git to create the commit with `message` on the parent `base`, + authored by the repository git identity + 3. Return the new commit hash; every git failure — an unreadable + parent, an unset identity — surfaces as a clean error + + Requirements: + - The working copy, the repository index, and HEAD stay untouched + - The temporary index lives only inside the environment of a single git + invocation — nothing persists after the build + - No temporary directories or files are created outside .git + + Constraints: + - Do not create, move, or delete branches — the commit exists only as a + hash until the caller plants it + - Do not write the file to the working copy + +"create_branch_at_commit(branch_name: str, commit: str)": + location: publish.py + annotations: | + Create a branch at a commit without switching to it. + + `branch_name`: the branch name as entered by the user + `commit`: the commit the branch points to + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git to create the branch `branch_name` pointing at `commit`, + leaving the working copy on its current branch + 2. A git failure surfaces as a clean error + + Requirements: + - The name is taken verbatim — no normalization, no suffixing + - The mutation is local — no network + - The working copy, the index, and HEAD stay untouched — no switch + happens + + Constraints: + - Do not validate the name characters — git owns name validity + +"delete_local_branch(branch_name: str)": + location: publish.py + annotations: | + Delete a local branch. + + `branch_name`: the short name of the local branch + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git to delete the local branch ref + 2. A git failure surfaces as a clean error + + Requirements: + - The deletion is local — no network + - The working copy, the index, and HEAD stay untouched — a branch not + checked out is deletable without a switch + + Constraints: + - Do not decide whether deletion is safe — the caller owns the rollback + policy + +"push_branch(branch_name: str)": + location: publish.py + annotations: | + Publish a branch to the origin remote with upstream binding. + + `branch_name`: the short name of the local branch + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git to push the branch to origin and bind its upstream + 2. A git failure propagates with its message — the caller owns the + rollback + + Requirements: + - The push is the only network operation of the topics domain + - The local branch stays in the repository after the push + + Constraints: + - Do not push other branches or tags — exactly the named branch + - Do not retry or roll back — the caller owns the failure policy + +"origin_configured() -> configured: bool": + location: publish.py + annotations: | + Probe whether the origin remote is configured. + + `configured`: True when the repository has an origin remote + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git for the origin remote URL + 2. Report the answer as a plain boolean + + Requirements: + - Read-only — no remote state is contacted, no network + - Strict as a probe result: an absent or unreadable origin reads False — + the probe never raises + + Constraints: + - Do not tolerate the absence into a success — the caller turns False + into its own clean error + --- Author: Goga CreatedAt: 29/08/26 Description: | - Git access for the topics domain — branch refs, ref tree paths and file - contents, and the bounded host-side branch mutations. + Git access for the topics domain — branch refs, ref tree reading, + revision resolution, host-side branch mutations, and quarantined branch + construction with publication. From 327bad1766290a2d33c45878031da735b10de2c6 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 17:04:06 +0000 Subject: [PATCH 118/229] feat: add TopicsConfig, loader step 10, and both config facades --- goga/config/__init__.py | 2 + goga/config/project/__init__.py | 2 + goga/config/project/config.py | 35 +++++ goga/config/project/loader.py | 67 ++++++++ tests/config/test_config.py | 5 +- tests/config/test_loader.py | 173 ++++++++++++++++++++- tests/config/test_project_cell_contract.py | 71 +++++++++ 7 files changed, 351 insertions(+), 4 deletions(-) diff --git a/goga/config/__init__.py b/goga/config/__init__.py index 0b95e388..977a3291 100644 --- a/goga/config/__init__.py +++ b/goga/config/__init__.py @@ -10,6 +10,7 @@ ProjectConfig, ReviewExecutorConfig, TaskExecutorConfig, + TopicsConfig, ) from .project.loader import load_project_config @@ -24,6 +25,7 @@ "ProjectConfig", "ReviewExecutorConfig", "TaskExecutorConfig", + "TopicsConfig", "load_home_config", "load_project_config", "resolve_project_name", diff --git a/goga/config/project/__init__.py b/goga/config/project/__init__.py index f0d2a3ed..29c57088 100644 --- a/goga/config/project/__init__.py +++ b/goga/config/project/__init__.py @@ -5,6 +5,7 @@ ProjectConfig, ReviewExecutorConfig, TaskExecutorConfig, + TopicsConfig, ) from .loader import load_project_config @@ -15,5 +16,6 @@ "ProjectConfig", "ReviewExecutorConfig", "TaskExecutorConfig", + "TopicsConfig", "load_project_config", ] diff --git a/goga/config/project/config.py b/goga/config/project/config.py index 068928f5..9906d416 100644 --- a/goga/config/project/config.py +++ b/goga/config/project/config.py @@ -112,6 +112,40 @@ class LintConfig: ignore: list[str] +@dataclass(kw_only=True, frozen=True) +class TopicsConfig: + """Fast-creation configuration of the topics section of `.goga/config.yml`. + + Immutable verbatim value-object. Fields are stored exactly as parsed: no + revision resolution, no template grammar checks, and no empty-to-None + normalization (that rule belongs to the loader, which always passes both + fields). Both fields may be `None` — a present-but-empty section means + "everything unset" (explicit absence). + + `base_ref`: any revision string the base of a published branch resolves + from — verbatim, None when unset + `publish_commit`: the commit message template of the publication, with + or without the {slug} placeholder — verbatim, None when + unset + + Args: + base_ref: The base revision of a published topic branch, verbatim + from `.goga/config.yml`; None when absent/YAML-null/empty. + publish_commit: The commit message template of the publication, + verbatim from `.goga/config.yml`; None when absent/YAML-null/empty. + + Returns: + A frozen value-object; construction performs no validation. + + Raises: + Nothing — structural typing is enforced by `load_project_config`, + and semantics belong to the consumer. + """ + + base_ref: str | None + publish_commit: str | None + + @dataclass(kw_only=True, frozen=True) class ProjectConfig: """Root project configuration loaded from .goga/config.yml.""" @@ -126,3 +160,4 @@ class ProjectConfig: tools: dict[str, str] | None = None usages: dict[str, dict[str, DepConfig]] | None = None lint: LintConfig | None = None + topics: TopicsConfig | None = None diff --git a/goga/config/project/loader.py b/goga/config/project/loader.py index 59a8c3f3..a2b21a08 100644 --- a/goga/config/project/loader.py +++ b/goga/config/project/loader.py @@ -11,6 +11,7 @@ ProjectConfig, ReviewExecutorConfig, TaskExecutorConfig, + TopicsConfig, ) @@ -184,6 +185,70 @@ def _parse_lint(data: dict) -> LintConfig | None: return LintConfig(ignore=ignore_list) +def _parse_topics_field(value, key: str) -> str | None: + """Parse a single string field of the optional ``topics`` section. + + Mirrors the ``_parse_optional_agent`` / ``_parse_review_scoped_fields`` + normalization: an unset field (absent or YAML-null) resolves to ``None``, + an empty or whitespace-only string strips to ``None``, and a present + non-string value is a structural type error. A non-empty string is stored + verbatim — no revision resolution, no template grammar checks. + + Args: + value: The raw field value from the ``topics`` mapping (a ``str``, or + None when absent). + key: The dotted field name for error messages (e.g. + ``"topics.base_ref"``). + + Returns: + The stripped field value, or ``None`` when unset/empty. + + Raises: + ValueError: When ``value`` is present but not a string. + """ + if value is None: + return None + if not isinstance(value, str): + raise ValueError(f"{key} must be a string in .goga/config.yml") + return value.strip() or None + + +def _parse_topics(data: dict) -> TopicsConfig | None: + """Parse the optional ``topics`` section into a ``TopicsConfig`` value-object. + + Structural-only parse mirroring the style of ``_parse_lint``: the section + is optional — absent or YAML-null resolves to ``None``, while a + present-but-empty mapping yields a ``TopicsConfig`` with both fields + ``None`` (a present section means "the section exists", not "unset"). + Unknown keys inside the mapping are ignored (the cell-wide stance — same + as ``lint``, ``codemanifest``, ``review_executor``). Rev resolvability, + template grammar, and the default template belong to the consuming + command, never to this loader. + + Args: + data: The already-parsed ``.goga/config.yml`` document. + + Returns: + A ``TopicsConfig`` storing both fields verbatim, or ``None`` when the + ``topics`` section is absent or YAML-null. + + Raises: + ValueError: When ``topics`` is present but not a mapping, or when + ``topics.base_ref``/``topics.publish_commit`` is present but not + a string. + """ + raw = data.get("topics") + if raw is None: + return None + if not isinstance(raw, dict): + raise ValueError("'topics' must be a mapping in .goga/config.yml") + + base_ref = _parse_topics_field(raw.get("base_ref"), "topics.base_ref") + publish_commit = _parse_topics_field(raw.get("publish_commit"), "topics.publish_commit") + + return TopicsConfig(base_ref=base_ref, publish_commit=publish_commit) + + def _parse_tools(data: dict) -> dict[str, str] | None: """Extract the optional top-level tools mapping. @@ -604,6 +669,7 @@ def load_project_config() -> ProjectConfig: tools = _parse_tools(data) usages = _parse_usages(data.get("usages")) lint = _parse_lint(data) + topics = _parse_topics(data) return ProjectConfig( lang=lang, @@ -616,4 +682,5 @@ def load_project_config() -> ProjectConfig: tools=tools, usages=usages, lint=lint, + topics=topics, ) diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 8a874926..c551a102 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -660,13 +660,14 @@ def test_lintconfig_kw_only_enforced(self): assert all(f.kw_only for f in dataclasses.fields(LintConfig)) def test_projectconfig_has_lint_field_default_none(self): - """ProjectConfig.lint defaults to None and is the last kw_only field.""" + """ProjectConfig.lint defaults to None and keeps its trailing append (topics follows it).""" cfg = ProjectConfig(lang="python", image=None, dockerfile=None, build=None, pipeline=None) assert cfg.lint is None assert "lint" in ProjectConfig.__dataclass_fields__ field_names = list(ProjectConfig.__dataclass_fields__.keys()) - assert field_names[-1] == "lint" + assert field_names[-2] == "lint" + assert field_names[-1] == "topics" def test_projectconfig_lint_accepts_lintconfig(self): te = TaskExecutorConfig(agent="claude") diff --git a/tests/config/test_loader.py b/tests/config/test_loader.py index b426d29a..7fc72cc7 100644 --- a/tests/config/test_loader.py +++ b/tests/config/test_loader.py @@ -2,6 +2,7 @@ import dataclasses import inspect +import re import goga.config as goga_config_mod import pytest @@ -12,6 +13,7 @@ PipelineConfig, ProjectConfig, TaskExecutorConfig, + TopicsConfig, load_project_config, ) from goga.config.project.config import DepConfig @@ -21,6 +23,8 @@ _parse_dockerfile, _parse_lint, _parse_tools, + _parse_topics, + _parse_topics_field, _parse_usages, _validate_usages_root, ) @@ -2860,9 +2864,10 @@ def test_projectconfig_has_lint_field(self): assert "lint" in field_names def test_projectconfig_lint_is_last_field(self): - """lint is the last declared field of ProjectConfig (backward-compatible append).""" + """lint is the last field before the trailing topics (backward-compatible append).""" field_names = [f.name for f in dataclasses.fields(ProjectConfig)] - assert field_names[-1] == "lint" + assert field_names[-2] == "lint" + assert field_names[-1] == "topics" def test_projectconfig_lint_annotation_optional_lintconfig(self): """lint field type is LintConfig | None.""" @@ -3568,3 +3573,167 @@ def test_review_executor_patience_zero_and_negative_verbatim(self, goga_project, ) config = load_project_config() assert config.build.review_executor.patience == int(patience_literal), patience_id + + +# --- Contract + logic tests for TopicsConfig + the topics section (loader step 10) --- + + +class TestParseTopicsContract: + def test_parse_topics_exists(self): + """_parse_topics is importable from goga.config.project.loader.""" + assert callable(_parse_topics) + + def test_parse_topics_signature(self): + """_parse_topics accepts a single dict parameter (parity with _parse_lint).""" + sig = inspect.signature(_parse_topics) + assert list(sig.parameters.keys()) == ["data"] + + def test_parse_topics_return_annotation(self): + """_parse_topics returns TopicsConfig | None.""" + ret = inspect.signature(_parse_topics).return_annotation + assert ret == TopicsConfig | None + + def test_parse_topics_field_signature(self): + """_parse_topics_field takes (value, key) positionally.""" + sig = inspect.signature(_parse_topics_field) + assert list(sig.parameters.keys()) == ["value", "key"] + + def test_projectconfig_topics_is_last_field(self): + """topics is the last declared field of ProjectConfig (backward-compatible append).""" + field_names = [f.name for f in dataclasses.fields(ProjectConfig)] + assert field_names[-1] == "topics" + + def test_projectconfig_topics_defaults_none(self): + """topics defaults to None (backward compatible — section absent).""" + assert {f.name: f for f in dataclasses.fields(ProjectConfig)}["topics"].default is None + + +class TestParseTopicsLogic: + def test_parse_topics_without_section_returns_none(self): + """No topics section → returns None.""" + assert _parse_topics({"language": "python"}) is None + + def test_parse_topics_null_section_returns_none(self): + """topics: null → returns None.""" + assert _parse_topics({"topics": None}) is None + + def test_parse_topics_empty_mapping_yields_instance(self): + """topics: {} → TopicsConfig(base_ref=None, publish_commit=None) — an instance, not None.""" + result = _parse_topics({"topics": {}}) + assert isinstance(result, TopicsConfig) + assert result == TopicsConfig(base_ref=None, publish_commit=None) + + def test_parse_topics_unknown_keys_are_ignored(self): + """Unknown keys inside the mapping are ignored (cell-wide stance).""" + result = _parse_topics({"topics": {"base_ref": "origin/main", "future_key": 5}}) + assert result == TopicsConfig(base_ref="origin/main", publish_commit=None) + + @pytest.mark.parametrize("bad_section", ["not-a-mapping", 5, ["a", "b"]]) + def test_parse_topics_rejects_non_mapping_section(self, bad_section): + """topics section that is not a mapping (str/int/list) → ValueError.""" + with pytest.raises(ValueError, match=r"'topics' must be a mapping"): + _parse_topics({"topics": bad_section}) + + def test_parse_topics_field_null_returns_none(self): + """A YAML-null field value → None.""" + assert _parse_topics_field(None, "topics.base_ref") is None + + def test_parse_topics_field_strips_to_none(self): + """An empty or whitespace-only string → None (the loader's empty-to-None rule).""" + assert _parse_topics_field("", "topics.base_ref") is None + assert _parse_topics_field(" ", "topics.publish_commit") is None + + def test_parse_topics_field_strips_surrounding_whitespace(self): + """Surrounding whitespace is stripped; the remainder is stored verbatim.""" + assert _parse_topics_field(" origin/main ", "topics.base_ref") == "origin/main" + + def test_parse_topics_field_rejects_non_string(self): + """A present non-string field is a structural type error.""" + with pytest.raises(ValueError, match=r"topics\.base_ref must be a string"): + _parse_topics_field(5, "topics.base_ref") + + +class TestLoadConfigTopics: + def test_topics_section_absent_yields_none(self, goga_project): + """Config with only language → cfg.topics is None, lang parsed, build None.""" + _write_goga_yml(goga_project, "language: python\n") + config = load_project_config() + assert config.topics is None + assert config.lang == "python" + assert config.build is None + + def test_topics_section_null_yields_none(self, goga_project): + """topics: null → cfg.topics is None.""" + _write_goga_yml(goga_project, "language: python\ntopics: null\n") + config = load_project_config() + assert config.topics is None + + def test_topics_section_parsed_verbatim(self, goga_project): + """Both fields stored verbatim — {slug} braces survive, no grammar check.""" + _write_goga_yml( + goga_project, + "language: python\ntopics:\n base_ref: origin/release-1.3\n" + ' publish_commit: "chore: {slug}"\n', + ) + config = load_project_config() + assert config.topics == TopicsConfig(base_ref="origin/release-1.3", publish_commit="chore: {slug}") + + def test_topics_section_not_mapping_raises_value_error(self, goga_project): + """topics: 5 → ValueError with the exact message (not AttributeError).""" + _write_goga_yml(goga_project, "language: python\ntopics: 5\n") + with pytest.raises(ValueError, match=r"^'topics' must be a mapping in \.goga/config\.yml$"): + load_project_config() + + @pytest.mark.parametrize( + ("bad_yaml", "message"), + [ + ("topics:\n base_ref: 5\n", "topics.base_ref must be a string in .goga/config.yml"), + ("topics:\n publish_commit:\n - 1\n", "topics.publish_commit must be a string in .goga/config.yml"), + ], + ) + def test_topics_field_not_string_raises_value_error(self, goga_project, bad_yaml, message): + """A non-string topics field is a structural type error with the dotted key.""" + _write_goga_yml(goga_project, f"language: python\n{bad_yaml}") + with pytest.raises(ValueError, match=f"^{re.escape(message)}$"): + load_project_config() + + @pytest.mark.parametrize( + ("base_ref_yaml", "field_id"), + [ + ("base_ref: null", "yaml-null"), + ('base_ref: " "', "whitespace"), + ('base_ref: ""', "empty"), + ], + ) + def test_topics_base_ref_unset_forms_normalize_to_none(self, goga_project, base_ref_yaml, field_id): + """base_ref absent/YAML-null/empty/whitespace → None; publish_commit stays verbatim.""" + _write_goga_yml( + goga_project, + f"language: python\ntopics:\n {base_ref_yaml}\n publish_commit: \"chore: {{slug}}\"\n", + ) + config = load_project_config() + assert config.topics is not None + assert config.topics.base_ref is None, field_id + assert config.topics.publish_commit == "chore: {slug}" + + def test_topics_section_empty_mapping_yields_topics_config(self, goga_project): + """topics: {} → an instance with both fields None — "explicit absence" semantics.""" + _write_goga_yml(goga_project, "language: python\ntopics: {}\n") + config = load_project_config() + assert config.topics is not None + assert isinstance(config.topics, TopicsConfig) + assert config.topics == TopicsConfig(base_ref=None, publish_commit=None) + + def test_topics_section_alongside_other_sections(self, goga_project): + """topics coexists with the full schema; sibling sections stay intact.""" + _write_goga_yml( + goga_project, + "language: python\nimage: qarium/foo:1.0\npipeline:\n agent: claude\n" + "build:\n task_executor:\n agent: claude\nlint:\n ignore:\n - .venv/\n" + "topics:\n base_ref: origin/main\n", + ) + config = load_project_config() + assert config.topics == TopicsConfig(base_ref="origin/main", publish_commit=None) + assert config.lint is not None + assert config.lint.ignore == [".venv/"] + assert config.pipeline.agent == "claude" diff --git a/tests/config/test_project_cell_contract.py b/tests/config/test_project_cell_contract.py index 16e81c73..4715b64c 100644 --- a/tests/config/test_project_cell_contract.py +++ b/tests/config/test_project_cell_contract.py @@ -1,7 +1,9 @@ # tests/config/test_project_cell_contract.py — contract + logic tests for the relocated/renamed project cell +import dataclasses import inspect +import goga.config as goga_config_mod import goga.config.project as project_mod import pytest from goga.config.project import ( @@ -9,6 +11,7 @@ CodemanifestConfig, PipelineConfig, ProjectConfig, + TopicsConfig, load_project_config, ) @@ -78,6 +81,74 @@ def test_load_project_config_returns_project_config_instance(self, goga_project) assert type(result) is project_mod.ProjectConfig +class TestTopicsConfigContract: + def test_topics_config_on_both_facades(self): + """TopicsConfig is importable from goga.config.project AND goga.config.""" + from goga.config import TopicsConfig + + assert project_mod.TopicsConfig is TopicsConfig + assert goga_config_mod.TopicsConfig is TopicsConfig + assert "TopicsConfig" in project_mod.__all__ + assert "TopicsConfig" in goga_config_mod.__all__ + + def test_topics_config_is_frozen_kw_only_dataclass(self): + """TopicsConfig is an immutable kw_only dataclass per `convention`.""" + params = TopicsConfig.__dataclass_params__ + assert params.frozen is True + assert params.kw_only is True + + def test_topics_config_declares_exactly_the_two_fields(self): + """The declared field set is exactly {base_ref, publish_commit}.""" + assert {f.name for f in dataclasses.fields(TopicsConfig)} == {"base_ref", "publish_commit"} + + def test_topics_config_fields_are_kw_only_without_defaults(self): + """Both fields are keyword-only and carry no defaults — the loader always passes both.""" + for field in dataclasses.fields(TopicsConfig): + assert field.kw_only is True + assert field.default is dataclasses.MISSING + assert field.default_factory is dataclasses.MISSING + + def test_topics_config_optional_union_annotations(self): + """Both fields are typed str | None ("explicit absence" semantics).""" + fields = {f.name: f for f in dataclasses.fields(TopicsConfig)} + assert fields["base_ref"].type == str | None + assert fields["publish_commit"].type == str | None + + def test_topics_config_stores_fields_verbatim(self): + """Pure construction stores both values verbatim — no normalization here.""" + config = TopicsConfig(base_ref="origin/release-1.3", publish_commit="chore: {slug}") + assert config.base_ref == "origin/release-1.3" + assert config.publish_commit == "chore: {slug}" + + def test_project_config_gains_trailing_topics_field(self): + """ProjectConfig declares `topics` as its LAST field, defaulting to None.""" + fields = dataclasses.fields(ProjectConfig) + assert fields[-1].name == "topics" + assert fields[-1].default is None + + def test_project_config_topics_annotation_optional(self): + """The topics field type is TopicsConfig | None.""" + topics_field = {f.name: f for f in dataclasses.fields(ProjectConfig)}["topics"] + assert topics_field.type == TopicsConfig | None + + def test_load_project_config_signature_unchanged(self): + """load_project_config still accepts no arguments.""" + assert list(inspect.signature(load_project_config).parameters.keys()) == [] + + def test_project_config_existing_callers_stay_valid(self): + """ProjectConfig(...) omitting topics=/usages=/lint= stays constructible; topics is None.""" + config = ProjectConfig( + lang="python", + image=None, + dockerfile=None, + build=None, + pipeline=None, + commands={}, + ) + assert config.topics is None + assert config.lang == "python" + + # --- Logic tests (relocated loader exercised end-to-end) --- From 2ff663cadfa7496661d007758f4a1bdb1b5cfc22 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 17:12:10 +0000 Subject: [PATCH 119/229] feat: add the six publication git routines of goga/topics/git (publish.py) --- goga/topics/git/__init__.py | 24 ++- goga/topics/git/publish.py | 261 +++++++++++++++++++++++++++++++ tests/topics/git/test_publish.py | 253 ++++++++++++++++++++++++++++++ tests/topics/git/test_trees.py | 4 +- 4 files changed, 537 insertions(+), 5 deletions(-) create mode 100644 goga/topics/git/publish.py create mode 100644 tests/topics/git/test_publish.py diff --git a/goga/topics/git/__init__.py b/goga/topics/git/__init__.py index b5b15436..de72181c 100644 --- a/goga/topics/git/__init__.py +++ b/goga/topics/git/__init__.py @@ -1,13 +1,25 @@ """Git-access cell for the topics domain. The branch-ref inventory, the file-path reading of a ref tree, the file -contents of a ref tree, and the bounded set of host-side branch mutations +contents of a ref tree, the bounded set of host-side branch mutations — checking out a local branch, creating a local branch from a remote-tracking ref, create-and-switch to a new branch, and the -working-tree cleanliness probe. It is environment access, not topic -logic — every decision belongs to the caller. +working-tree cleanliness probe — and the quarantined publication: +resolving a revision into its commit, building one commit over a base +through a temporary index without touching the working copy, planting and +deleting a branch without switching, pushing a branch to origin with +upstream binding, and the origin probe. It is environment access, not +topic logic — every decision belongs to the caller. """ +from .publish import ( + commit_file_on_base, + create_branch_at_commit, + delete_local_branch, + origin_configured, + push_branch, + resolve_ref_commit, +) from .refs import BranchRef, list_branch_refs from .switch import ( checkout_local_branch, @@ -20,10 +32,16 @@ __all__: list[str] = [ "BranchRef", "checkout_local_branch", + "commit_file_on_base", "create_and_switch_branch", + "create_branch_at_commit", "create_branch_from_remote_tracking", + "delete_local_branch", "is_working_tree_clean", "list_branch_refs", + "origin_configured", + "push_branch", "read_ref_file", "read_ref_tree_paths", + "resolve_ref_commit", ] diff --git a/goga/topics/git/publish.py b/goga/topics/git/publish.py new file mode 100644 index 00000000..53ca1eca --- /dev/null +++ b/goga/topics/git/publish.py @@ -0,0 +1,261 @@ +"""The quarantined publication of the topics-domain git cell. + +The entities declared in the cell CODEMANIFEST with +``location: publish.py``: revision resolution, the quarantined building of +one commit that adds a single file on top of a parent commit, planting a +branch at a commit without switching, deleting a local branch, pushing a +branch to origin with upstream binding, and the strict origin probe. The +quarantined path never touches the working copy, the repository index, or +HEAD — a dirty tree and a detached HEAD do not interfere. Every git +invocation follows the ``git`` practice. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + + +def resolve_ref_commit(ref: str) -> str: + """Resolve a revision string into the commit it names. + + Args: + ref: Any revision string — a branch name, a remote-tracking ref, a + tag, or a commit hash, resolved as git resolves it. + + Returns: + The commit hash the revision names — annotated tags peeled to + their commit, so the hash is usable as ``commit-tree -p`` parent. + + Algorithm: + 1. Ask git to resolve ``ref`` into its commit + 2. An unresolvable revision surfaces as a clean error carrying the + git reason + + Requirements: + Read-only — no ref is created, moved, or deleted. + + No network — the revision resolves against the local repository + state. + + Constraints: + Do not fetch — a stale remote-tracking base is the caller's + accepted condition. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + resolution itself (propagated raw — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + result = _run_git(["git", "rev-parse", "--verify", f"{ref}^{{commit}}"]) + return result.stdout.strip() + + +def commit_file_on_base(base: str, path: str, content: str, message: str) -> str: + """Build one commit that adds a single file on top of a parent commit — + without touching the working copy. + + Args: + base: The parent commit hash. + path: The file path to add, relative to the repository root. + content: The file content as text. + message: The final commit message. + + Returns: + The hash of the built commit. + + Algorithm: + 1. Build the tree of the commit in a temporary index quarantined + from the repository — the parent tree, the new blob of ``path`` + staged over it, and the resulting tree written out + 2. Ask git to create the commit with ``message`` on the parent + ``base``, authored by the repository git identity + 3. Return the new commit hash; every git failure — an unreadable + parent, an unset identity — surfaces as a clean error + + Requirements: + The working copy, the repository index, and HEAD stay untouched. + + The temporary index lives only inside the environment of a single + git invocation — nothing persists after the build. + + No temporary directories or files are created outside ``.git``. + + Constraints: + Do not create, move, or delete branches — the commit exists only + as a hash until the caller plants it. + + Do not write the file to the working copy. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + chain itself (propagated raw — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + git_dir = _run_git(["git", "rev-parse", "--git-dir"]).stdout.strip() + fd, name = tempfile.mkstemp(dir=git_dir, prefix="goga-publish-index-") + os.close(fd) + index = Path(name) + try: + _run_git(["git", "read-tree", base], index=index) + blob = _run_git(["git", "hash-object", "-w", "--stdin"], input=content).stdout.strip() + _run_git(["git", "update-index", "--add", "--cacheinfo", f"100644,{blob},{path}"], index=index) + tree = _run_git(["git", "write-tree"], index=index).stdout.strip() + return _run_git(["git", "commit-tree", tree, "-p", base, "-m", message]).stdout.strip() + finally: + index.unlink(missing_ok=True) + + +def create_branch_at_commit(branch_name: str, commit: str) -> None: + """Create a branch at a commit without switching to it. + + Args: + branch_name: The branch name as entered by the user. + commit: The commit the branch points to. + + Algorithm: + 1. Ask git to create the branch ``branch_name`` pointing at + ``commit``, leaving the working copy on its current branch + 2. A git failure surfaces as a clean error + + Requirements: + The name is taken verbatim — no normalization, no suffixing. + + The mutation is local — no network. + + The working copy, the index, and HEAD stay untouched — no switch + happens. + + Constraints: + Do not validate the name characters — git owns name validity. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + branch creation itself (propagated raw — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + _run_git(["git", "update-ref", f"refs/heads/{branch_name}", commit]) + + +def delete_local_branch(branch_name: str) -> None: + """Delete a local branch. + + Args: + branch_name: The short name of the local branch. + + Algorithm: + 1. Ask git to delete the local branch ref + 2. A git failure surfaces as a clean error + + Requirements: + The deletion is local — no network. + + The working copy, the index, and HEAD stay untouched — a branch + not checked out is deletable without a switch. + + Constraints: + Do not decide whether deletion is safe — the caller owns the + rollback policy. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + deletion itself (propagated raw — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + _run_git(["git", "update-ref", "-d", f"refs/heads/{branch_name}"]) + + +def push_branch(branch_name: str) -> None: + """Publish a branch to the origin remote with upstream binding. + + Args: + branch_name: The short name of the local branch. + + Algorithm: + 1. Ask git to push the branch to origin and bind its upstream + 2. A git failure propagates with its message — the caller owns the + rollback + + Requirements: + The push is the only network operation of the topics domain. + + The local branch stays in the repository after the push. + + Constraints: + Do not push other branches or tags — exactly the named branch. + + Do not retry or roll back — the caller owns the failure policy. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + push itself (propagated raw — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + _run_git(["git", "push", "-u", "origin", branch_name]) + + +def origin_configured() -> bool: + """Probe whether the origin remote is configured. + + Returns: + True when the repository has an origin remote. + + Algorithm: + 1. Ask git for the origin remote URL + 2. Report the answer as a plain boolean + + Requirements: + Read-only — no remote state is contacted, no network. + + Strict as a probe result: an absent or unreadable origin reads + False — the probe never raises. + + Constraints: + Do not tolerate the absence into a success — the caller turns + False into its own clean error. + """ + try: + _run_git(["git", "remote", "get-url", "origin"]) + except (subprocess.CalledProcessError, FileNotFoundError): + return False + return True + + +def _run_git( + command: list[str], + *, + input: str | None = None, + index: Path | None = None, +) -> subprocess.CompletedProcess[str]: + """Run one git invocation following the ``git`` practice. + + Args: + command: The argv of the invocation, starting with ``git``. + input: The text to feed the invocation on stdin, if any. + index: The path of a quarantined index — exported to the single + invocation through the ``GIT_INDEX_FILE`` env var, never + written to the repository index. + + Returns: + The completed invocation with captured text output. + """ + return subprocess.run( + command, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + input=input, + env={ + **os.environ, + "GIT_TERMINAL_PROMPT": "0", + **({"GIT_INDEX_FILE": str(index)} if index is not None else {}), + }, + ) diff --git a/tests/topics/git/test_publish.py b/tests/topics/git/test_publish.py new file mode 100644 index 00000000..0fe83e5a --- /dev/null +++ b/tests/topics/git/test_publish.py @@ -0,0 +1,253 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/git/CODEMANIFEST`` with ``location: publish.py``: + +- ``resolve_ref_commit(ref)`` — resolve a revision string into the commit + it names +- ``commit_file_on_base(base, path, content, message)`` — build one commit + that adds a single file on top of a parent commit, without touching the + working copy +- ``create_branch_at_commit(branch_name, commit)`` — create a branch at a + commit without switching to it +- ``delete_local_branch(branch_name)`` — delete a local branch +- ``push_branch(branch_name)`` — publish the branch to origin with upstream + binding +- ``origin_configured()`` — the strict origin probe + +The subprocess call is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched; the quarantined +index is the one real filesystem object (``tempfile.mkstemp`` inside the +``.git`` directory the test provides). +""" + +from __future__ import annotations + +import inspect +import subprocess +import typing +from pathlib import Path +from unittest import mock + +import pytest +from goga.topics.git import ( + commit_file_on_base, + create_branch_at_commit, + delete_local_branch, + origin_configured, + push_branch, + resolve_ref_commit, +) + +_TITLE_PATH = ".goga/history/2026/feature-foo/title.txt" +_TITLE_CONTENT = "Payment retry\n" +_TITLE_MESSAGE = "goga: create topic feature-foo" + + +def _git_answer(stdout: str = "") -> subprocess.CompletedProcess[str]: + """A successful git invocation answering ``stdout``.""" + return subprocess.CompletedProcess(args=["git"], returncode=0, stdout=stdout, stderr="") + + +def _commands_of(run: mock.Mock) -> list[list[str]]: + """The argv list of every invocation the mock received.""" + return [call.args[0] for call in run.call_args_list] + + +# --- Contract tests --- + + +class TestPublishContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """All six publish routines live on the cell facade.""" + import goga.topics.git as cell + + assert cell.resolve_ref_commit is resolve_ref_commit + assert cell.commit_file_on_base is commit_file_on_base + assert cell.create_branch_at_commit is create_branch_at_commit + assert cell.delete_local_branch is delete_local_branch + assert cell.push_branch is push_branch + assert cell.origin_configured is origin_configured + for name in ( + "resolve_ref_commit", + "commit_file_on_base", + "create_branch_at_commit", + "delete_local_branch", + "push_branch", + "origin_configured", + ): + assert name in cell.__all__ + + def test_declared_signatures(self) -> None: + """The routines take exactly the declared parameters.""" + assert list(inspect.signature(resolve_ref_commit).parameters) == ["ref"] + assert list(inspect.signature(commit_file_on_base).parameters) == ["base", "path", "content", "message"] + assert list(inspect.signature(create_branch_at_commit).parameters) == ["branch_name", "commit"] + assert list(inspect.signature(delete_local_branch).parameters) == ["branch_name"] + assert list(inspect.signature(push_branch).parameters) == ["branch_name"] + assert list(inspect.signature(origin_configured).parameters) == [] + + def test_parameters_are_positional_or_keyword_with_contract_hints(self) -> None: + """No extras, no defaults, and the declared type hints.""" + hints = { + resolve_ref_commit: {"ref": str, "return": str}, + commit_file_on_base: {"base": str, "path": str, "content": str, "message": str, "return": str}, + create_branch_at_commit: {"branch_name": str, "commit": str, "return": type(None)}, + delete_local_branch: {"branch_name": str, "return": type(None)}, + push_branch: {"branch_name": str, "return": type(None)}, + origin_configured: {"return": bool}, + } + for routine, declared in hints.items(): + parameters = inspect.signature(routine).parameters + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in parameters.values() + ), routine + assert all(parameter.default is inspect.Parameter.empty for parameter in parameters.values()), routine + assert typing.get_type_hints(routine) == declared, routine + + +# --- Logic tests --- + + +class TestResolveRefCommit: + def test_resolve_ref_commit_returns_peeled_commit(self) -> None: + """``^{commit}`` peels annotated tags — the hash is a commit hash.""" + run = mock.Mock(return_value=_git_answer("1a2b3c4d5e6f7890\n")) + with mock.patch("goga.topics.git.publish.subprocess.run", run): + commit = resolve_ref_commit("origin/main") + + assert commit == "1a2b3c4d5e6f7890" + assert run.call_args.args[0] == ["git", "rev-parse", "--verify", "origin/main^{commit}"] + + def test_resolve_ref_commit_propagates_git_failure(self) -> None: + """An unresolvable revision raises raw — the cell never wraps.""" + failure = subprocess.CalledProcessError(128, ["git", "rev-parse"], stderr="fatal: Needed a single revision") + + with ( + mock.patch("goga.topics.git.publish.subprocess.run", side_effect=failure), + pytest.raises(subprocess.CalledProcessError), + ): + resolve_ref_commit("origin/absent") + + +class TestCommitFileOnBase: + def test_commit_file_on_base_builds_commit_through_quarantined_index(self, tmp_path: Path) -> None: + """The six-step chain — one quarantined index, nothing left behind.""" + git_dir = tmp_path / ".git" + git_dir.mkdir() + + def answer_by_argv(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + stdout = { + ("rev-parse", "--git-dir"): str(git_dir), + ("hash-object", "-w", "--stdin"): "<blob>", + ("write-tree",): "<tree>", + ("commit-tree", "<tree>", "-p", "<base>", "-m", _TITLE_MESSAGE): "<commit>", + }.get(tuple(command[1:]), "") + return subprocess.CompletedProcess(args=command, returncode=0, stdout=stdout, stderr="") + + run = mock.Mock(side_effect=answer_by_argv) + with mock.patch("goga.topics.git.publish.subprocess.run", run): + commit = commit_file_on_base("<base>", _TITLE_PATH, _TITLE_CONTENT, _TITLE_MESSAGE) + + assert commit == "<commit>" + assert _commands_of(run) == [ + ["git", "rev-parse", "--git-dir"], + ["git", "read-tree", "<base>"], + ["git", "hash-object", "-w", "--stdin"], + ["git", "update-index", "--add", "--cacheinfo", f"100644,<blob>,{_TITLE_PATH}"], + ["git", "write-tree"], + ["git", "commit-tree", "<tree>", "-p", "<base>", "-m", _TITLE_MESSAGE], + ] + + quarantined = {"read-tree", "update-index", "write-tree"} + for call in run.call_args_list: + env = call.kwargs["env"] + assert env["GIT_TERMINAL_PROMPT"] == "0" + if call.args[0][1] in quarantined: + assert "GIT_INDEX_FILE" in env + else: + assert "GIT_INDEX_FILE" not in env + assert call.kwargs["encoding"] == "utf-8" + + index = Path(run.call_args_list[1].kwargs["env"]["GIT_INDEX_FILE"]) + assert index.parent == git_dir + assert index.name.startswith("goga-publish-index-") + assert not index.exists() + + assert run.call_args_list[2].kwargs["input"] == _TITLE_CONTENT + + def test_commit_file_on_base_removes_temporary_index_on_failure(self, tmp_path: Path) -> None: + """A failed chain still leaves no index behind — the ``finally`` unlink.""" + git_dir = tmp_path / ".git" + git_dir.mkdir() + + def answer_by_argv(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + argv = tuple(command[1:]) + if argv == ("write-tree",): + raise subprocess.CalledProcessError(128, command, stderr="fatal: unable to write tree") + stdout = str(git_dir) if argv == ("rev-parse", "--git-dir") else "" + return subprocess.CompletedProcess(args=command, returncode=0, stdout=stdout, stderr="") + + run = mock.Mock(side_effect=answer_by_argv) + with ( + mock.patch("goga.topics.git.publish.subprocess.run", run), + pytest.raises(subprocess.CalledProcessError), + ): + commit_file_on_base("<base>", _TITLE_PATH, _TITLE_CONTENT, _TITLE_MESSAGE) + + index = Path(run.call_args_list[1].kwargs["env"]["GIT_INDEX_FILE"]) + assert index.parent == git_dir + assert not index.exists() + + +class TestBranchAndPushMutations: + def test_create_branch_at_commit_updates_ref_without_switch(self) -> None: + """The plant pins ``refs/heads`` and leaves the working copy alone.""" + run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): + result = create_branch_at_commit("Feature/Foo_Bar", "<commit>") + + assert result is None + assert run.call_count == 1 + assert run.call_args.args[0] == ["git", "update-ref", "refs/heads/Feature/Foo_Bar", "<commit>"] + assert run.call_args.kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0" + + def test_delete_local_branch_deletes_ref(self) -> None: + """The rollback addresses the same ``refs/heads`` ref the plant created.""" + run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): + delete_local_branch("Feature/Foo_Bar") + + assert run.call_args.args[0] == ["git", "update-ref", "-d", "refs/heads/Feature/Foo_Bar"] + + def test_push_branch_pushes_with_upstream_binding(self) -> None: + """Exactly the named branch, ``-u`` present, origin hardcoded.""" + run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): + push_branch("Feature/Foo_Bar") + + assert run.call_count == 1 + assert run.call_args.args[0] == ["git", "push", "-u", "origin", "Feature/Foo_Bar"] + + +class TestOriginConfigured: + def test_origin_configured_true_when_configured(self) -> None: + """A readable origin remote URL reads True.""" + run = mock.Mock(return_value=_git_answer("git@github.com:o/r.git\n")) + with mock.patch("goga.topics.git.publish.subprocess.run", run): + configured = origin_configured() + + assert configured is True + assert run.call_args.args[0] == ["git", "remote", "get-url", "origin"] + + @pytest.mark.parametrize( + "failure", + [ + subprocess.CalledProcessError(2, ["git", "remote", "get-url", "origin"]), + FileNotFoundError("git"), + ], + ids=["no-origin-remote", "no-git-binary"], + ) + def test_origin_configured_false_without_origin(self, failure: Exception) -> None: + """The probe never raises — both failure shapes read False.""" + with mock.patch("goga.topics.git.publish.subprocess.run", side_effect=failure): + assert origin_configured() is False diff --git a/tests/topics/git/test_trees.py b/tests/topics/git/test_trees.py index 817c853e..54277c05 100644 --- a/tests/topics/git/test_trees.py +++ b/tests/topics/git/test_trees.py @@ -68,13 +68,13 @@ def test_git_invocation_follows_the_git_practice(self) -> None: assert kwargs["env"] == {**os.environ, "GIT_TERMINAL_PROMPT": "0"} def test_file_entity_is_importable_from_the_cell_facade(self) -> None: - """``read_ref_file`` lives on the eight-name cell facade.""" + """``read_ref_file`` lives on the fourteen-name cell facade.""" import goga.topics.git as cell assert cell.read_ref_file is read_ref_file assert "read_ref_file" in cell.__all__ assert cell.__all__ == sorted(cell.__all__) - assert len(cell.__all__) == 8 + assert len(cell.__all__) == 14 def test_file_signature_takes_ref_and_path_and_returns_optional_str(self) -> None: """``read_ref_file(ref: str, path: str) -> str | None``.""" From 65d9ce38284b91ab1c94f5a0fc084bc7b25cc8bd Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 17:17:43 +0000 Subject: [PATCH 120/229] feat: add the branch-tree slug oracle check_slug_occupancy (goga/topics) --- goga/topics/__init__.py | 3 +- goga/topics/creation.py | 71 +++++++++++++++- tests/topics/test_creation.py | 150 +++++++++++++++++++++++++++++++++- 3 files changed, 220 insertions(+), 4 deletions(-) diff --git a/goga/topics/__init__.py b/goga/topics/__init__.py index 2b950376..d023efd4 100644 --- a/goga/topics/__init__.py +++ b/goga/topics/__init__.py @@ -10,7 +10,7 @@ """ from .board import BoardRecord, collect_topic_board -from .creation import check_branch_occupancy, create_topic +from .creation import check_branch_occupancy, check_slug_occupancy, create_topic from .ensuring import ensure_topic from .switching import ( SwitchCandidate, @@ -22,6 +22,7 @@ "BoardRecord", "SwitchCandidate", "check_branch_occupancy", + "check_slug_occupancy", "collect_topic_board", "create_topic", "ensure_topic", diff --git a/goga/topics/creation.py b/goga/topics/creation.py index 07e8fc90..b1d5aeae 100644 --- a/goga/topics/creation.py +++ b/goga/topics/creation.py @@ -2,7 +2,10 @@ The entities declared in the cell CODEMANIFEST with ``location: creation.py``: the three-oracle occupancy check of a fresh-work -name and the orchestrator that creates the branch — named exactly as entered +name, the branch-tree slug oracle that reads the topic directory of a slug +across every branch tree of the inventory — without checkout, so a topic +hosted only on a branch (or only on ``origin``) is visible — and the +orchestrator that creates the branch — named exactly as entered — together with its topic directory of the year and, when a title is given, its topic title file. Topic identity and addressing belong to the history facade; the bounded git mutation belongs to the nested git cell. Git @@ -24,10 +27,11 @@ ensure_topic_dir, normalize_topic_slug, resolve_current_branch_name, + resolve_history_root, resolve_topic_file, topic_exists, ) -from .git import create_and_switch_branch, list_branch_refs +from .git import create_and_switch_branch, list_branch_refs, read_ref_tree_paths # The board hint of an occupancy conflict — where the occupied names are # visible to the user. @@ -80,6 +84,47 @@ def check_branch_occupancy( raise click.ClickException(f"git is not available: {exc}") from exc +def check_slug_occupancy(slug: str, year: str | None = None) -> str | None: + """Decide whether any branch of the inventory already hosts the topic + directory of the slug. + + Reads the branch trees through ``read_ref_tree_paths`` — the local + branches and the remote-tracking refs as they exist locally, without + checkout — one ref at a time; the first ref whose tree carries paths + under the topic directory prefix of the slug is the conflict. A topic + hosted only on ``origin`` blocks the slug the same way a local one + does; a topic living only in the working copy does not — that is the + file oracle's domain. + + Args: + slug: Normalized topic slug (checked across every branch tree). + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The human-readable reason naming the hosting branch, or ``None`` + when no branch hosts the slug. + + Constraints: + Read-only — no ref or directory is created; no checkout, no + worktree. + Do not resolve remote state over the network — the local inventory + only. + Do not probe the working copy — a topic living only on disk is the + file oracle's domain. + + Raises: + click.ClickException: a git infrastructure failure (its stderr when + git reports one, or a missing git binary). + """ + try: + return _slug_conflict(slug, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + + def create_topic( branch_name: str, year: str | None = None, title: str | None = None ) -> str: @@ -183,6 +228,28 @@ def _occupancy_conflict( return None +def _slug_conflict(slug: str, year: str | None) -> str | None: + """Probe the branch-tree slug oracle — the traced algorithm, unwrapped. + + Args: + slug: Normalized topic slug (checked across every branch tree). + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The reason naming the first hosting branch, or ``None``. + """ + resolved_year = year or current_year() + # The trailing slash is load-bearing: it keeps a sibling slug that only + # shares the prefix text ("feature-foo-bar" of "feature-foo") free. + prefix = f"{resolve_history_root().as_posix()}/{resolved_year}/{slug}/" + for ref in list_branch_refs(): + if read_ref_tree_paths(ref.name, prefix): + return ( + f"topic '{slug}' of {resolved_year} is already hosted by branch '{ref.name}'" + ) + return None + + def _create_topic(branch_name: str, year: str | None, title: str | None) -> str: """Run the traced creation procedure — the unwrapped orchestration. diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index a8e72634..5ecd4f84 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -3,6 +3,8 @@ - ``check_branch_occupancy(branch_name, slug, year)`` — the three-oracle occupancy check of a fresh-work name +- ``check_slug_occupancy(slug, year)`` — the branch-tree occupancy oracle of + a topic slug - ``create_topic(branch_name, year, title)`` — the fresh-work creation procedure with its optional topic title file @@ -19,12 +21,18 @@ import subprocess import sys import typing +from collections.abc import Callable from pathlib import Path from unittest import mock import click import pytest -from goga.topics import check_branch_occupancy, create_topic, creation +from goga.topics import ( + check_branch_occupancy, + check_slug_occupancy, + create_topic, + creation, +) from goga.topics.git import BranchRef # --- Shared scenario helpers --- @@ -70,6 +78,23 @@ def _topic_dir(cwd: Path, year: str, slug: str) -> Path: return path +def _wire_slug_oracle( + monkeypatch: pytest.MonkeyPatch, + inventory: list[BranchRef], + reader: Callable[[str, str], list[str]], +) -> mock.Mock: + """Patch creation's import points: the inventory and the tree reader. + + Returns: + The branch-ref listing as a recording mock — the oracle's only + other git touchpoint. + """ + listing = mock.Mock(return_value=inventory) + monkeypatch.setattr(creation, "list_branch_refs", listing) + monkeypatch.setattr(creation, "read_ref_tree_paths", reader) + return listing + + # --- Contract tests --- @@ -80,10 +105,12 @@ def test_entities_are_importable_from_the_cell_facade(self) -> None: assert cell.create_topic is create_topic assert cell.check_branch_occupancy is check_branch_occupancy + assert cell.check_slug_occupancy is check_slug_occupancy expected = { "BoardRecord", "SwitchCandidate", "check_branch_occupancy", + "check_slug_occupancy", "collect_topic_board", "create_topic", "ensure_topic", @@ -92,6 +119,22 @@ def test_entities_are_importable_from_the_cell_facade(self) -> None: } assert set(cell.__all__) == expected + def test_check_slug_occupancy_signature(self) -> None: + """``check_slug_occupancy(slug, year=None)``.""" + signature = inspect.signature(check_slug_occupancy) + assert list(signature.parameters) == ["slug", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(check_slug_occupancy) + assert hints == { + "slug": str, + "year": str | None, + "return": str | None, + } + def test_check_branch_occupancy_signature(self) -> None: """``check_branch_occupancy(branch_name, slug, year=None)``.""" signature = inspect.signature(check_branch_occupancy) @@ -211,6 +254,111 @@ def test_check_branch_occupancy_free_everywhere( assert check_branch_occupancy("feat/x", "feat-x", "2026") is None +# --- Logic tests: the branch-tree slug oracle --- + + +class TestCheckSlugOccupancy: + def test_check_slug_occupancy_returns_first_hosting_branch( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The first ref whose tree carries the topic directory names the conflict.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="alpha", remote=False), + BranchRef(name="beta", remote=True), + ] + reader = mock.Mock( + side_effect=[[], [".goga/history/2026/feature-foo/title.txt"]] + ) + listing = _wire_slug_oracle(monkeypatch, inventory, reader) + + conflict = check_slug_occupancy("feature-foo", "2026") + + assert conflict == ( + "topic 'feature-foo' of 2026 is already hosted by branch 'beta'" + ) + assert reader.call_args.args == ("beta", ".goga/history/2026/feature-foo/") + listing.assert_called_once_with() + + def test_check_slug_occupancy_stops_at_first_hit( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The first occupied ref wins — the remaining refs are not probed.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="alpha", remote=False), + BranchRef(name="beta", remote=False), + BranchRef(name="gamma", remote=False), + ] + reader = mock.Mock( + side_effect=[ + [".goga/history/2026/feature-foo/title.txt"], + [".goga/history/2026/feature-foo/title.txt"], + [".goga/history/2026/feature-foo/title.txt"], + ] + ) + _wire_slug_oracle(monkeypatch, inventory, reader) + + conflict = check_slug_occupancy("feature-foo", "2026") + + assert conflict == ( + "topic 'feature-foo' of 2026 is already hosted by branch 'alpha'" + ) + assert reader.call_count == 1 + + def test_check_slug_occupancy_free_slug_returns_none( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """No ref hosts the slug — None, one probe per ref.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="alpha", remote=False), + BranchRef(name="origin/beta", remote=True), + ] + reader = mock.Mock(return_value=[]) + listing = _wire_slug_oracle(monkeypatch, inventory, reader) + + assert check_slug_occupancy("feature-foo", "2026") is None + assert reader.call_count == 2 + listing.assert_called_once_with() + + def test_check_slug_occupancy_does_not_match_sibling_slug_prefix( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A sibling slug sharing the prefix text hosts nothing — the trailing slash. + + The real ``startswith`` filter lives inside the reader, which is + mocked away here — the emulation keeps the oracle honest about what + the reader contract returns. + """ + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="alpha", remote=False)] + paths = [".goga/history/2026/feature-foo-bar/title.txt"] + received: list[str] = [] + + def emulate_reader(ref: str, prefix: str) -> list[str]: + received.append(prefix) + return [path for path in paths if path.startswith(prefix)] + + _wire_slug_oracle(monkeypatch, inventory, emulate_reader) + + assert check_slug_occupancy("feature-foo", "2026") is None + assert received == [".goga/history/2026/feature-foo/"] + + def test_check_slug_occupancy_ignores_disk_only_topics( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A topic living only in the working copy is invisible to this oracle.""" + monkeypatch.chdir(tmp_path) + topic_dir = _topic_dir(tmp_path, "2026", "feature-foo") + (topic_dir / "title.txt").write_text("On disk only\n", encoding="utf-8") + inventory = [BranchRef(name="alpha", remote=False)] + reader = mock.Mock(return_value=[]) + _wire_slug_oracle(monkeypatch, inventory, reader) + + assert check_slug_occupancy("feature-foo", "2026") is None + + # --- Logic tests: the creation procedure --- From 5263dcdc6a74a6a93124ecc077bb79f4e7bb86f7 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 17:23:47 +0000 Subject: [PATCH 121/229] feat: add the fast creation-and-publication cycle publish_topic (goga/topics) --- goga/topics/__init__.py | 16 +- goga/topics/publishing.py | 152 ++++++++++++ tests/topics/test_creation.py | 1 + tests/topics/test_publishing.py | 396 ++++++++++++++++++++++++++++++++ 4 files changed, 560 insertions(+), 5 deletions(-) create mode 100644 goga/topics/publishing.py create mode 100644 tests/topics/test_publishing.py diff --git a/goga/topics/__init__.py b/goga/topics/__init__.py index d023efd4..d67c9d7a 100644 --- a/goga/topics/__init__.py +++ b/goga/topics/__init__.py @@ -2,16 +2,21 @@ The cross-branch topic inventory of one year with per-topic statuses, the switch-identifier resolution and switching orchestration, the fresh-work -creation procedure, and the combined ensure orchestration that switches -onto hosted work and creates it when nothing hosts the identifier. Topic -identity, addressing, and statuses belong to the history facade; git -access belongs to the nested leaf cell ``goga.topics.git``. Mutations are -local-only and happen strictly after every decision is made. +creation procedure, the fast creation-and-publication cycle that builds a +one-commit branch off an explicit base through quarantined git plumbing +and pushes it to origin while the caller stays on their branch, and the +combined ensure orchestration that switches onto hosted work and creates +it when nothing hosts the identifier. Topic identity, addressing, and +statuses belong to the history facade; git access belongs to the nested +leaf cell ``goga.topics.git``. Mutations are local-only and happen +strictly after every decision is made — the publication push of the fast +cycle is the single network exception. """ from .board import BoardRecord, collect_topic_board from .creation import check_branch_occupancy, check_slug_occupancy, create_topic from .ensuring import ensure_topic +from .publishing import publish_topic from .switching import ( SwitchCandidate, resolve_switch_candidates, @@ -26,6 +31,7 @@ "collect_topic_board", "create_topic", "ensure_topic", + "publish_topic", "resolve_switch_candidates", "switch_topic", ] diff --git a/goga/topics/publishing.py b/goga/topics/publishing.py new file mode 100644 index 00000000..0f1b810a --- /dev/null +++ b/goga/topics/publishing.py @@ -0,0 +1,152 @@ +"""The fast creation-and-publication of the topics domain. + +The entity declared in the cell CODEMANIFEST with +``location: publishing.py``: the fast cycle that creates fresh work and +publishes it in one go — a branch off an explicit base carrying exactly one +commit with the topic title file, pushed to origin, while the caller stays +on their branch. Every decision is made before the first mutation; the +mutation sequence is the quarantined commit build, the branch plant, and +the push, and a failed publication rolls back fully — the planted branch +is deleted and nothing else was ever mutated. The occupancy oracles and the +re-ask machinery belong to ``creation``; the bounded git mutations to the +nested git cell. Git infrastructure failures surface as +``click.ClickException`` — the clean-error boundary of the domain. +""" + +from __future__ import annotations + +import contextlib +import subprocess + +import click + +from ..history import ( + current_year, + normalize_topic_slug, + resolve_current_branch_name, + resolve_topic_file, +) +from .creation import _BOARD_HINT, _reask, check_branch_occupancy, check_slug_occupancy +from .git import ( + commit_file_on_base, + create_branch_at_commit, + delete_local_branch, + origin_configured, + push_branch, + resolve_ref_commit, +) + + +def publish_topic( + branch_name: str, + title: str, + base_ref: str, + commit_message: str, + year: str | None = None, +) -> str: + """Create fresh work and publish it — a branch off an explicit base + carrying one commit with the topic title, pushed to origin, while the + caller stays on their branch. + + Args: + branch_name: Branch name as entered by the user. + title: Topic title — written to the title file as entered plus a + single trailing newline. + base_ref: Base revision the branch starts from — any revision + string, resolved as git resolves it. + commit_message: Commit message template — the ``{slug}`` + placeholder is replaced with the topic slug; a template + without the placeholder is used as is. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + One line describing the created and published work. + + Raises: + click.ClickException: the current branch already hosting the slug, + a missing origin remote, an unresolved occupancy conflict + without a terminal, a git infrastructure failure (its stderr + when git reports one, or a missing git binary). + click.Abort: Ctrl-C or EOF at the re-ask prompt — nothing was + mutated. + """ + try: + return _publish_topic(branch_name, title, base_ref, commit_message, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + + +def _publish_topic( + branch_name: str, + title: str, + base_ref: str, + commit_message: str, + year: str | None, +) -> str: + """Run the traced fast cycle — the unwrapped orchestration. + + Args: + branch_name: Branch name as entered by the user. + title: Topic title as entered by the user. + base_ref: Base revision the branch starts from. + commit_message: Commit message template with ``{slug}`` optional. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The single result line of the outcome. + """ + resolved_year = year or current_year() + while True: + slug = normalize_topic_slug(branch_name) + + if slug == "": + reason = f"branch name '{branch_name}' normalizes to an empty topic slug" + branch_name = _reask(reason) + continue + + current = resolve_current_branch_name() + if current is not None and normalize_topic_slug(current) == slug: + raise click.ClickException( + f"branch {current} already hosts topic {resolved_year}/{slug}" + " — the fast path is only for fresh work" + ) + + conflict = check_branch_occupancy(branch_name, slug, resolved_year) + if conflict is None: + conflict = check_slug_occupancy(slug, resolved_year) + if conflict is not None: + branch_name = _reask(conflict, _BOARD_HINT) + continue + + if not origin_configured(): + raise click.ClickException( + "origin is not configured — the fast mode publishes to origin" + ) + + base_commit = resolve_ref_commit(base_ref) + + path = resolve_topic_file(slug, "title.txt", resolved_year).as_posix() + commit = commit_file_on_base( + base_commit, + path, + f"{title}\n", + commit_message.replace("{slug}", slug), + ) + + create_branch_at_commit(branch_name, commit) + try: + push_branch(branch_name) + except subprocess.CalledProcessError: + # Full rollback before the one clean error — a failure of the + # rollback itself is suppressed so the original push reason + # surfaces; a branch left behind stays visible on the board. + with contextlib.suppress(subprocess.CalledProcessError, FileNotFoundError): + delete_local_branch(branch_name) + raise + + return ( + f"Created branch {branch_name} and published topic {resolved_year}/{slug}" + ) diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index 5ecd4f84..2f88989d 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -114,6 +114,7 @@ def test_entities_are_importable_from_the_cell_facade(self) -> None: "collect_topic_board", "create_topic", "ensure_topic", + "publish_topic", "resolve_switch_candidates", "switch_topic", } diff --git a/tests/topics/test_publishing.py b/tests/topics/test_publishing.py new file mode 100644 index 00000000..6eaba722 --- /dev/null +++ b/tests/topics/test_publishing.py @@ -0,0 +1,396 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/CODEMANIFEST`` with ``location: publishing.py``: + +- ``publish_topic(branch_name, title, base_ref, commit_message, year)`` — + the fast creation-and-publication cycle + +Every git touchpoint is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched; ``normalize_topic_slug`` +stays real because it is a pure string transformation, and so does +``resolve_topic_file`` (a pure path composer). The recording doubles assert +the decision-before-mutation order, the exact delegation arguments, and the +full rollback of a failed publication. +""" + +from __future__ import annotations + +import inspect +import subprocess +import sys +import typing +from pathlib import Path +from unittest import mock + +import click +import pytest +from goga.topics import publish_topic, publishing + + +# --- Shared scenario helpers --- + + +def _non_interactive(monkeypatch: pytest.MonkeyPatch) -> None: + """Make stdin a non-terminal — the re-ask path must abort cleanly.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + + +def _interactive( + monkeypatch: pytest.MonkeyPatch, answers: list[str] +) -> mock.Mock: + """Make stdin a terminal and answer the re-ask prompts in order.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + prompt = mock.Mock(side_effect=answers) + monkeypatch.setattr(click, "prompt", prompt) + return prompt + + +class _Cycle: + """Recording doubles of every mocked touchpoint of the fast cycle.""" + + def __init__(self) -> None: + self.resolve_current_branch_name = mock.Mock(return_value="main") + self.check_branch_occupancy = mock.Mock(return_value=None) + self.check_slug_occupancy = mock.Mock(return_value=None) + self.origin_configured = mock.Mock(return_value=True) + self.resolve_ref_commit = mock.Mock(return_value="<base>") + self.commit_file_on_base = mock.Mock(return_value="<commit>") + self.create_branch_at_commit = mock.Mock() + self.push_branch = mock.Mock() + self.delete_local_branch = mock.Mock() + self.current_year = mock.Mock(return_value="2026") + + +def _wire_cycle(monkeypatch: pytest.MonkeyPatch) -> _Cycle: + """Patch publishing's import points with the recording doubles.""" + cycle = _Cycle() + for name, double in vars(cycle).items(): + monkeypatch.setattr(publishing, name, double) + return cycle + + +def _assert_no_mutation(cycle: _Cycle) -> None: + """Assert none of the three mutations of the cycle ran.""" + cycle.commit_file_on_base.assert_not_called() + cycle.create_branch_at_commit.assert_not_called() + cycle.push_branch.assert_not_called() + cycle.delete_local_branch.assert_not_called() + + +# --- Contract tests --- + + +class TestPublishingContract: + def test_publish_topic_is_importable_from_the_cell_facade(self) -> None: + """``publish_topic`` lives on the cell facade and in ``__all__``.""" + import goga.topics as cell + + assert cell.publish_topic is publish_topic + expected = { + "BoardRecord", + "SwitchCandidate", + "check_branch_occupancy", + "check_slug_occupancy", + "collect_topic_board", + "create_topic", + "ensure_topic", + "publish_topic", + "resolve_switch_candidates", + "switch_topic", + } + assert set(cell.__all__) == expected + assert "publish_topic" in cell.__all__ + + def test_publish_topic_signature(self) -> None: + """``publish_topic(branch_name, title, base_ref, commit_message, year=None)``. + + ``commit_message`` carries no default — the design-review pin: the + template is always an explicit argument. + """ + signature = inspect.signature(publish_topic) + assert list(signature.parameters) == [ + "branch_name", + "title", + "base_ref", + "commit_message", + "year", + ] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + assert ( + signature.parameters["commit_message"].default is inspect.Parameter.empty + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(publish_topic) + assert hints == { + "branch_name": str, + "title": str, + "base_ref": str, + "commit_message": str, + "year": str | None, + "return": str, + } + + def test_no_working_copy_write_in_publishing(self) -> None: + """The quarantined cycle writes nothing to the working copy.""" + assert not hasattr(publishing, "ensure_topic_dir") + source = inspect.getsource(publishing) + assert "write_text" not in source + assert "mkdir" not in source + + def test_publishing_never_switches(self) -> None: + """No switch, checkout, or reset primitive reaches the fast cycle.""" + for forbidden in ( + "create_and_switch_branch", + "checkout_local_branch", + "create_branch_from_remote_tracking", + ): + assert not hasattr(publishing, forbidden) + + +# --- Logic tests: the fast creation-and-publication cycle --- + + +class TestPublishTopic: + def test_publish_topic_happy_path_builds_plants_and_pushes( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A free name: resolve, build, plant, push — in that exact order.""" + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + + result = publish_topic( + "Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}" + ) + + cycle.resolve_current_branch_name.assert_called_once_with() + cycle.check_branch_occupancy.assert_called_once_with( + "Feature/Foo_Bar", "feature-foo-bar", "2026" + ) + cycle.check_slug_occupancy.assert_called_once_with("feature-foo-bar", "2026") + cycle.origin_configured.assert_called_once_with() + cycle.resolve_ref_commit.assert_called_once_with("origin/main") + cycle.commit_file_on_base.assert_called_once_with( + "<base>", + ".goga/history/2026/feature-foo-bar/title.txt", + "Payment retry\n", + "goga: create topic feature-foo-bar", + ) + cycle.create_branch_at_commit.assert_called_once_with( + "Feature/Foo_Bar", "<commit>" + ) + cycle.push_branch.assert_called_once_with("Feature/Foo_Bar") + cycle.delete_local_branch.assert_not_called() + assert result == ( + "Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar" + ) + assert "\n" not in result + + @pytest.mark.parametrize( + ("template", "expected"), + [ + ("chore: new topic", "chore: new topic"), + ("chore: new topic {slug}", "chore: new topic feature-foo-bar"), + ], + ) + def test_publish_topic_template_without_placeholder_used_as_is( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + template: str, + expected: str, + ) -> None: + """The template applies via plain ``str.replace`` — no format grammar.""" + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + + publish_topic("Feature/Foo_Bar", "T", "origin/main", template) + + assert cycle.commit_file_on_base.call_args.args[3] == expected + + def test_publish_topic_current_branch_hosting_slug_is_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The current branch hosting the slug: one clean error, no mutation.""" + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.resolve_current_branch_name.return_value = "Feature/Foo_Bar" + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") + + assert raised.value.message == ( + "branch Feature/Foo_Bar already hosts topic 2026/feature-foo-bar" + " — the fast path is only for fresh work" + ) + _assert_no_mutation(cycle) + + def test_publish_topic_conflict_without_terminal_fails_with_board_hint( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An occupancy conflict without a terminal: the reason and the hint.""" + monkeypatch.chdir(tmp_path) + _non_interactive(monkeypatch) + cycle = _wire_cycle(monkeypatch) + cycle.check_slug_occupancy.return_value = ( + "topic 'feature-foo-bar' of 2026 is already hosted by branch 'alpha'" + ) + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m", "2026") + + assert raised.value.message == ( + "topic 'feature-foo-bar' of 2026 is already hosted by branch 'alpha'" + " — run 'goga topics status' to see the board" + ) + _assert_no_mutation(cycle) + + def test_publish_topic_failed_push_rolls_back_and_surfaces_reason( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A failed push deletes the planted branch and keeps its reason.""" + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.push_branch.side_effect = subprocess.CalledProcessError( + 1, ["git", "push"], stderr="error: failed to push some refs" + ) + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m", "2026") + + assert raised.value.message == "git failed: error: failed to push some refs" + cycle.delete_local_branch.assert_called_once_with("Feature/Foo_Bar") + + def test_publish_topic_rollback_failure_still_surfaces_push_reason( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A rollback failure of its own is suppressed — the push reason wins.""" + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.push_branch.side_effect = subprocess.CalledProcessError( + 1, ["git", "push"], stderr="error: failed to push some refs" + ) + cycle.delete_local_branch.side_effect = subprocess.CalledProcessError( + 128, ["git", "update-ref"], stderr="fatal: unable to delete" + ) + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m", "2026") + + assert raised.value.message == "git failed: error: failed to push some refs" + cycle.delete_local_branch.assert_called_once_with("Feature/Foo_Bar") + + def test_publish_topic_unresolvable_base_is_clean_error_before_mutations( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An unresolvable base fails before any mutation was made.""" + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.resolve_ref_commit.side_effect = subprocess.CalledProcessError( + 128, ["git", "rev-parse"], stderr="fatal: Needed a single revision" + ) + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "no-such-ref", "m") + + assert raised.value.message == "git failed: fatal: Needed a single revision" + _assert_no_mutation(cycle) + + def test_publish_topic_without_origin_is_clean_error_before_mutations( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """No origin remote: one clean error, nothing mutated.""" + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.origin_configured.return_value = False + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") + + assert ( + raised.value.message + == "origin is not configured — the fast mode publishes to origin" + ) + _assert_no_mutation(cycle) + + def test_publish_topic_detached_head_does_not_interfere( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A detached HEAD reads ``None`` and stays out of the way.""" + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.resolve_current_branch_name.return_value = None + + result = publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") + + assert result == ( + "Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar" + ) + cycle.resolve_current_branch_name.assert_called_once_with() + cycle.push_branch.assert_called_once_with("Feature/Foo_Bar") + + def test_publish_topic_reask_restarts_the_fast_cycle( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A re-asked name restarts the whole cycle — new branch, new slug.""" + monkeypatch.chdir(tmp_path) + prompt = _interactive(monkeypatch, ["Feature/Baz"]) + cycle = _wire_cycle(monkeypatch) + cycle.check_slug_occupancy.side_effect = [ + "topic 'feature-foo-bar' of 2026 is already hosted by branch 'alpha'", + None, + ] + + result = publish_topic("Feature/Foo_Bar", "T", "origin/main", "m", "2026") + + assert result == "Created branch Feature/Baz and published topic 2026/feature-baz" + assert prompt.call_count == 1 + assert prompt.call_args.args[0] == "New branch name" + cycle.create_branch_at_commit.assert_called_once_with("Feature/Baz", "<commit>") + assert ( + cycle.commit_file_on_base.call_args.args[1] + == ".goga/history/2026/feature-baz/title.txt" + ) + assert cycle.commit_file_on_base.call_args.args[3] == "m" + cycle.push_branch.assert_called_once_with("Feature/Baz") + + def test_publish_topic_empty_slug_reasks( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A name that normalizes to nothing re-asks — no board hint, no mutation.""" + monkeypatch.chdir(tmp_path) + prompt = _interactive(monkeypatch, ["Feature/Baz"]) + cycle = _wire_cycle(monkeypatch) + + result = publish_topic("///", "T", "origin/main", "m") + + assert prompt.call_count == 1 + assert prompt.call_args.args[0] == "New branch name" + cycle.create_branch_at_commit.assert_called_once_with("Feature/Baz", "<commit>") + assert ( + cycle.commit_file_on_base.call_args.args[1] + == ".goga/history/2026/feature-baz/title.txt" + ) + assert cycle.commit_file_on_base.call_args.args[2] == "T\n" + cycle.push_branch.assert_called_once_with("Feature/Baz") + assert result == "Created branch Feature/Baz and published topic 2026/feature-baz" + + +# --- Infrastructure boundary --- + + +class TestPublishingInfrastructureBoundary: + def test_missing_git_binary_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A missing git binary during the cycle is a clean error.""" + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.resolve_ref_commit.side_effect = FileNotFoundError("git") + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") + + assert "git" in raised.value.message + _assert_no_mutation(cycle) From 50f28c94a8329f77fae1bb2d92d2176684907d67 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 17:33:44 +0000 Subject: [PATCH 122/229] feat: add the topics create --publish flags and value resolution (goga/commands/topics) --- goga/commands/topics/topics.py | 98 ++++++++- tests/commands/topics/test_topics_command.py | 208 ++++++++++++++++++- tests/topics/test_publishing.py | 1 - 3 files changed, 298 insertions(+), 9 deletions(-) diff --git a/goga/commands/topics/topics.py b/goga/commands/topics/topics.py index e9053200..4547e2d0 100644 --- a/goga/commands/topics/topics.py +++ b/goga/commands/topics/topics.py @@ -5,8 +5,11 @@ topics domain. The group carries the year scope every subcommand shares and is a thin wrapper — it resolves the inputs, delegates every computation to the domain routines of ``goga.topics``, and renders the board through the -``render`` module. No inventory walking, no switch resolution, and no git -access live here; domain errors surface as clean CLI errors. +``render`` module. The fast creation-and-publication mode of ``create`` +resolves its own inputs at this layer: a flag beats the ``topics`` section +of the project configuration, which beats the built-in default. No +inventory walking, no switch resolution, and no git access live here; +domain errors surface as clean CLI errors. """ from __future__ import annotations @@ -15,10 +18,16 @@ from dataclasses import dataclass import click +import yaml -from ...topics import collect_topic_board, create_topic, switch_topic +from ...config import TopicsConfig, load_project_config +from ...topics import collect_topic_board, create_topic, publish_topic, switch_topic from .render import render_topic_board +# The built-in template of the publish path — the lowest row of the +# flag > topics section > default resolution matrix. +_DEFAULT_PUBLISH_COMMIT = "goga: create topic {slug}" + @dataclass(kw_only=True) class _TopicsScope: @@ -27,6 +36,16 @@ class _TopicsScope: year: str | None = None +def _topics_section() -> TopicsConfig | None: + """Read the topics section of .goga/config.yml — None when unset or unconfigured.""" + try: + return load_project_config().topics + except FileNotFoundError: + return None + except (KeyError, ValueError, yaml.YAMLError) as exc: + raise click.ClickException(str(exc)) from exc + + @click.group() @click.option( "--year", @@ -81,8 +100,34 @@ def status(scope: _TopicsScope, remote: bool = False, info: bool = False) -> Non default=None, help="Topic title — writes title.txt in the topic directory.", ) +@click.option( + "--publish", + "-p", + is_flag=True, + default=False, + help="Create the work off an explicit base and publish it to origin without switching.", +) +@click.option( + "--base-ref", + default=None, + help="Base revision of the published branch; beats topics.base_ref of .goga/config.yml.", +) +@click.option( + "--commit", + "-c", + "commit_message", + default=None, + help="Commit message template; beats topics.publish_commit — {slug} takes the topic slug.", +) @click.pass_obj -def create(scope: _TopicsScope, branch_name: str, title: str | None = None) -> None: +def create( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared CLI surface + scope: _TopicsScope, + branch_name: str, + title: str | None = None, + publish: bool = False, + base_ref: str | None = None, + commit_message: str | None = None, +) -> None: """Create fresh work — a branch with the name as entered and its topic directory. The branch name is taken verbatim; the topic directory of the scoped @@ -92,8 +137,51 @@ def create(scope: _TopicsScope, branch_name: str, title: str | None = None) -> N already hosting the same slug is an idempotent success. Occupied names and empty slugs re-ask on an interactive terminal and fail with a clean error otherwise. One result line on stdout. + + --publish/-p is the fast mode: the branch is created off an explicit + base — --base-ref, otherwise topics.base_ref of .goga/config.yml — + carrying one commit with the topic title file — the message template + from --commit/-c, otherwise topics.publish_commit, otherwise the + built-in default — and is pushed to origin without switching. The + title is required in this mode — the board reads the topic through + the title file — and a failed publication rolls back fully: the + planted branch is deleted and one clean error names the reason. """ - line = create_topic(branch_name, scope.year, title) + if not publish and (base_ref is not None or commit_message is not None): + raise click.ClickException("--base-ref and --commit act only together with --publish") + + if publish and title is None: + raise click.ClickException( + "--publish needs a topic title — pass --title/-t; the board reads the topic through the title file" + ) + + if not publish: + line = create_topic(branch_name, scope.year, title) + click.echo(line) + click.get_current_context().exit(0) + + # The configuration is read lazily — only when a value no flag + # provided has to come from it; both flags given means zero reads. + section = _topics_section() if base_ref is None or commit_message is None else None + + base = base_ref if base_ref is not None else (section.base_ref if section is not None else None) + if base is None: + raise click.ClickException( + "no base for the published branch — set topics.base_ref in .goga/config.yml or pass --base-ref:\n" + "topics:\n base_ref: origin/main" + ) + + template = ( + commit_message + if commit_message is not None + else ( + section.publish_commit + if section is not None and section.publish_commit is not None + else _DEFAULT_PUBLISH_COMMIT + ) + ) + + line = publish_topic(branch_name, title, base, template, scope.year) click.echo(line) click.get_current_context().exit(0) diff --git a/tests/commands/topics/test_topics_command.py b/tests/commands/topics/test_topics_command.py index a7ebecd7..efcb1ce2 100644 --- a/tests/commands/topics/test_topics_command.py +++ b/tests/commands/topics/test_topics_command.py @@ -8,7 +8,11 @@ ``goga.topics`` domain — the board collection and rendering for ``status`` (the ``--info/-i`` flag adds the title column to the rendered table), the creation (``--title/-t`` writes the topic title file) and switching -procedures for ``create``/``switch``. The logic tests mock the domain at its +procedures for ``create``/``switch``. ``create`` also carries the fast +creation-and-publication mode — ``--publish/-p`` with ``--base-ref`` and +``--commit/-c`` — whose values resolve as flag beats the ``topics`` section +of ``.goga/config.yml`` beats the built-in default, the configuration being +read on the publish path only. The logic tests mock the domain at its import site in the command module and drive the CLI surface through ``CliRunner``; a pinned ``COLUMNS`` keeps the measured terminal width deterministic. @@ -20,6 +24,7 @@ import os import shutil import sys +from pathlib import Path from unittest import mock import click @@ -119,12 +124,47 @@ def test_create_carries_the_title_option(self) -> None: assert title_option.is_flag is False assert title_option.default is None + def test_create_carries_the_publish_flag(self) -> None: + """create: --publish/-p flag, defaulting to False.""" + command = topics.commands["create"] + publish_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "publish") + assert "-p" in publish_option.opts + assert "--publish" in publish_option.opts + assert publish_option.is_flag is True + assert publish_option.default is False + + def test_create_carries_the_base_ref_option(self) -> None: + """create: --base-ref option, long form only, defaulting to None.""" + command = topics.commands["create"] + base_ref_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "base_ref") + assert base_ref_option.opts == ["--base-ref"] + assert base_ref_option.is_flag is False + assert base_ref_option.default is None + + def test_create_carries_the_commit_option_with_the_explicit_param_name(self) -> None: + """create: --commit/-c bound to the param name ``commit_message``.""" + command = topics.commands["create"] + commit_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "commit_message") + assert commit_option.opts == ["--commit", "-c"] + assert commit_option.is_flag is False + assert commit_option.default is None + def test_create_callback_signature(self) -> None: - """``create(scope, branch_name, title=None)``.""" + """``create(scope, branch_name, title=None, publish=False, base_ref=None, commit_message=None)``.""" callback = topics.commands["create"].callback signature = inspect.signature(callback) - assert list(signature.parameters) == ["scope", "branch_name", "title"] + assert list(signature.parameters) == [ + "scope", + "branch_name", + "title", + "publish", + "base_ref", + "commit_message", + ] assert signature.parameters["title"].default is None + assert signature.parameters["publish"].default is False + assert signature.parameters["base_ref"].default is None + assert signature.parameters["commit_message"].default is None def test_switch_carries_the_identifier_positional(self) -> None: """switch: the required identifier positional.""" @@ -168,6 +208,16 @@ def test_subcommand_help_follows_the_cli_docstring_rule(self, subcommand: str) - for section in ("Args:", "Returns:", "Raises:"): assert section not in result.output + def test_create_help_lists_the_new_flags(self) -> None: + """create --help lists --publish/-p, --base-ref, and --commit/-c.""" + result = CliRunner().invoke(topics, ["create", "--help"]) + assert result.exit_code == 0 + assert "--publish" in result.output + assert "-p" in result.output + assert "--base-ref" in result.output + assert "--commit" in result.output + assert "-c" in result.output + def test_year_defaults_to_none_for_the_domain(self) -> None: """Without --year the subcommands hand the domain the current-year None.""" with mock.patch.object(_topics_module, "create_topic") as mock_create: @@ -367,3 +417,155 @@ def test_domain_error_surfaces_clean(self, subcommand: str, routine: str) -> Non assert "working tree is dirty" in result.stderr assert "Traceback" not in result.stderr assert result.stdout == "" + + +def _write_config(tmp_path: Path, body: str) -> None: + """Write ``.goga/config.yml`` with the given body under tmp_path.""" + goga_dir = tmp_path / ".goga" + goga_dir.mkdir(exist_ok=True) + (goga_dir / "config.yml").write_text(body, encoding="utf-8") + + +class TestTopicsCreatePublish: + def test_create_publish_flag_beats_config_section(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Both publication flags given: the flag values win and no config read happens.""" + monkeypatch.chdir(tmp_path) + _write_config( + tmp_path, + "language: python\ntopics:\n base_ref: origin/config-base\n publish_commit: 'config: {slug}'\n", + ) + with ( + mock.patch.object(_topics_module, "load_project_config") as mock_load, + mock.patch.object(_topics_module, "create_topic") as mock_create, + mock.patch.object( + _topics_module, + "publish_topic", + return_value="Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar", + ) as mock_publish, + ): + result = CliRunner().invoke( + topics, + [ + "create", + "Feature/Foo_Bar", + "--publish", + "--title", + "T", + "--base-ref", + "origin/flag-base", + "--commit", + "flag: {slug}", + ], + ) + assert result.exit_code == 0 + mock_publish.assert_called_once_with("Feature/Foo_Bar", "T", "origin/flag-base", "flag: {slug}", None) + mock_load.assert_not_called() + mock_create.assert_not_called() + assert result.output.splitlines() == ["Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar"] + + def test_create_publish_resolves_config_and_default(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """No flags: the base comes from the config, the template from the built-in default.""" + monkeypatch.chdir(tmp_path) + _write_config(tmp_path, "language: python\ntopics:\n base_ref: origin/config-base\n") + with mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish: + result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "--publish", "--title", "T"]) + assert result.exit_code == 0 + mock_publish.assert_called_once_with( + "Feature/Foo_Bar", "T", "origin/config-base", "goga: create topic {slug}", None + ) + assert result.output == "line\n" + + @pytest.mark.parametrize("extra", [["--commit", "m"], ["--base-ref", "origin/main"]]) + def test_create_publication_flags_without_publish_are_clean_error(self, extra: list[str]) -> None: + """--base-ref or --commit without --publish is a clean error; no domain routine runs.""" + with ( + mock.patch.object(_topics_module, "load_project_config") as mock_load, + mock.patch.object(_topics_module, "create_topic") as mock_create, + mock.patch.object(_topics_module, "publish_topic") as mock_publish, + ): + result = CliRunner().invoke(topics, ["create", "X", *extra]) + assert result.exit_code == 1 + assert "--base-ref and --commit act only together with --publish" in result.stderr + mock_load.assert_not_called() + mock_create.assert_not_called() + mock_publish.assert_not_called() + + def test_create_publish_without_title_is_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """--publish without a title is a clean error asking for it; the domain is untouched.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object(_topics_module, "publish_topic") as mock_publish: + result = CliRunner().invoke(topics, ["create", "X", "--publish"]) + assert result.exit_code == 1 + assert "--publish needs a topic title" in result.stderr + assert "--title" in result.stderr + mock_publish.assert_not_called() + + def test_create_publish_without_base_names_config_and_flag( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Nothing set: the error names the configuration line, the flag, and a yaml example.""" + monkeypatch.chdir(tmp_path) + _write_config(tmp_path, "language: python\n") + with mock.patch.object(_topics_module, "publish_topic") as mock_publish: + result = CliRunner().invoke(topics, ["create", "X", "--publish", "--title", "T"]) + assert result.exit_code == 1 + assert "topics.base_ref" in result.stderr + assert "--base-ref" in result.stderr + assert "topics:" in result.stderr + assert "base_ref: origin/main" in result.stderr + mock_publish.assert_not_called() + + def test_create_publish_invalid_config_surfaces_its_own_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A malformed topics section surfaces the loader's error, not a 'no base' guess.""" + monkeypatch.chdir(tmp_path) + _write_config(tmp_path, "language: python\ntopics: 5\n") + with mock.patch.object(_topics_module, "publish_topic") as mock_publish: + result = CliRunner().invoke(topics, ["create", "X", "--publish", "--title", "T"]) + assert result.exit_code == 1 + assert "'topics' must be a mapping in .goga/config.yml" in result.stderr + mock_publish.assert_not_called() + + def test_create_default_path_never_reads_configuration( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Without --publish the configuration is never read — a config-less repository works.""" + monkeypatch.chdir(tmp_path) + with ( + mock.patch.object(_topics_module, "load_project_config") as mock_load, + mock.patch.object( + _topics_module, "create_topic", return_value="Created branch Feature/Foo_Bar and topic 2026/x" + ) as mock_create, + ): + result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with("Feature/Foo_Bar", None, None) + mock_load.assert_not_called() + + def test_create_publish_missing_config_counts_as_unset( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A missing configuration file counts as unset — the flag and the default act.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish: + result = CliRunner().invoke( + topics, ["create", "X", "--publish", "--title", "T", "--base-ref", "origin/main"] + ) + assert result.exit_code == 0 + mock_publish.assert_called_once_with("X", "T", "origin/main", "goga: create topic {slug}", None) + + def test_create_publish_explicit_empty_title_is_not_missing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """--title '' is a deliberate empty title, not a missing one — the gate checks None.""" + monkeypatch.chdir(tmp_path) + _write_config(tmp_path, "language: python\ntopics:\n base_ref: origin/main\n") + with ( + mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish, + mock.patch.object(_topics_module, "create_topic") as mock_create, + ): + result = CliRunner().invoke(topics, ["create", "X", "--publish", "--title", ""]) + assert result.exit_code == 0 + assert mock_publish.call_args.args[1] == "" + mock_create.assert_not_called() diff --git a/tests/topics/test_publishing.py b/tests/topics/test_publishing.py index 6eaba722..f84332be 100644 --- a/tests/topics/test_publishing.py +++ b/tests/topics/test_publishing.py @@ -25,7 +25,6 @@ import pytest from goga.topics import publish_topic, publishing - # --- Shared scenario helpers --- From 74c655bc472c17fafc16efeb4d934b5bac947eda Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 17:41:21 +0000 Subject: [PATCH 123/229] feat: add the four publish-workflow integration scenarios (tests/integration) --- tests/integration/test_topic_workflows.py | 220 +++++++++++++++++++++- 1 file changed, 214 insertions(+), 6 deletions(-) diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index 94d49356..809ee0a7 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -23,6 +23,12 @@ cell: checkout, remote-tracking branch creation, and branch-plus-directory creation. + publish_topic — the quarantined fast path over the real git cell: the + title commit is built off a pushed ``origin/main`` while the working + copy, the index, and HEAD stay as they are, the branch is planted and + pushed to a real bare ``origin``, and the failed-push scenario breaks + the push URL to prove the full rollback of the planted branch. + Git is real: the git-dependent scenarios run in a throwaway repository under ``tmp_path`` (``git init`` plus commits, with ``git update-ref`` manufacturing the remote-tracking twin) and skip when no git binary is @@ -49,9 +55,9 @@ from goga.cli import app from goga.commands.history import history from goga.commands.topics import topics -from goga.history import assemble_status_scale +from goga.history import assemble_status_scale, current_year from goga.history.statuses import assembly as statuses_assembly -from goga.topics import create_topic, switch_topic +from goga.topics import create_topic, publish_topic, switch_topic from goga.topics import switching as topics_switching # The scenarios drive real git — skip them where no git binary exists. @@ -75,6 +81,40 @@ def _git(root: Path, *args: str) -> None: subprocess.run(["git", *args], cwd=root, check=True, capture_output=True, text=True) +def _git_out(root: Path, *args: str) -> str: + """Run one git command in the throwaway repository and capture stdout. + + Args: + root: The repository root. + *args: The git arguments. + + Returns: + The stripped stdout of the command. + """ + result = subprocess.run( + ["git", *args], cwd=root, check=True, capture_output=True, text=True + ) + return result.stdout.strip() + + +def _worktree_snapshot(root: Path) -> list[str]: + """List every working-copy path of the throwaway repository. + + Args: + root: The repository root. + + Returns: + The sorted repository-relative posix paths of every file and + directory below ``root`` — the ``.git`` directory excluded, the + state the user sees and the quarantine invariant protects. + """ + return sorted( + path.relative_to(root).as_posix() + for path in root.rglob("*") + if path.relative_to(root).parts[0] != ".git" + ) + + def _write(root: Path, relative: str) -> None: """Create one artifact file of the throwaway history tree. @@ -145,21 +185,51 @@ def _init_topic_repo(root: Path) -> None: _git(root, "switch", "-q", "feat-a") -def _board_rows(output: str) -> list[tuple[str, str, str]]: +def _init_publish_repo(root: Path) -> Path: + """Build the throwaway repository the publish scenarios share. + + A ``main`` branch with one tracked-file commit, a bare ``origin`` + sibling wired in as the ``origin`` remote with ``origin/main`` + materialized through a real push, and the git identity committed to + the repository config — the domain's ``commit-tree`` invocation carries + no ``-c`` identity of its own, so an unset identity would fail there. + + Args: + root: The empty directory the repository is built in. + + Returns: + The path of the bare origin repository. + """ + _git(root, "init", "-q", "-b", "main") + _git(root, "config", "user.email", "goga@example.com") + _git(root, "config", "user.name", "goga tests") + (root / "tracked.txt").write_text("base\n", encoding="utf-8") + _git(root, "add", "tracked.txt") + _git(root, *_GIT_IDENTITY, "commit", "-qm", "base") + origin = root.parent / f"{root.name}-origin.git" + _git(root, "init", "-q", "--bare", str(origin)) + _git(root, "remote", "add", "origin", str(origin)) + _git(root, "push", "-q", "origin", "main") + return origin + + +def _board_rows(output: str, columns: int = 3) -> list[tuple[str, ...]]: """Parse the rendered board into its data rows. Args: output: The captured stdout of ``goga topics status``. + columns: The text-column count of the table — 3 without ``--info``, + 4 with it (the title column between branch and statuses). Returns: - The ``(topic cell, branch, statuses)`` tuples of the data rows — - the header and separator rows dropped, every cell stripped. + The cell tuples of the data rows — the header and separator rows + dropped, every cell stripped. """ lines = [line for line in output.splitlines() if line.startswith("|")] rows = [] for line in lines[2:]: cells = line.split("|") - rows.append((cells[1].strip(), cells[2].strip(), cells[3].strip())) + rows.append(tuple(cell.strip() for cell in cells[1 : columns + 1])) return rows @@ -520,3 +590,141 @@ def test_prune_remote_only_host_protects_over_real_git( assert result.exit_code == 0 assert result.output == "" assert (tmp_path / ".goga/history/2025/remote-only/prd.md").exists() + + +@requires_git +class TestPublishTopicRealGit: + """``publish_topic`` over the real git cell — the quarantined fast path. + + No domain routine and no git routine is mocked: the CLI-less scenarios + drive the whole chain ``publish_topic`` → ``goga.topics.git`` → real + git against a throwaway repository with a real bare ``origin``. + """ + + def test_publish_end_to_end_leaves_user_state_untouched( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A publish over a dirty tree leaves HEAD, the status, and the + working copy identical — the topic exists only in the pushed branch.""" + _init_publish_repo(tmp_path) + # The uncommitted modification the quarantine invariant must survive. + (tmp_path / "tracked.txt").write_text("wip\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + year = current_year() + before = ( + _git_out(tmp_path, "rev-parse", "HEAD"), + _git_out(tmp_path, "status", "--porcelain"), + _worktree_snapshot(tmp_path), + ) + + line = publish_topic( + "Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}" + ) + + after = ( + _git_out(tmp_path, "rev-parse", "HEAD"), + _git_out(tmp_path, "status", "--porcelain"), + _worktree_snapshot(tmp_path), + ) + assert before == after + # The topic directory exists in the pushed branch, never on disk. + assert not (tmp_path / ".goga").exists() + assert line == f"Created branch Feature/Foo_Bar and published topic {year}/feature-foo-bar" + assert "\n" not in line + + def test_publish_creates_single_title_commit_and_shows_on_remote_board( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The published branch carries exactly the title commit — upstream + bound to origin and visible on the remote board with the ``new`` status.""" + _init_publish_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("COLUMNS", "120") + year = current_year() + topic_path = f".goga/history/{year}/feature-foo-bar/title.txt" + + publish_topic( + "Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}" + ) + + assert _git_out(tmp_path, "rev-list", "--count", "origin/main..Feature/Foo_Bar") == "1" + assert _git_out(tmp_path, "show", "--name-only", "--format=", "Feature/Foo_Bar").splitlines() == [ + topic_path + ] + assert ( + _git_out(tmp_path, "show", "-s", "--format=%s", "Feature/Foo_Bar") + == "goga: create topic feature-foo-bar" + ) + assert _git_out(tmp_path, "show", f"Feature/Foo_Bar:{topic_path}") == "Payment retry" + assert _git_out(tmp_path, "config", "branch.Feature/Foo_Bar.remote") == "origin" + # The local branch stays after the push. + assert _git_out(tmp_path, "rev-parse", "--verify", "refs/heads/Feature/Foo_Bar") + _git(tmp_path, "fetch", "-q", "origin") + + result = CliRunner().invoke(topics, ["--year", year, "status", "--remote", "--info"]) + + assert result.exit_code == 0 + assert _board_rows(result.output, columns=4) == [ + ("feature-foo-bar", "origin/Feature/Foo_Bar", "Payment retry", "[new]") + ] + + def test_publish_failed_push_rolls_back_and_rerun_succeeds( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A failed push deletes the planted branch and disturbs nothing — + the freed name carries the immediate re-run of the same cycle.""" + origin = _init_publish_repo(tmp_path) + _git(tmp_path, "remote", "set-url", "--push", "origin", "../does-not-exist.git") + monkeypatch.chdir(tmp_path) + porcelain_before = _git_out(tmp_path, "status", "--porcelain") + + with pytest.raises(click.ClickException, match="git failed:"): + publish_topic( + "Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}" + ) + + assert "Feature/Foo_Bar" not in _git_out(tmp_path, "for-each-ref", "refs/heads") + assert _git_out(tmp_path, "status", "--porcelain") == porcelain_before + + _git(tmp_path, "remote", "set-url", "--push", "origin", str(origin)) + line = publish_topic( + "Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}" + ) + + assert "refs/heads/Feature/Foo_Bar" in _git_out(tmp_path, "for-each-ref", "refs/heads") + assert _git_out(tmp_path, "rev-parse", "--verify", "refs/remotes/origin/Feature/Foo_Bar") + assert "published topic" in line + + def test_publish_non_ascii_title_survives_utf8( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A non-ASCII title round-trips byte-exact — UTF-8 with one + trailing newline, in the branch tree and on the remote board.""" + _init_publish_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("COLUMNS", "120") + year = current_year() + topic_path = f".goga/history/{year}/feature-foo-bar/title.txt" + + publish_topic( + "Feature/Foo_Bar", "Оплата повторно", "origin/main", "goga: create topic {slug}" + ) + + shown = subprocess.run( + ["git", "show", f"Feature/Foo_Bar:{topic_path}"], + cwd=tmp_path, + check=True, + capture_output=True, + ) + assert shown.stdout.decode("utf-8") == "Оплата повторно\n" + assert shown.stdout == "Оплата повторно\n".encode("utf-8") # noqa: UP012 — the codec is the contract + assert len(shown.stdout) == 30 + _git(tmp_path, "fetch", "-q", "origin") + + result = CliRunner().invoke(topics, ["--year", year, "status", "--remote", "--info"]) + + assert result.exit_code == 0 + assert "Оплата" in result.output + assert ("feature-foo-bar", "origin/Feature/Foo_Bar", "Оплата повторно", "[new]") in _board_rows( + result.output, columns=4 + ) From 63f6e7520f6968fc853036f7dd5887d3b5e6a0c3 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 18:01:03 +0000 Subject: [PATCH 124/229] fix: address code review findings --- README.md | 3 + docs/cli/topics.md | 28 +++- docs/configuration/index.md | 2 +- docs/configuration/project.md | 19 ++- goga/topics/git/publish.py | 13 +- goga/topics/git/trees.py | 5 +- goga/topics/publishing.py | 5 + tests/commands/topics/test_topics_command.py | 65 ++++++++++ tests/integration/test_topic_workflows.py | 75 +++++++++++ tests/topics/git/test_publish.py | 25 +++- tests/topics/git/test_trees.py | 16 ++- tests/topics/test_creation.py | 56 ++++++++ tests/topics/test_publishing.py | 128 +++++++++++++++++-- 13 files changed, 416 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 86a3b6f5..67b47c86 100644 --- a/README.md +++ b/README.md @@ -146,10 +146,13 @@ goga topics status --remote # same board over remote-tracking refs goga topics status --info # the board with the title column (first line of title.txt) goga topics create feat/x # fresh work: the branch verbatim + its topic directory goga topics create feat/x -t "Payment retry" # same, and writes title.txt (status: new) +goga topics create feat/x -p -t "Payment retry" # same, committed + pushed to origin, no switch goga topics switch feat-x # onto the branch hosting that work (branch, slug, or prefix) goga topics --year 2025 status # the board of an explicit year ``` +`--publish`/`-p` is the fast mode: it builds the branch off an explicit base (`--base-ref`, or `topics.base_ref` in `.goga/config.yml`) with a single `title.txt` commit and pushes it to `origin` without switching — your working copy, index, and HEAD stay untouched, and a failed push rolls the branch back. See [`goga topics`](https://qarium.github.io/goga/cli/topics/). + The board is a three-column table — topic, branch, statuses, plus a Title column under `--info` — with `*` marking the current branch and a local branch absorbing its remote twin. Each topic carries its **maximal statuses** in scale order: `empty → new → defined → discovered → backlog → designed → specified → planned → done`, deepening as `title.txt`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. A topic can carry several statuses at once (`goga history status` prints them; `-s` filters by any of them). Topics no branch hosts anymore are orphans — `goga history prune --dry-run` lists the orphans of a year, and `goga history prune [YEAR]` deletes them (irreversibly: the history tree is not in git). diff --git a/docs/cli/topics.md b/docs/cli/topics.md index c3c85d50..f0414dea 100644 --- a/docs/cli/topics.md +++ b/docs/cli/topics.md @@ -2,13 +2,13 @@ Work with the topics of one year — the cross-branch inventory, fresh-work creation, and switching. -`goga topics` is a Click group with three subcommands (`status`, `create`, `switch`) over the topics domain. It is host-side and git-driven: the board reads branch trees without checkout, creation and switching perform bounded local git mutations, and no network access ever happens (no fetch, no push). +`goga topics` is a Click group with three subcommands (`status`, `create`, `switch`) over the topics domain. It is host-side and git-driven: the board reads branch trees without checkout, and creation and switching perform bounded local git mutations. `create --publish` is the one exception on the network: it pushes the branch to `origin` (the only network operation of the group — no fetch ever happens); every other mutation is local. ## Synopsis ```bash goga topics [--year YYYY] status [--remote] [--info] -goga topics [--year YYYY] create BRANCH_NAME [--title TITLE] +goga topics [--year YYYY] create BRANCH_NAME [--title TITLE] [--publish] [--base-ref REF] [--commit TEMPLATE] goga topics [--year YYYY] switch IDENTIFIER ``` @@ -57,6 +57,26 @@ goga topics create Feature/Foo_Bar --title "Payment retry" - Occupancy is probed against three oracles in order: a local branch with the entered name, a remote-tracking branch with the entered name (local refs only — no network), and an existing `.goga/history/<YYYY>/<slug>/` directory (only a directory occupies a topic). - An occupied name or a name that normalizes to an empty slug (a fully non-ASCII name) prints the reason and prompts for a new name on an interactive terminal, restarting with it; with no terminal it exits 1 with the reason (and a hint to `goga topics status` for occupied names). Ctrl-C at the prompt aborts with nothing created. +### `--publish` — create and publish in one step + +`-p`/`--publish` builds the branch off an explicit base, commits only the topic's `title.txt` on it, and pushes it to `origin` — while you stay on your branch: + +```bash +goga topics create Feature/Foo_Bar --publish --title "Payment retry" +# Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar +``` + +- The working copy, the index, and HEAD stay untouched — the commit is built through quarantined git plumbing, so a dirty tree and a detached HEAD do not interfere; the topic directory is never created on disk. +- The branch carries exactly one commit — the title file at `.goga/history/<YYYY>/<slug>/title.txt` — and is pushed to `origin` with upstream binding (`git push -u`, exactly that one branch). The topic appears on the remote board with the `new` status. +- `-t`/`--title` is **required** under `--publish` (the board reads the topic through the title file): without it, exit 1 with `--publish needs a topic title — pass --title/-t`. An explicit empty title `""` is accepted and writes the bare newline. +- Base resolution: `--base-ref` > `topics.base_ref` in `.goga/config.yml` > error. With nothing set, exit 1 with a message naming both the configuration line and the flag, including a two-line YAML example (see [Project Configuration](../configuration/project.md#topics)). +- Commit template: `--commit`/`-c` > `topics.publish_commit` > the built-in default `goga: create topic {slug}`. `{slug}` is replaced with the topic slug; a template without the placeholder is used verbatim. +- `--base-ref` or `--commit` without `--publish` is a clean error (exit 1) — they act only together with `--publish`. +- Occupancy under `--publish` adds a fourth oracle on top of the three above: any branch tree of the inventory — local and remote-tracking refs — hosting the topic directory of the slug. The conflict reads `topic '<slug>' of <YYYY> is already hosted by branch '<branch>'`, with the same re-ask/exit-1 behavior as the other oracles. +- The current branch already hosting the slug is a clean error (exit 1) — the fast path is only for fresh work; use the default `create` for the idempotent case. +- `origin` must be configured (exit 1 otherwise, before any mutation). The repository git identity must be set — `commit-tree` needs an author. +- A failed push rolls back fully: the planted branch is deleted, nothing else was ever mutated, and git's push reason surfaces as one clean error (`git failed: <git stderr>`, exit 1). A re-run with the same name then succeeds. + ## `goga topics switch` Brings the repository onto the branch hosting the requested work: @@ -85,10 +105,10 @@ The same resolution backs the switch half of `goga pipeline <name> -t <identifie | Code | Meaning | |------|---------| | `0` | Success — the board printed, the work created, or the switch performed (including the idempotent outcomes) | -| `1` | A clean domain error: an unresolvable or ambiguous identifier, an occupied name without a terminal, a dirty working tree, a git infrastructure failure, or a broken `goga_tool_*` package failing to import during status-scale assembly | +| `1` | A clean domain error: an unresolvable or ambiguous identifier, an occupied name without a terminal, a dirty working tree, a failed publication (`--publish`), a git infrastructure failure, or a broken `goga_tool_*` package failing to import during status-scale assembly | | `2` | A usage error (unknown option, missing argument) | ## Notes -- Every mutation is local — no fetch, no push, no network. +- Every mutation is local except the `--publish` push — no fetch ever happens, and `create --publish` is the only subcommand that pushes. - `goga history status` shows the same statuses scoped to the working copy of one year (see [history](history.md)). diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 146966ae..6e220ebc 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -4,7 +4,7 @@ goga reads configuration from two files: the **project** config `.goga/config.ym | File | Scope | Required | |---|---|---| -| [Project Configuration](project.md) — `.goga/config.yml` | One project: language, image, build and pipeline executors, codemanifest, tools, usages, lint | Required for `goga build` / `goga pipeline` | +| [Project Configuration](project.md) — `.goga/config.yml` | One project: language, image, build and pipeline executors, codemanifest, tools, usages, lint, topics | Required for `goga build` / `goga pipeline` | | [Home Configuration](home.md) — `~/.goga/config.yml` | Whole machine: base env layer, extra `docker run` / `docker build` arguments | Optional — absent by default | The home config is the lower-priority layer: `home.env` is the base of the env layering formula `{**home.env, **project_env, **cli_env}`, and `docker.run` / `docker.build` fragments are appended to every container invocation regardless of the project. See [Home Configuration](home.md#env-layering) for the layering details. diff --git a/docs/configuration/project.md b/docs/configuration/project.md index a0093c7c..c8a677c0 100644 --- a/docs/configuration/project.md +++ b/docs/configuration/project.md @@ -77,6 +77,11 @@ codemanifest: # ignore: # - .venv/ # - build/dist + +# topics: optional — fast topic publication (`goga topics create --publish`) +# topics: +# base_ref: origin/main # base of the published topic branches +# publish_commit: "goga: create topic {slug}" # commit message template ({slug} optional) ``` ## Fields reference @@ -95,6 +100,7 @@ codemanifest: | `tools` | mapping | No | goga-tool version declarations consumed by `goga install` in bulk mode. Keys are tool names (without the `goga-tool-` prefix); values are version-form strings. Values are stored verbatim — the four-form grammar (`1.0.x`, `1.x`, `1.0.1`, `latest`) is validated by `goga install`, not the loader. Defaults to `None` (absent); an empty mapping is `{}`. YAML-null values (`viewer:`) are rejected | | `usages` | mapping | No | Git dependencies whose cell-level `.usages/` files are synced into `.goga/usages/<group>/<dep>/` by [`goga usages sync`](../cli/usages.md) and checked for drift against the remote by [`goga usages status`](../cli/usages.md). Two-level mapping: `<group>` → `<dep>` → `{ git, ref, root }`. Defaults to `None` (absent), which makes `goga usages sync` a no-op (exit 0); an empty mapping is `{}`. `<group>` and `<dep>` keys are validated as filesystem path segments — empty, `.` / `..`, or any name containing `/` or `\` raise `ValueError` | | `lint` | mapping | No | Optional linter section consumed by [`goga lint`](../cli/lint.md). Currently holds `ignore`, a list of directory relative paths to prune from lint traversal. Defaults to `None` (absent); an empty mapping is equivalent to no ignore list. Structural type errors (non-mapping `lint`, non-list `lint.ignore`, or a non-string element) raise `ValueError` | +| `topics` | mapping | No | Fast topic publication section consumed by [`goga topics create --publish`](../cli/topics.md#--publish--create-and-publish-in-one-step). Defaults to `None` (absent); a present-but-empty mapping is a `TopicsConfig` with both fields `None`. A non-mapping value raises `ValueError` | ### build @@ -178,6 +184,17 @@ Optional section consumed by [`goga lint`](../cli/lint.md) to prune directories When `lint` is absent, `config.lint` is `None` and `goga lint` lints every directory. A present-but-non-mapping `lint`, a non-list `lint.ignore`, or a non-string element raises `ValueError`. The `lint` command derives `ignore` **tolerantly** — any loader error falls back to no filtering rather than failing the lint run. +### topics + +Optional section consumed by [`goga topics create --publish`](../cli/topics.md#--publish--create-and-publish-in-one-step). Read on the publish path only — a run without `--publish` never touches it. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `topics.base_ref` | `string` | No | Base revision of a published topic branch — any revision string (branch, remote-tracking ref, tag, hash), stored verbatim with no resolvability check. Absent/YAML-null/empty/whitespace resolves to `None`; a non-string raises `ValueError`. Overridden by the `--base-ref` CLI option; when neither is set, `create --publish` exits 1 | +| `topics.publish_commit` | `string` | No | Commit message template of the published title commit; the optional `{slug}` placeholder is replaced with the topic slug, and a template without it is used verbatim. Same normalization and typing rules as `base_ref`. Overridden by the `--commit`/`-c` CLI option; the built-in default is `goga: create topic {slug}` | + +When `topics` is absent, `config.topics` is `None` ("everything unset"). Unknown keys inside the mapping are ignored — the same stance as `lint` and `codemanifest`. + ## Pre-built Docker images goga provides prebuilt language images for build execution: @@ -198,7 +215,7 @@ The config loader raises specific exceptions for invalid configuration: |-------|-------| | `FileNotFoundError` | `.goga/config.yml` does not exist or is empty | | `KeyError` | Missing required field (`language`, or `build.task_executor` when `build` is present) | -| `ValueError` | Invalid field value (wrong type, empty string, non-mapping where mapping expected), or the deprecated `build.image` field is present. `build.review_executor` adds: non-mapping section (`build.review_executor must be a mapping`), non-bool `skip` (a YAML `1` is rejected), non-string `agent`, `roles` that is not a list of strings, a non-mapping `env` (`build.review_executor.env must be a mapping in .goga/config.yml`), `env` with non-string keys/values (`build.review_executor.env must have string keys and values`), a non-string `base_ref` (`build.review_executor.base_ref must be a string in .goga/config.yml`), or a non-int `patience`, including a YAML boolean (`build.review_executor.patience must be an int in .goga/config.yml`) | +| `ValueError` | Invalid field value (wrong type, empty string, non-mapping where mapping expected), or the deprecated `build.image` field is present. `build.review_executor` adds: non-mapping section (`build.review_executor must be a mapping`), non-bool `skip` (a YAML `1` is rejected), non-string `agent`, `roles` that is not a list of strings, a non-mapping `env` (`build.review_executor.env must be a mapping in .goga/config.yml`), `env` with non-string keys/values (`build.review_executor.env must have string keys and values`), a non-string `base_ref` (`build.review_executor.base_ref must be a string in .goga/config.yml`), or a non-int `patience`, including a YAML boolean (`build.review_executor.patience must be an int in .goga/config.yml`). `topics` adds: a non-mapping section (`'topics' must be a mapping in .goga/config.yml`) or a non-string field (`topics.base_ref must be a string in .goga/config.yml`, `topics.publish_commit must be a string in .goga/config.yml`) | ## Implementation details diff --git a/goga/topics/git/publish.py b/goga/topics/git/publish.py index 53ca1eca..2705f848 100644 --- a/goga/topics/git/publish.py +++ b/goga/topics/git/publish.py @@ -198,7 +198,18 @@ def push_branch(branch_name: str) -> None: OSError: unexpected OS-level failures of the git invocation (e.g. a missing git binary). """ - _run_git(["git", "push", "-u", "origin", branch_name]) + # The full refspec is load-bearing: a bare name that starts with a dash + # (git accepts ``refs/heads/--mirror``, and the plant creates it verbatim) + # would be parsed as a push option — ``--mirror`` would sync and prune + # every remote ref while ``--delete`` or ``--repo`` would act at all. The + # refspec can never start with a dash, so exactly the named branch goes. + _run_git([ + "git", + "push", + "-u", + "origin", + f"refs/heads/{branch_name}:refs/heads/{branch_name}", + ]) def origin_configured() -> bool: diff --git a/goga/topics/git/trees.py b/goga/topics/git/trees.py index 7ba0b45c..de02f95c 100644 --- a/goga/topics/git/trees.py +++ b/goga/topics/git/trees.py @@ -53,7 +53,10 @@ def read_ref_tree_paths(ref: str, prefix: str) -> list[str]: missing git binary). """ result = subprocess.run( - ["git", "ls-tree", "-r", "--name-only", ref, "--", prefix], + # The ``--`` separator precedes the ref: a display name that starts + # with a dash (git accepts ``refs/heads/--mirror``) would otherwise + # be parsed as an ls-tree option and fail the whole inventory read. + ["git", "ls-tree", "-r", "--name-only", "--", ref, prefix], check=True, capture_output=True, text=True, diff --git a/goga/topics/publishing.py b/goga/topics/publishing.py index 0f1b810a..f0fb63da 100644 --- a/goga/topics/publishing.py +++ b/goga/topics/publishing.py @@ -77,6 +77,11 @@ def publish_topic( raise click.ClickException(f"git failed: {detail}") from exc except FileNotFoundError as exc: raise click.ClickException(f"git is not available: {exc}") from exc + except OSError as exc: + # The quarantined chain creates and removes its temporary index under + # ``.git`` — an unwritable repository directory surfaces here as one + # clean error instead of a raw traceback, mirroring ``create_topic``. + raise click.ClickException(f"cannot build the publication commit: {exc}") from exc def _publish_topic( diff --git a/tests/commands/topics/test_topics_command.py b/tests/commands/topics/test_topics_command.py index efcb1ce2..a68f14f0 100644 --- a/tests/commands/topics/test_topics_command.py +++ b/tests/commands/topics/test_topics_command.py @@ -475,6 +475,71 @@ def test_create_publish_resolves_config_and_default(self, tmp_path: Path, monkey ) assert result.output == "line\n" + def test_create_publish_config_template_beats_default( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``topics.publish_commit`` wins over the built-in default template.""" + monkeypatch.chdir(tmp_path) + _write_config( + tmp_path, + "language: python\ntopics:\n base_ref: origin/config-base\n publish_commit: 'config: {slug}'\n", + ) + with mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish: + result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "--publish", "--title", "T"]) + assert result.exit_code == 0 + mock_publish.assert_called_once_with( + "Feature/Foo_Bar", "T", "origin/config-base", "config: {slug}", None + ) + + def test_create_publish_flag_base_with_config_template( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A flag base with a config template — each value resolves on its own row.""" + monkeypatch.chdir(tmp_path) + _write_config( + tmp_path, + "language: python\ntopics:\n base_ref: origin/config-base\n publish_commit: 'config: {slug}'\n", + ) + with ( + mock.patch.object( + _topics_module, "load_project_config", wraps=_topics_module.load_project_config + ) as mock_load, + mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish, + ): + result = CliRunner().invoke( + topics, + ["create", "Feature/Foo_Bar", "--publish", "--title", "T", "--base-ref", "origin/flag-base"], + ) + assert result.exit_code == 0 + mock_publish.assert_called_once_with( + "Feature/Foo_Bar", "T", "origin/flag-base", "config: {slug}", None + ) + # The template flag is absent, so the config is read for it. + mock_load.assert_called_once_with() + + def test_create_publish_flag_template_with_config_base( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A config base with a flag template — the flag template wins.""" + monkeypatch.chdir(tmp_path) + _write_config(tmp_path, "language: python\ntopics:\n base_ref: origin/config-base\n") + with ( + mock.patch.object( + _topics_module, "load_project_config", wraps=_topics_module.load_project_config + ) as mock_load, + mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish, + ): + result = CliRunner().invoke( + topics, + ["create", "Feature/Foo_Bar", "--publish", "--title", "T", "--commit", "flag: {slug}"], + ) + assert result.exit_code == 0 + mock_publish.assert_called_once_with( + "Feature/Foo_Bar", "T", "origin/config-base", "flag: {slug}", None + ) + # The base flag is absent, so the config is read for it. + mock_load.assert_called_once_with() + @pytest.mark.parametrize("extra", [["--commit", "m"], ["--base-ref", "origin/main"]]) def test_create_publication_flags_without_publish_are_clean_error(self, extra: list[str]) -> None: """--base-ref or --commit without --publish is a clean error; no domain routine runs.""" diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index 809ee0a7..dbe046ae 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -728,3 +728,78 @@ def test_publish_non_ascii_title_survives_utf8( assert ("feature-foo-bar", "origin/Feature/Foo_Bar", "Оплата повторно", "[new]") in _board_rows( result.output, columns=4 ) + + def test_publish_slug_hosted_by_another_branch_is_blocked( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The branch-tree oracle over real git: a slug already hosted by + another branch blocks the publish — nothing is planted or pushed.""" + _init_publish_repo(tmp_path) + year = current_year() + _git(tmp_path, "switch", "-q", "-c", "Host_Branch") + _write(tmp_path, f".goga/history/{year}/feature-foo-bar/prd.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "host topic") + _git(tmp_path, "switch", "-q", "main") + monkeypatch.chdir(tmp_path) + heads_before = _git_out(tmp_path, "for-each-ref", "refs/heads") + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") + + assert raised.value.message == ( + f"topic 'feature-foo-bar' of {year} is already hosted by branch 'Host_Branch'" + " — run 'goga topics status' to see the board" + ) + assert _git_out(tmp_path, "for-each-ref", "refs/heads") == heads_before + assert "Feature/Foo_Bar" not in _git_out(tmp_path, "for-each-ref", "refs/heads") + + def test_publish_sibling_slug_is_not_a_conflict( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A sibling slug sharing only the prefix text stays free. + + ``feature-foo-bar`` hosted by another branch must not block + ``feature-foo`` — the trailing slash of the probe prefix against + real git pathspec filtering, not an emulated reader. + """ + _init_publish_repo(tmp_path) + year = current_year() + _git(tmp_path, "switch", "-q", "-c", "Host_Branch") + _write(tmp_path, f".goga/history/{year}/feature-foo-bar/prd.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "host sibling topic") + _git(tmp_path, "switch", "-q", "main") + monkeypatch.chdir(tmp_path) + + line = publish_topic("Feature/Foo", "T", "origin/main", "m") + + assert line == f"Created branch Feature/Foo and published topic {year}/feature-foo" + + def test_publish_slug_hosted_only_on_origin_is_blocked( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A slug hosted only by a remote-tracking ref blocks the publish. + + The name differs from the remote ref's short name, so the branch + oracle stays free and the conflict comes from the branch-tree oracle + alone — a topic hosted only on ``origin`` blocks the slug. + """ + _init_publish_repo(tmp_path) + year = current_year() + _git(tmp_path, "switch", "-q", "-c", "throwaway") + _write(tmp_path, f".goga/history/{year}/remote-only/prd.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "remote-only topic") + _git(tmp_path, "update-ref", "refs/remotes/origin/Remote_Only", "HEAD") + _git(tmp_path, "switch", "-q", "main") + _git(tmp_path, "branch", "-qD", "throwaway") + monkeypatch.chdir(tmp_path) + + with pytest.raises(click.ClickException) as raised: + publish_topic("Remote/Only", "T", "origin/main", "m") + + assert raised.value.message == ( + f"topic 'remote-only' of {year} is already hosted by branch 'origin/Remote_Only'" + " — run 'goga topics status' to see the board" + ) diff --git a/tests/topics/git/test_publish.py b/tests/topics/git/test_publish.py index 0fe83e5a..e5b8a8bc 100644 --- a/tests/topics/git/test_publish.py +++ b/tests/topics/git/test_publish.py @@ -226,7 +226,30 @@ def test_push_branch_pushes_with_upstream_binding(self) -> None: push_branch("Feature/Foo_Bar") assert run.call_count == 1 - assert run.call_args.args[0] == ["git", "push", "-u", "origin", "Feature/Foo_Bar"] + assert run.call_args.args[0] == [ + "git", + "push", + "-u", + "origin", + "refs/heads/Feature/Foo_Bar:refs/heads/Feature/Foo_Bar", + ] + + def test_push_branch_refspec_cannot_be_parsed_as_an_option(self) -> None: + """A dash-leading branch name stays a refspec — never a push option. + + Git accepts ``refs/heads/--mirror`` and the plant creates names + verbatim, so a bare-name argv would hand git ``push -u origin + --mirror``: git would then sync and prune every remote ref while + reporting success. The ``refs/heads/...:refs/heads/...`` form starts + with ``r`` and can only ever name exactly the one branch. + """ + run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): + push_branch("--mirror") + + refspec = run.call_args.args[0][4] + assert refspec == "refs/heads/--mirror:refs/heads/--mirror" + assert not refspec.startswith("-") class TestOriginConfigured: diff --git a/tests/topics/git/test_trees.py b/tests/topics/git/test_trees.py index 54277c05..3b11c962 100644 --- a/tests/topics/git/test_trees.py +++ b/tests/topics/git/test_trees.py @@ -60,13 +60,27 @@ def test_git_invocation_follows_the_git_practice(self) -> None: assert run.call_count == 1 command = run.call_args.args[0] - assert command == ["git", "ls-tree", "-r", "--name-only", "feat-a", "--", ".goga/history/"] + assert command == ["git", "ls-tree", "-r", "--name-only", "--", "feat-a", ".goga/history/"] kwargs = run.call_args.kwargs assert kwargs["check"] is True assert kwargs["capture_output"] is True assert kwargs["text"] is True assert kwargs["env"] == {**os.environ, "GIT_TERMINAL_PROMPT": "0"} + def test_git_invocation_separates_a_dash_leading_ref(self) -> None: + """The ``--`` separator precedes the ref, never only the pathspec. + + A display name that starts with a dash (git accepts + ``refs/heads/--mirror``, and the publish path plants names verbatim) + would otherwise be parsed as an ls-tree option and fail every + inventory read of the repository with ``unknown option``. + """ + run = mock.Mock(return_value=_git_answer("")) + with mock.patch("goga.topics.git.trees.subprocess.run", run): + read_ref_tree_paths("--mirror", ".goga/history/") + + assert run.call_args.args[0] == ["git", "ls-tree", "-r", "--name-only", "--", "--mirror", ".goga/history/"] + def test_file_entity_is_importable_from_the_cell_facade(self) -> None: """``read_ref_file`` lives on the fourteen-name cell facade.""" import goga.topics.git as cell diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index 2f88989d..e6d927cc 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -359,6 +359,23 @@ def test_check_slug_occupancy_ignores_disk_only_topics( assert check_slug_occupancy("feature-foo", "2026") is None + def test_check_slug_occupancy_default_year_is_current( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``year=None`` resolves to the current year — the probe is year-scoped.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="alpha", remote=False)] + reader = mock.Mock(return_value=[".goga/history/2026/feature-foo/title.txt"]) + _wire_slug_oracle(monkeypatch, inventory, reader) + monkeypatch.setattr(creation, "current_year", lambda: "2026") + + conflict = check_slug_occupancy("feature-foo") + + assert conflict == ( + "topic 'feature-foo' of 2026 is already hosted by branch 'alpha'" + ) + assert reader.call_args.args == ("alpha", ".goga/history/2026/feature-foo/") + # --- Logic tests: the creation procedure --- @@ -665,6 +682,45 @@ def test_git_failure_of_the_oracles_surfaces_as_clean_error( assert "fatal: not a git repository" in raised.value.message + def test_git_failure_of_the_slug_oracle_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The branch-tree oracle wraps the listing failure like its sibling. + + Both git touchpoints of the oracle share the boundary: the inventory + listing and the per-ref tree reader. + """ + monkeypatch.chdir(tmp_path) + failure = subprocess.CalledProcessError( + returncode=128, cmd=["git", "ls-tree"], stderr="fatal: not a git repository" + ) + _wire_slug_oracle( + monkeypatch, + [BranchRef(name="alpha", remote=False)], + mock.Mock(side_effect=failure), + ) + + with pytest.raises(click.ClickException) as raised: + check_slug_occupancy("feature-foo", "2026") + + assert "fatal: not a git repository" in raised.value.message + + def test_missing_git_binary_of_the_slug_oracle_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A missing git binary is a clean error on the branch-tree oracle too.""" + monkeypatch.chdir(tmp_path) + _wire_slug_oracle( + monkeypatch, + [BranchRef(name="alpha", remote=False)], + mock.Mock(side_effect=FileNotFoundError("git")), + ) + + with pytest.raises(click.ClickException) as raised: + check_slug_occupancy("feature-foo", "2026") + + assert "git" in raised.value.message + def test_missing_git_binary_surfaces_as_clean_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/topics/test_publishing.py b/tests/topics/test_publishing.py index f84332be..225249a9 100644 --- a/tests/topics/test_publishing.py +++ b/tests/topics/test_publishing.py @@ -44,26 +44,46 @@ def _interactive( class _Cycle: - """Recording doubles of every mocked touchpoint of the fast cycle.""" + """Recording doubles of every mocked touchpoint of the fast cycle. + + Every double is attached to one parent recorder, so a scenario can + assert the real call order across touchpoints — not just per-touchpoint + counts. + """ def __init__(self) -> None: - self.resolve_current_branch_name = mock.Mock(return_value="main") - self.check_branch_occupancy = mock.Mock(return_value=None) - self.check_slug_occupancy = mock.Mock(return_value=None) - self.origin_configured = mock.Mock(return_value=True) - self.resolve_ref_commit = mock.Mock(return_value="<base>") - self.commit_file_on_base = mock.Mock(return_value="<commit>") - self.create_branch_at_commit = mock.Mock() - self.push_branch = mock.Mock() - self.delete_local_branch = mock.Mock() - self.current_year = mock.Mock(return_value="2026") + self.recorder = mock.Mock(name="fast-cycle") + self.resolve_current_branch_name = self._attach( + "resolve_current_branch_name", return_value="main" + ) + self.check_branch_occupancy = self._attach( + "check_branch_occupancy", return_value=None + ) + self.check_slug_occupancy = self._attach( + "check_slug_occupancy", return_value=None + ) + self.origin_configured = self._attach("origin_configured", return_value=True) + self.resolve_ref_commit = self._attach("resolve_ref_commit", return_value="<base>") + self.commit_file_on_base = self._attach( + "commit_file_on_base", return_value="<commit>" + ) + self.create_branch_at_commit = self._attach("create_branch_at_commit") + self.push_branch = self._attach("push_branch") + self.delete_local_branch = self._attach("delete_local_branch") + self.current_year = self._attach("current_year", return_value="2026") + + def _attach(self, name: str, **kwargs: object) -> mock.Mock: + double = mock.Mock(**kwargs) + self.recorder.attach_mock(double, name) + return double def _wire_cycle(monkeypatch: pytest.MonkeyPatch) -> _Cycle: """Patch publishing's import points with the recording doubles.""" cycle = _Cycle() for name, double in vars(cycle).items(): - monkeypatch.setattr(publishing, name, double) + if hasattr(publishing, name): + monkeypatch.setattr(publishing, name, double) return cycle @@ -132,14 +152,27 @@ def test_publish_topic_signature(self) -> None: } def test_no_working_copy_write_in_publishing(self) -> None: - """The quarantined cycle writes nothing to the working copy.""" + """A shallow source guardrail against working-copy writes. + + This greps the module source for the write primitives and checks the + write helper is not imported — it cannot catch every spelling + (``os.makedirs``, ``Path.write_bytes``, a function-local import). + The real invariant is pinned end-to-end by the dirty-tree snapshot + of ``tests/integration/test_topic_workflows.py``; this guardrail only + makes the obvious regressions fail fast. + """ assert not hasattr(publishing, "ensure_topic_dir") source = inspect.getsource(publishing) assert "write_text" not in source assert "mkdir" not in source def test_publishing_never_switches(self) -> None: - """No switch, checkout, or reset primitive reaches the fast cycle.""" + """The switch primitives of the sibling paths stay unimported. + + An attribute check on the module — it fails when a switch helper is + imported at module level, but a function-local import would evade it. + The end-to-end guarantee lives in the integration snapshot. + """ for forbidden in ( "create_and_switch_branch", "checkout_local_branch", @@ -181,6 +214,27 @@ def test_publish_topic_happy_path_builds_plants_and_pushes( ) cycle.push_branch.assert_called_once_with("Feature/Foo_Bar") cycle.delete_local_branch.assert_not_called() + # The parent recorder pins the cross-touchpoint order: every + # decision precedes the first mutation, and the mutations run + # build -> plant -> push. + assert cycle.recorder.mock_calls == [ + mock.call.current_year(), + mock.call.resolve_current_branch_name(), + mock.call.check_branch_occupancy( + "Feature/Foo_Bar", "feature-foo-bar", "2026" + ), + mock.call.check_slug_occupancy("feature-foo-bar", "2026"), + mock.call.origin_configured(), + mock.call.resolve_ref_commit("origin/main"), + mock.call.commit_file_on_base( + "<base>", + ".goga/history/2026/feature-foo-bar/title.txt", + "Payment retry\n", + "goga: create topic feature-foo-bar", + ), + mock.call.create_branch_at_commit("Feature/Foo_Bar", "<commit>"), + mock.call.push_branch("Feature/Foo_Bar"), + ] assert result == ( "Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar" ) @@ -245,6 +299,31 @@ def test_publish_topic_conflict_without_terminal_fails_with_board_hint( ) _assert_no_mutation(cycle) + def test_publish_topic_branch_occupancy_conflict_skips_the_slug_oracle( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The oracle order: branch occupancy first — its conflict wins alone. + + The CODEMANIFEST pins the step: ``check_branch_occupancy`` runs + before ``check_slug_occupancy`` and the first conflict wins, so a + branch-occupancy conflict must leave the slug oracle unprobed — + probing both would double the git invocations and blur the reason. + """ + monkeypatch.chdir(tmp_path) + _non_interactive(monkeypatch) + cycle = _wire_cycle(monkeypatch) + cycle.check_branch_occupancy.return_value = "branch 'Feature/Foo_Bar' already exists" + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m", "2026") + + assert raised.value.message == ( + "branch 'Feature/Foo_Bar' already exists" + " — run 'goga topics status' to see the board" + ) + cycle.check_slug_occupancy.assert_not_called() + _assert_no_mutation(cycle) + def test_publish_topic_failed_push_rolls_back_and_surfaces_reason( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -393,3 +472,24 @@ def test_missing_git_binary_surfaces_as_clean_error( assert "git" in raised.value.message _assert_no_mutation(cycle) + + def test_unwritable_repository_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An OS failure of the quarantined chain is a clean error. + + The temporary index of ``commit_file_on_base`` lives under ``.git`` — + an unwritable repository directory raises ``PermissionError`` (an + ``OSError``), which the boundary must fold into one clean error the + way ``create_topic`` folds its ``mkdir`` failures, not a traceback. + """ + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.commit_file_on_base.side_effect = PermissionError(13, "Permission denied") + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") + + assert raised.value.message.startswith("cannot build the publication commit:") + cycle.create_branch_at_commit.assert_not_called() + cycle.push_branch.assert_not_called() From d036d7cc8796b4ab994b51c484d2c24459e8a75a Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 18:21:38 +0000 Subject: [PATCH 125/229] fix: address code review findings --- goga/commands/topics/topics.py | 6 +++++ goga/topics/git/publish.py | 15 +++++++++++- tests/commands/topics/test_topics_command.py | 17 ++++++++++++++ tests/integration/test_topic_workflows.py | 24 ++++++++++++++++++++ tests/topics/git/test_publish.py | 23 +++++++++++++++++-- 5 files changed, 82 insertions(+), 3 deletions(-) diff --git a/goga/commands/topics/topics.py b/goga/commands/topics/topics.py index 4547e2d0..a7810e36 100644 --- a/goga/commands/topics/topics.py +++ b/goga/commands/topics/topics.py @@ -42,6 +42,12 @@ def _topics_section() -> TopicsConfig | None: return load_project_config().topics except FileNotFoundError: return None + except OSError as exc: + # A present-but-unreadable file (a directory in its place, a + # permission failure) surfaces as its own clean error — the loader + # documents OSError on its Raises surface. FileNotFoundError, an + # OSError subclass, is already handled above as "unset". + raise click.ClickException(str(exc)) from exc except (KeyError, ValueError, yaml.YAMLError) as exc: raise click.ClickException(str(exc)) from exc diff --git a/goga/topics/git/publish.py b/goga/topics/git/publish.py index 2705f848..63bb8896 100644 --- a/goga/topics/git/publish.py +++ b/goga/topics/git/publish.py @@ -139,7 +139,20 @@ def create_branch_at_commit(branch_name: str, commit: str) -> None: OSError: unexpected OS-level failures of the git invocation (e.g. a missing git binary). """ - _run_git(["git", "update-ref", f"refs/heads/{branch_name}", commit]) + # The create-only form is load-bearing: a plain ``update-ref <ref> + # <commit>`` moves an existing ref without complaint, and the occupancy + # oracle can miss one — git lengthens the display name of ``refs/heads/v1`` + # to ``heads/v1`` when a tag of the same name exists, and a concurrent + # writer can plant the name between the oracle and the plant. The moved + # branch would then be deleted by the caller's rollback — real work lost + # behind a push error. ``create`` refuses an existing ref (``reference + # already exists``), so the failure surfaces as one clean error before + # anything is mutated; the stdin stream never parses the verbatim name as + # an option, dash-leading or not. + _run_git( + ["git", "update-ref", "--stdin"], + input=f"create refs/heads/{branch_name} {commit}\n", + ) def delete_local_branch(branch_name: str) -> None: diff --git a/tests/commands/topics/test_topics_command.py b/tests/commands/topics/test_topics_command.py index a68f14f0..ed0c1a46 100644 --- a/tests/commands/topics/test_topics_command.py +++ b/tests/commands/topics/test_topics_command.py @@ -592,6 +592,23 @@ def test_create_publish_invalid_config_surfaces_its_own_error( assert "'topics' must be a mapping in .goga/config.yml" in result.stderr mock_publish.assert_not_called() + def test_create_publish_unreadable_config_surfaces_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An unreadable configuration file (a directory in its place) + surfaces one clean error — not a raw IsADirectoryError traceback, and + not the 'no base' guess either. + """ + monkeypatch.chdir(tmp_path) + (tmp_path / ".goga").mkdir() + (tmp_path / ".goga" / "config.yml").mkdir() + with mock.patch.object(_topics_module, "publish_topic") as mock_publish: + result = CliRunner().invoke(topics, ["create", "X", "--publish", "--title", "T"]) + assert result.exit_code == 1 + assert "Is a directory" in result.stderr + assert not isinstance(result.exception, IsADirectoryError) + mock_publish.assert_not_called() + def test_create_default_path_never_reads_configuration( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index dbe046ae..08289d51 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -803,3 +803,27 @@ def test_publish_slug_hosted_only_on_origin_is_blocked( f"topic 'remote-only' of {year} is already hosted by branch 'origin/Remote_Only'" " — run 'goga topics status' to see the board" ) + + def test_publish_name_the_oracle_misses_never_deletes_real_work( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An occupied name the inventory oracle cannot see is stopped by the + create-only plant — the pre-existing branch survives. + + A tag of the same name makes git lengthen the display name of + ``refs/heads/v1`` to ``heads/v1``, so no oracle reports the name + occupied. A plant that moved the ref would push the new commit over + ``v1`` and then delete the branch on the push failure — the real + commit lost behind the push error. The plant refuses instead. + """ + _init_publish_repo(tmp_path) + _git(tmp_path, "branch", "v1") + _git(tmp_path, "tag", "v1") + before = _git_out(tmp_path, "rev-parse", "refs/heads/v1") + monkeypatch.chdir(tmp_path) + + with pytest.raises(click.ClickException, match="reference already exists"): + publish_topic("v1", "Title", "origin/main", "goga: create topic {slug}") + + assert _git_out(tmp_path, "rev-parse", "refs/heads/v1") == before + assert "refs/remotes/origin/v1" not in _git_out(tmp_path, "for-each-ref", "refs/remotes/origin") diff --git a/tests/topics/git/test_publish.py b/tests/topics/git/test_publish.py index e5b8a8bc..d7524934 100644 --- a/tests/topics/git/test_publish.py +++ b/tests/topics/git/test_publish.py @@ -200,7 +200,7 @@ def answer_by_argv(command: list[str], **_kwargs: object) -> subprocess.Complete class TestBranchAndPushMutations: - def test_create_branch_at_commit_updates_ref_without_switch(self) -> None: + def test_create_branch_at_commit_creates_ref_without_switch(self) -> None: """The plant pins ``refs/heads`` and leaves the working copy alone.""" run = mock.Mock(return_value=_git_answer()) with mock.patch("goga.topics.git.publish.subprocess.run", run): @@ -208,9 +208,28 @@ def test_create_branch_at_commit_updates_ref_without_switch(self) -> None: assert result is None assert run.call_count == 1 - assert run.call_args.args[0] == ["git", "update-ref", "refs/heads/Feature/Foo_Bar", "<commit>"] + assert run.call_args.args[0] == ["git", "update-ref", "--stdin"] + assert run.call_args.kwargs["input"] == "create refs/heads/Feature/Foo_Bar <commit>\n" assert run.call_args.kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0" + def test_create_branch_at_commit_stream_cannot_move_an_existing_ref(self) -> None: + """The plant is create-only — a plain ``update-ref <ref> <commit>`` + would move an existing ref, and the occupancy oracle can miss one + (git lengthens the display name of ``refs/heads/v1`` to ``heads/v1`` + when a tag of the same name exists; a concurrent writer can plant the + name in between). The moved branch would then be deleted by the + caller's rollback — real work lost behind a push error. The ``create`` + stream refuses an existing ref before anything is mutated. + """ + run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): + create_branch_at_commit("--mirror", "<commit>") + + stream = run.call_args.kwargs["input"] + assert stream == "create refs/heads/--mirror <commit>\n" + # A dash-leading name stays a ref in the stream — never an option. + assert not stream.splitlines()[0].startswith("-") + def test_delete_local_branch_deletes_ref(self) -> None: """The rollback addresses the same ``refs/heads`` ref the plant created.""" run = mock.Mock(return_value=_git_answer()) From 25a71af551081b5ab3fd575f3bc2489b4016dc3a Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 18:43:53 +0000 Subject: [PATCH 126/229] fix: address code review findings --- goga/topics/git/publish.py | 4 +++ goga/topics/git/trees.py | 13 ++++++- goga/topics/publishing.py | 20 ++++++----- tests/integration/test_topic_workflows.py | 35 ++++++++++++++++++ tests/topics/git/test_publish.py | 18 +++++++++- tests/topics/git/test_trees.py | 43 +++++++++++++++++++++-- tests/topics/test_publishing.py | 22 +++++++++++- 7 files changed, 142 insertions(+), 13 deletions(-) diff --git a/goga/topics/git/publish.py b/goga/topics/git/publish.py index 63bb8896..5a5d427e 100644 --- a/goga/topics/git/publish.py +++ b/goga/topics/git/publish.py @@ -216,9 +216,13 @@ def push_branch(branch_name: str) -> None: # would be parsed as a push option — ``--mirror`` would sync and prune # every remote ref while ``--delete`` or ``--repo`` would act at all. The # refspec can never start with a dash, so exactly the named branch goes. + # ``--no-follow-tags`` holds that no-tags line even under the user's + # ``push.followTags`` config — a local-only annotated tag on the base + # commit would otherwise ride along with the branch. _run_git([ "git", "push", + "--no-follow-tags", "-u", "origin", f"refs/heads/{branch_name}:refs/heads/{branch_name}", diff --git a/goga/topics/git/trees.py b/goga/topics/git/trees.py index de02f95c..f5d25f8b 100644 --- a/goga/topics/git/trees.py +++ b/goga/topics/git/trees.py @@ -40,6 +40,10 @@ def read_ref_tree_paths(ref: str, prefix: str) -> list[str]: A ref or prefix without matches yields an empty list — not an error. + Both sides of the read are anchored at the repository root — the + invocation reads the same tree from any working directory inside + the repository. + Constraints: Do not materialize the tree — no checkout, no worktree, no temp directory. @@ -56,7 +60,14 @@ def read_ref_tree_paths(ref: str, prefix: str) -> list[str]: # The ``--`` separator precedes the ref: a display name that starts # with a dash (git accepts ``refs/heads/--mirror``) would otherwise # be parsed as an ls-tree option and fail the whole inventory read. - ["git", "ls-tree", "-r", "--name-only", "--", ref, prefix], + # ``--full-name`` and the ``:/`` pathspec magic anchor both sides of + # the read at the repository root: without them git resolves the + # pathspec against the working directory and reports paths relative + # to it, so a caller inside a subdirectory would read nothing while + # the publication write (``--cacheinfo``) lands at the root + # regardless — the occupancy oracle would miss the very conflict it + # exists to catch. + ["git", "ls-tree", "-r", "--name-only", "--full-name", "--", ref, f":/{prefix}"], check=True, capture_output=True, text=True, diff --git a/goga/topics/publishing.py b/goga/topics/publishing.py index f0fb63da..e7579d53 100644 --- a/goga/topics/publishing.py +++ b/goga/topics/publishing.py @@ -78,10 +78,12 @@ def publish_topic( except FileNotFoundError as exc: raise click.ClickException(f"git is not available: {exc}") from exc except OSError as exc: - # The quarantined chain creates and removes its temporary index under - # ``.git`` — an unwritable repository directory surfaces here as one - # clean error instead of a raw traceback, mirroring ``create_topic``. - raise click.ClickException(f"cannot build the publication commit: {exc}") from exc + # An OS-level failure can strike at any phase — the quarantined + # chain creating or removing its temporary index under ``.git``, or a + # git invocation failing to spawn — so the message stays + # phase-neutral; it surfaces here as one clean error instead of a + # raw traceback, mirroring ``create_topic``. + raise click.ClickException(f"cannot complete the publication: {exc}") from exc def _publish_topic( @@ -144,10 +146,12 @@ def _publish_topic( create_branch_at_commit(branch_name, commit) try: push_branch(branch_name) - except subprocess.CalledProcessError: - # Full rollback before the one clean error — a failure of the - # rollback itself is suppressed so the original push reason - # surfaces; a branch left behind stays visible on the board. + except (subprocess.CalledProcessError, OSError): + # Full rollback before the one clean error — a git failure and a + # spawn-level OS failure of the push alike leave nothing of this + # cycle behind. A failure of the rollback itself is suppressed so + # the original push reason surfaces; a branch left behind stays + # visible on the board. with contextlib.suppress(subprocess.CalledProcessError, FileNotFoundError): delete_local_branch(branch_name) raise diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index 08289d51..ed58cf8b 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -754,6 +754,41 @@ def test_publish_slug_hosted_by_another_branch_is_blocked( assert _git_out(tmp_path, "for-each-ref", "refs/heads") == heads_before assert "Feature/Foo_Bar" not in _git_out(tmp_path, "for-each-ref", "refs/heads") + def test_publish_from_a_subdirectory_still_sees_the_branch_tree_conflict( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The branch-tree oracle reads the root tree from any directory. + + The publication write is root-relative (``--cacheinfo`` stages at + the repository root regardless of the working directory), so the + oracle must probe the same tree from a subdirectory — a bare + pathspec resolves against the working directory there and the probe + would read nothing, publishing a duplicate of an already-hosted + topic to origin. + """ + _init_publish_repo(tmp_path) + year = current_year() + _git(tmp_path, "switch", "-q", "-c", "Host_Branch") + _write(tmp_path, f".goga/history/{year}/feature-foo-bar/prd.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "host topic") + _git(tmp_path, "switch", "-q", "main") + heads_before = _git_out(tmp_path, "for-each-ref", "refs/heads") + nested = tmp_path / "nested" + nested.mkdir() + monkeypatch.chdir(nested) + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") + + assert raised.value.message == ( + f"topic 'feature-foo-bar' of {year} is already hosted by branch 'Host_Branch'" + " — run 'goga topics status' to see the board" + ) + assert _git_out(tmp_path, "for-each-ref", "refs/heads") == heads_before + assert "Feature/Foo_Bar" not in _git_out(tmp_path, "for-each-ref", "refs/heads") + assert "Feature/Foo_Bar" not in _git_out(tmp_path, "ls-remote", "--heads", "origin") + def test_publish_sibling_slug_is_not_a_conflict( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/topics/git/test_publish.py b/tests/topics/git/test_publish.py index d7524934..e4268b3c 100644 --- a/tests/topics/git/test_publish.py +++ b/tests/topics/git/test_publish.py @@ -248,11 +248,27 @@ def test_push_branch_pushes_with_upstream_binding(self) -> None: assert run.call_args.args[0] == [ "git", "push", + "--no-follow-tags", "-u", "origin", "refs/heads/Feature/Foo_Bar:refs/heads/Feature/Foo_Bar", ] + def test_push_branch_does_not_follow_tags(self) -> None: + """``--no-follow-tags`` holds the no-tags line under user config. + + The refspec alone names exactly one branch, but git's + ``push.followTags`` config pushes local-only annotated tags sitting + on the pushed commits alongside the refspec — the contract forbids + publishing anything but the named branch, so the explicit negation + overrides the user's config. + """ + run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): + push_branch("Feature/Foo_Bar") + + assert "--no-follow-tags" in run.call_args.args[0] + def test_push_branch_refspec_cannot_be_parsed_as_an_option(self) -> None: """A dash-leading branch name stays a refspec — never a push option. @@ -266,7 +282,7 @@ def test_push_branch_refspec_cannot_be_parsed_as_an_option(self) -> None: with mock.patch("goga.topics.git.publish.subprocess.run", run): push_branch("--mirror") - refspec = run.call_args.args[0][4] + refspec = run.call_args.args[0][-1] assert refspec == "refs/heads/--mirror:refs/heads/--mirror" assert not refspec.startswith("-") diff --git a/tests/topics/git/test_trees.py b/tests/topics/git/test_trees.py index 3b11c962..aae62d7b 100644 --- a/tests/topics/git/test_trees.py +++ b/tests/topics/git/test_trees.py @@ -60,7 +60,16 @@ def test_git_invocation_follows_the_git_practice(self) -> None: assert run.call_count == 1 command = run.call_args.args[0] - assert command == ["git", "ls-tree", "-r", "--name-only", "--", "feat-a", ".goga/history/"] + assert command == [ + "git", + "ls-tree", + "-r", + "--name-only", + "--full-name", + "--", + "feat-a", + ":/.goga/history/", + ] kwargs = run.call_args.kwargs assert kwargs["check"] is True assert kwargs["capture_output"] is True @@ -79,7 +88,37 @@ def test_git_invocation_separates_a_dash_leading_ref(self) -> None: with mock.patch("goga.topics.git.trees.subprocess.run", run): read_ref_tree_paths("--mirror", ".goga/history/") - assert run.call_args.args[0] == ["git", "ls-tree", "-r", "--name-only", "--", "--mirror", ".goga/history/"] + assert run.call_args.args[0] == [ + "git", + "ls-tree", + "-r", + "--name-only", + "--full-name", + "--", + "--mirror", + ":/.goga/history/", + ] + + def test_git_invocation_anchors_the_read_at_the_repository_root(self) -> None: + """``--full-name`` and the ``:/`` pathspec magic pin both sides root. + + The contract anchors the prefix and the reported paths at the + repository root, but git resolves a bare pathspec against the working + directory and reports paths relative to it — a caller inside a + subdirectory would read nothing while the publication write + (``--cacheinfo``) lands at the root regardless, and the occupancy + oracle would miss the very conflict it exists to catch. The magic + prefix is handed to git only; the returned paths are matched against + the caller's prefix unchanged. + """ + run = mock.Mock(return_value=_git_answer(".goga/history/2026/feat-a/plan.md\n")) + with mock.patch("goga.topics.git.trees.subprocess.run", run): + paths = read_ref_tree_paths("feat-a", ".goga/history/2026/feat-a/") + + command = run.call_args.args[0] + assert "--full-name" in command + assert command[-1] == ":/.goga/history/2026/feat-a/" + assert paths == [".goga/history/2026/feat-a/plan.md"] def test_file_entity_is_importable_from_the_cell_facade(self) -> None: """``read_ref_file`` lives on the fourteen-name cell facade.""" diff --git a/tests/topics/test_publishing.py b/tests/topics/test_publishing.py index 225249a9..e5ca7d42 100644 --- a/tests/topics/test_publishing.py +++ b/tests/topics/test_publishing.py @@ -490,6 +490,26 @@ def test_unwritable_repository_surfaces_as_clean_error( with pytest.raises(click.ClickException) as raised: publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") - assert raised.value.message.startswith("cannot build the publication commit:") + assert raised.value.message.startswith("cannot complete the publication:") cycle.create_branch_at_commit.assert_not_called() cycle.push_branch.assert_not_called() + + def test_publish_topic_oserror_push_rolls_back_too( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An OS failure of the push rolls the planted branch back as well. + + ``push_branch`` can fail at spawn level (``PermissionError`` and kin + are ``OSError`` subclasses) — the full-rollback guarantee covers + every failed publication, not only git's own non-zero exits, or a + branch nobody asked for would survive the error. + """ + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.push_branch.side_effect = PermissionError(13, "Permission denied") + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m", "2026") + + assert raised.value.message.startswith("cannot complete the publication:") + cycle.delete_local_branch.assert_called_once_with("Feature/Foo_Bar") From 22aa50cd2e52af6165c495e36da9597db61f541c Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 19:09:54 +0000 Subject: [PATCH 127/229] fix: address code review findings --- goga/topics/git/publish.py | 20 ++++++++- goga/topics/publishing.py | 2 +- tests/integration/test_topic_workflows.py | 53 +++++++++++++++++++++++ tests/topics/git/test_publish.py | 30 +++++++++++-- tests/topics/test_publishing.py | 25 +++++++++++ 5 files changed, 123 insertions(+), 7 deletions(-) diff --git a/goga/topics/git/publish.py b/goga/topics/git/publish.py index 5a5d427e..25bee862 100644 --- a/goga/topics/git/publish.py +++ b/goga/topics/git/publish.py @@ -149,9 +149,18 @@ def create_branch_at_commit(branch_name: str, commit: str) -> None: # already exists``), so the failure surfaces as one clean error before # anything is mutated; the stdin stream never parses the verbatim name as # an option, dash-leading or not. + # + # The ``-z`` framing is load-bearing the same way: the line-oriented + # stream splits on ``LF``, so a newline inside a machine-generated name + # would open a *second* command of the same transaction — ``create + # refs/heads/x <oid>`` followed by ``update refs/heads/main <oid>`` moves + # the user's branch behind a garbled error. Under NUL delimiters the name + # stays one token, so the verbatim name reaches git's own refname + # validation — the contract that git owns name validity — and a control + # character dies as ``invalid ref format`` before anything is mutated. _run_git( - ["git", "update-ref", "--stdin"], - input=f"create refs/heads/{branch_name} {commit}\n", + ["git", "update-ref", "--stdin", "-z"], + input=f"create refs/heads/{branch_name}\0{commit}\0", ) @@ -274,12 +283,19 @@ def _run_git( Returns: The completed invocation with captured text output. """ + # The output is display data — hashes and git messages — so a byte a + # remote hook left outside UTF-8 decodes with the replacement character + # instead of raising from inside ``subprocess.run``: a + # ``UnicodeDecodeError`` is a ``ValueError``, it matches none of the + # domain's handlers, so it would pierce the clean-error boundary and + # skip the caller's rollback — mirroring ``read_ref_file``. return subprocess.run( command, check=True, capture_output=True, text=True, encoding="utf-8", + errors="replace", input=input, env={ **os.environ, diff --git a/goga/topics/publishing.py b/goga/topics/publishing.py index e7579d53..09f274be 100644 --- a/goga/topics/publishing.py +++ b/goga/topics/publishing.py @@ -152,7 +152,7 @@ def _publish_topic( # cycle behind. A failure of the rollback itself is suppressed so # the original push reason surfaces; a branch left behind stays # visible on the board. - with contextlib.suppress(subprocess.CalledProcessError, FileNotFoundError): + with contextlib.suppress(subprocess.CalledProcessError, OSError): delete_local_branch(branch_name) raise diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index ed58cf8b..02b8dc56 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -862,3 +862,56 @@ def test_publish_name_the_oracle_misses_never_deletes_real_work( assert _git_out(tmp_path, "rev-parse", "refs/heads/v1") == before assert "refs/remotes/origin/v1" not in _git_out(tmp_path, "for-each-ref", "refs/remotes/origin") + + def test_publish_non_utf8_remote_output_rolls_back_and_surfaces_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A remote message outside UTF-8 neither pierces the boundary nor + strands the planted branch. + + The push of a rejecting ``pre-receive`` hook answers with bytes a + strict UTF-8 reader cannot decode — a ``UnicodeDecodeError`` is a + ``ValueError``, it matches no handler of the domain, so strict + decoding would pierce the clean-error boundary *and* skip the + rollback. The replacement-character decoding keeps both guarantees. + """ + origin = _init_publish_repo(tmp_path) + hook = origin / "hooks" / "pre-receive" + hook.write_text('#!/bin/sh\nprintf "erreur: \\377\\376\\n" >&2\nexit 1\n') + hook.chmod(0o755) + monkeypatch.chdir(tmp_path) + + with pytest.raises(click.ClickException, match="remote rejected"): + publish_topic("Feature/Foo_Bar", "Title", "origin/main", "goga: create topic {slug}") + + # The planted branch was rolled back — nothing of the cycle survives. + assert "refs/heads/Feature/Foo_Bar" not in _git_out( + tmp_path, "for-each-ref", "refs/heads" + ) + assert not (tmp_path / ".goga").exists() + + def test_publish_newline_in_name_cannot_inject_a_second_command( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A newline inside the name never rewrites another ref. + + The line-oriented ``update-ref --stdin`` stream splits commands on + ``LF``, so a machine-generated name carrying ``... <oid> LF update + refs/heads/main`` would open a second command of the transaction and + silently move the user's ``main`` behind a garbled error. The NUL + framing keeps the verbatim name one token, so git's own refname + validation rejects it as one clean error before any mutation. + """ + _init_publish_repo(tmp_path) + monkeypatch.chdir(tmp_path) + base = _git_out(tmp_path, "rev-parse", "HEAD") + injected = f"evil {base}\nupdate refs/heads/main" + + with pytest.raises(click.ClickException, match="invalid ref format"): + publish_topic(injected, "Title", "origin/main", "goga: create topic {slug}") + + assert _git_out(tmp_path, "rev-parse", "refs/heads/main") == base + assert _git_out(tmp_path, "for-each-ref", "--format=%(refname)", "refs/heads") == "refs/heads/main" + assert "refs/remotes/origin/evil" not in _git_out( + tmp_path, "for-each-ref", "refs/remotes/origin" + ) diff --git a/tests/topics/git/test_publish.py b/tests/topics/git/test_publish.py index e4268b3c..fb94995d 100644 --- a/tests/topics/git/test_publish.py +++ b/tests/topics/git/test_publish.py @@ -208,8 +208,8 @@ def test_create_branch_at_commit_creates_ref_without_switch(self) -> None: assert result is None assert run.call_count == 1 - assert run.call_args.args[0] == ["git", "update-ref", "--stdin"] - assert run.call_args.kwargs["input"] == "create refs/heads/Feature/Foo_Bar <commit>\n" + assert run.call_args.args[0] == ["git", "update-ref", "--stdin", "-z"] + assert run.call_args.kwargs["input"] == "create refs/heads/Feature/Foo_Bar\0<commit>\0" assert run.call_args.kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0" def test_create_branch_at_commit_stream_cannot_move_an_existing_ref(self) -> None: @@ -226,9 +226,31 @@ def test_create_branch_at_commit_stream_cannot_move_an_existing_ref(self) -> Non create_branch_at_commit("--mirror", "<commit>") stream = run.call_args.kwargs["input"] - assert stream == "create refs/heads/--mirror <commit>\n" + assert stream == "create refs/heads/--mirror\0<commit>\0" # A dash-leading name stays a ref in the stream — never an option. - assert not stream.splitlines()[0].startswith("-") + assert not stream.split("\0")[0].split(" ", 1)[1].startswith("-") + + def test_create_branch_at_commit_stream_cannot_split_a_second_command(self) -> None: + """A newline inside the name stays one refname — never a second command. + + The line-oriented stream splits on ``LF``, so a machine-generated + name carrying a newline would open a second command of the same + transaction — ``create refs/heads/x <oid>`` followed by ``update + refs/heads/main <oid>`` silently moves the user's branch. The NUL + delimiters keep the verbatim name one token, so git's own refname + validation owns it instead of the stream parser. + """ + run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): + create_branch_at_commit("evil <oid>\nupdate refs/heads/main", "<commit>") + + stream = run.call_args.kwargs["input"] + # The whole name sits inside the single NUL-delimited refname slot. + tokens = stream.split("\0") + assert tokens[0] == "create refs/heads/evil <oid>\nupdate refs/heads/main" + assert tokens[1] == "<commit>" + # No LF ever terminates a command — only the two NULs delimit fields. + assert stream.count("\n") == 1 def test_delete_local_branch_deletes_ref(self) -> None: """The rollback addresses the same ``refs/heads`` ref the plant created.""" diff --git a/tests/topics/test_publishing.py b/tests/topics/test_publishing.py index e5ca7d42..3c1851d6 100644 --- a/tests/topics/test_publishing.py +++ b/tests/topics/test_publishing.py @@ -359,6 +359,31 @@ def test_publish_topic_rollback_failure_still_surfaces_push_reason( assert raised.value.message == "git failed: error: failed to push some refs" cycle.delete_local_branch.assert_called_once_with("Feature/Foo_Bar") + def test_publish_topic_rollback_oserror_still_surfaces_push_reason( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An OS-level rollback failure is suppressed like a git one. + + The push guard catches every ``OSError``, so the rollback suppresses + every ``OSError`` too — a ``PermissionError`` of the deletion (an + ``OSError`` that is not a ``FileNotFoundError``) must not replace the + in-flight push reason with its own message. + """ + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.push_branch.side_effect = subprocess.CalledProcessError( + 1, ["git", "push"], stderr="error: failed to push some refs" + ) + cycle.delete_local_branch.side_effect = PermissionError( + "no more process handles" + ) + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m", "2026") + + assert raised.value.message == "git failed: error: failed to push some refs" + cycle.delete_local_branch.assert_called_once_with("Feature/Foo_Bar") + def test_publish_topic_unresolvable_base_is_clean_error_before_mutations( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 3fe9dc6f57ba2e05313e7758ed1c9b3d5f6bfeb8 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 19:42:17 +0000 Subject: [PATCH 128/229] fix: address code review findings --- goga/topics/git/publish.py | 12 +++++ tests/integration/test_topic_workflows.py | 64 +++++++++++++++++++++++ tests/topics/git/test_publish.py | 38 ++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/goga/topics/git/publish.py b/goga/topics/git/publish.py index 25bee862..04ef8f0f 100644 --- a/goga/topics/git/publish.py +++ b/goga/topics/git/publish.py @@ -289,6 +289,17 @@ def _run_git( # ``UnicodeDecodeError`` is a ``ValueError``, it matches none of the # domain's handlers, so it would pierce the clean-error boundary and # skip the caller's rollback — mirroring ``read_ref_file``. + # + # The devnull stdin is load-bearing: without it every invocation + # inherits the caller's stdin, and a git subcommand that falls back to + # reading its operand from stdin would block on a descriptor that never + # reaches EOF — a terminal, an open pipe under a harness. ``commit-tree + # -m ""`` does exactly that (an empty ``-m`` counts as "no message + # supplied"), so an explicit empty template hung the publish cycle + # forever. No invocation of this module consumes the caller's stdin: + # the two that need stdin (``hash-object --stdin``, + # ``update-ref --stdin``) pass ``input`` explicitly, which routes them + # through a pipe instead. return subprocess.run( command, check=True, @@ -297,6 +308,7 @@ def _run_git( encoding="utf-8", errors="replace", input=input, + stdin=subprocess.DEVNULL if input is None else None, env={ **os.environ, "GIT_TERMINAL_PROMPT": "0", diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index 02b8dc56..897c0ac1 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -42,9 +42,11 @@ from __future__ import annotations +import os import shutil import subprocess import sys +import threading from pathlib import Path from types import ModuleType from unittest import mock @@ -915,3 +917,65 @@ def test_publish_newline_in_name_cannot_inject_a_second_command( assert "refs/remotes/origin/evil" not in _git_out( tmp_path, "for-each-ref", "refs/remotes/origin" ) + + def test_publish_empty_template_does_not_wait_for_stdin( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An explicitly empty commit template publishes without waiting on stdin. + + ``commit-tree -m ""`` reads its message from stdin when the ``-m`` + argument is empty, and the runner used to leave that stdin + inherited from the caller: under a terminal or a harness-held open + pipe — neither ever reaching EOF — the publish hung forever with no + output. The cycle must complete, the empty template taken as + verbatim as an empty ``--title`` writing its bare newline. + """ + _init_publish_repo(tmp_path) + monkeypatch.chdir(tmp_path) + year = current_year() + + # fd 0 becomes a pipe nobody writes to — EOF never arrives, the + # exact descriptor a terminal or a held stdin provides. The cycle + # runs on a thread so a regression surfaces as this test's failure + # instead of a timeout of the whole session. + read_fd, write_fd = os.pipe() + saved_stdin = os.dup(0) + lines: list[str] = [] + failure: list[BaseException] = [] + + def cycle() -> None: + try: + os.dup2(read_fd, 0) + lines.append( + publish_topic("Feature/Foo_Bar", "Payment retry", "origin/main", "") + ) + except BaseException as exc: # recorded, re-raised on the main thread below + failure.append(exc) + finally: + os.dup2(saved_stdin, 0) + + thread = threading.Thread(target=cycle, daemon=True) + thread.start() + thread.join(timeout=30) + alive = thread.is_alive() + os.close(read_fd) + os.close(write_fd) + os.close(saved_stdin) + + assert not alive, ( + "publish_topic with an empty template never returned — " + "a git invocation is waiting on the caller's stdin" + ) + if failure: + raise failure[0] + assert lines[0] == ( + f"Created branch Feature/Foo_Bar and published topic {year}/feature-foo-bar" + ) + # The published commit carries the empty message verbatim. + assert _git_out(tmp_path, "show", "-s", "--format=%s", "Feature/Foo_Bar") == "" + assert ( + _git_out( + tmp_path, "show", f"Feature/Foo_Bar:.goga/history/{year}/feature-foo-bar/title.txt" + ) + == "Payment retry" + ) diff --git a/tests/topics/git/test_publish.py b/tests/topics/git/test_publish.py index fb94995d..81dd0a58 100644 --- a/tests/topics/git/test_publish.py +++ b/tests/topics/git/test_publish.py @@ -198,6 +198,44 @@ def answer_by_argv(command: list[str], **_kwargs: object) -> subprocess.Complete assert index.parent == git_dir assert not index.exists() + def test_commit_file_on_base_empty_message_never_waits_for_stdin(self, tmp_path: Path) -> None: + """An empty template completes instead of waiting on the caller's stdin. + + ``commit-tree -m ""`` counts the empty ``-m`` as "no message + supplied" and falls back to reading the message from stdin. Without + the devnull redirect the invocation inherited the caller's stdin — + a terminal or an open pipe under a harness, neither of which ever + reaches EOF — and the publish cycle hung forever with no output and + no error. The invocations that legitimately need stdin pass + ``input`` explicitly and keep their pipe. + """ + git_dir = tmp_path / ".git" + git_dir.mkdir() + + def answer_by_argv(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + stdout = { + ("rev-parse", "--git-dir"): str(git_dir), + ("hash-object", "-w", "--stdin"): "<blob>", + ("write-tree",): "<tree>", + ("commit-tree", "<tree>", "-p", "<base>", "-m", ""): "<commit>", + }.get(tuple(command[1:]), "") + return subprocess.CompletedProcess(args=command, returncode=0, stdout=stdout, stderr="") + + run = mock.Mock(side_effect=answer_by_argv) + with mock.patch("goga.topics.git.publish.subprocess.run", run): + commit = commit_file_on_base("<base>", _TITLE_PATH, _TITLE_CONTENT, "") + + assert commit == "<commit>" + for call in run.call_args_list: + if call.kwargs.get("input") is None: + assert call.kwargs["stdin"] == subprocess.DEVNULL + else: + # The explicit ``input`` routes the invocation through a + # pipe — a second stdin would make ``subprocess.run`` raise. + assert call.kwargs["stdin"] is None + # The empty message reached git verbatim — no error, no substitution. + assert run.call_args_list[-1].args[0][-1] == "" + class TestBranchAndPushMutations: def test_create_branch_at_commit_creates_ref_without_switch(self) -> None: From 2e251a1564e3c2f99ac0755bd1ae3e67a137f355 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Sun, 30 Aug 2026 20:24:43 +0000 Subject: [PATCH 129/229] docs: align topics CODEMANIFEST guarantees with the implementation Acceptance review of the topics create --publish feature found three annotation-precision gaps where the manifest lagged behind a correct implementation: - read_ref_tree_paths: the repository-root anchoring introduced by --full-name and the :/ pathspec magic was a load-bearing guarantee for the occupancy oracle but absent from the manifest Requirements - resolve_ref_commit: annotated tags are peeled to their commit, so a tag is a valid --base-ref - check_slug_occupancy: the trailing slash of the topic directory prefix keeps a sibling slug sharing only prefix text free No code changes were required. --- goga/topics/CODEMANIFEST | 4 +++- goga/topics/git/CODEMANIFEST | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/goga/topics/CODEMANIFEST b/goga/topics/CODEMANIFEST index 41ba44a6..490b6feb 100644 --- a/goga/topics/CODEMANIFEST +++ b/goga/topics/CODEMANIFEST @@ -517,7 +517,9 @@ Annotations: | 1. Resolve the year — `year` when given, otherwise the current year via `current_year` 2. Compose the topic directory prefix of the slug under the root - resolved via `resolve_history_root` + resolved via `resolve_history_root` — the prefix carries a trailing + slash, so a sibling slug sharing only the prefix text + ("feature-foo" of "feature-foo-bar") stays free 3. Probe every ref of `list_branch_refs` via `read_ref_tree_paths` — the first ref whose tree carries paths under the prefix is the conflict diff --git a/goga/topics/git/CODEMANIFEST b/goga/topics/git/CODEMANIFEST index 2b8f9a96..bba42280 100644 --- a/goga/topics/git/CODEMANIFEST +++ b/goga/topics/git/CODEMANIFEST @@ -114,6 +114,9 @@ Annotations: | - One git invocation per ref - Read-only — the working copy, the index, and .git stay untouched - A ref or prefix without matches yields an empty list — not an error + - Both sides of the read are anchored at the repository root — the + invocation reads the same tree and reports the same paths from any + working directory inside the repository Constraints: - Do not materialize the tree — no checkout, no worktree, no temp @@ -252,7 +255,9 @@ Annotations: | `ref`: any revision string — a branch name, a remote-tracking ref, a tag, or a commit hash, resolved as git resolves it - `commit`: the commit hash the revision names + `commit`: the commit hash the revision names — an annotated tag is + peeled to its commit, so the hash is usable as the parent of + a commit-tree build Apply the `git` practice for the invocation pattern. Apply the `convention` practice for docstring style and intra-package From a549cdba3ee5b041ea3de6750991f2037f6e6f0d Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 03:47:14 +0000 Subject: [PATCH 130/229] chore: drop the review_executor patience override Return build.review_executor to the default patience (0, disabled) so the external review runs until it converges instead of stopping after two unchanged rounds. --- .goga/config.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.goga/config.yml b/.goga/config.yml index 5c019e76..79e97b85 100644 --- a/.goga/config.yml +++ b/.goga/config.yml @@ -16,7 +16,6 @@ build: review_executor: agent: claude base_ref: release/1.3.0 - patience: 2 env: <<: *claude-env ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3[1m]" From b8c989cb744105444bcfc7794f51af581e03c0f0 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 03:47:26 +0000 Subject: [PATCH 131/229] feat: declare the hooks domain contracts and retire register_topic_statuses Contract layer of the domain-hooks feature: tool packages now extend goga domains through register_hooks(hooks) and named subscriptions instead of the dedicated register_topic_statuses callback. - new cells goga/hooks (catalog, dispatch, registry, tools) with the registering-hooks and declaring-actions practices - new cell goga/commands/hooks with the hooks-command practice for the goga hooks inspection command; the commands facade imports it - goga/history/statuses: the cell keeps the scale semantics and emits the status action; registration happens through the registry delivered to each subscribed tool's hook - goga/commands/tool: the facade-callback note now names register_hooks alongside main and install - docs: the goga hooks CLI reference, the Domain extensions section in docs/tools.md with the migration note, mkdocs nav and CLI index rows The register_topic_statuses callback is removed from the contracts; packages still carrying it lose their statuses silently after the update. --- docs/cli/hooks.md | 70 ++++++ docs/cli/index.md | 1 + docs/tools.md | 66 ++++-- goga/commands/CODEMANIFEST | 9 + goga/commands/hooks/.usages/hooks-command.md | 30 +++ goga/commands/hooks/CODEMANIFEST | 100 +++++++++ goga/commands/tool/CODEMANIFEST | 2 +- goga/history/.usages/registering-statuses.md | 47 ++-- goga/history/statuses/CODEMANIFEST | 91 ++++---- goga/hooks/.usages/declaring-actions.md | 39 ++++ goga/hooks/.usages/registering-hooks.md | 65 ++++++ goga/hooks/CODEMANIFEST | 45 ++++ goga/hooks/catalog/CODEMANIFEST | 81 +++++++ goga/hooks/dispatch/CODEMANIFEST | 141 ++++++++++++ goga/hooks/registry/CODEMANIFEST | 154 +++++++++++++ goga/hooks/tools/CODEMANIFEST | 225 +++++++++++++++++++ mkdocs.yml | 1 + 17 files changed, 1081 insertions(+), 86 deletions(-) create mode 100644 docs/cli/hooks.md create mode 100644 goga/commands/hooks/.usages/hooks-command.md create mode 100644 goga/commands/hooks/CODEMANIFEST create mode 100644 goga/hooks/.usages/declaring-actions.md create mode 100644 goga/hooks/.usages/registering-hooks.md create mode 100644 goga/hooks/CODEMANIFEST create mode 100644 goga/hooks/catalog/CODEMANIFEST create mode 100644 goga/hooks/dispatch/CODEMANIFEST create mode 100644 goga/hooks/registry/CODEMANIFEST create mode 100644 goga/hooks/tools/CODEMANIFEST diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md new file mode 100644 index 00000000..04e41e2b --- /dev/null +++ b/docs/cli/hooks.md @@ -0,0 +1,70 @@ +# goga hooks + +Inspect the hooks registered by the installed tool packages. + +## Synopsis + +```bash +goga hooks [--tool NAME]... +``` + +## Description + +`goga hooks` assembles the run registry once — it imports every installed `goga_tool_*` package and runs its `register_hooks` callback — and prints the registrations as a tree: tool, then domain, then action. It states the **fact of registration**, never whether a hook ran in a particular command. See [Tools — Domain extensions](../tools.md#domain-extensions--register_hooks) for the registration contract. + +## The tree + +```text +$ goga hooks +mkdocs + statuses + register_statuses published + register_statuses sync +scriba + statuses + register_statuses published + rejected statuses/register_statuses "dup": repeated name on the same address +``` + +- One tool line per tool with registrations — the tool identity, the package name with the `goga_tool_` prefix dropped and underscores turned into hyphens. There is no root line: the tools are the top level. +- Under a tool, one domain line per distinct domain of its subscriptions, ordered alphabetically. +- Under a domain, one line per subscription — the action name and the hook name. +- Every refused registration prints with its reason. +- A tool with no subscriptions and no refusals prints its line alone. +- An empty registry prints nothing and exits `0`. + +## The slice + +`-t`/`--tool` narrows the tree to the named tools. The option is repeatable; the name is the tool identity — without the `goga_tool_` prefix — as the tool line of the tree shows it. A requested tool without registrations keeps its entry with an empty list; an unknown name is not an error. + +```bash +goga hooks --tool my-tool +goga hooks -t my-tool -t other-tool +``` + +## Behavior + +- The registry assembles once at the start of the command; it is never cached between runs — package edits apply from the next run, without reinstall. +- A broken package import fails the command with a clean error naming the package (stderr, exit `1`, no traceback). +- Commands that use no hooks never build the registry and never enumerate the packages; only this command and the hook checkpoints do. + +## Exit Codes + +| Code | Meaning | +|------|--------------------------------------| +| `0` | Success — an empty registry included | +| `1` | Error — a broken package import | + +## Examples + +List every registered hook of the environment: + +```bash +goga hooks +``` + +Inspect one tool only: + +```bash +goga hooks --tool mkdocs +``` diff --git a/docs/cli/index.md b/docs/cli/index.md index 6908a1dc..3ad27939 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -39,6 +39,7 @@ python -m goga --help | [`goga history`](history.md) | Work with the `.goga/history/` tree (`list`, `status`, `path`, `ensure`, `prune`) | | [`goga topics`](topics.md) | Work with the topics of one year (`status` board, `create`, `switch`) | | [`goga tool`](tool.md) | Dynamic tool package invocation | +| [`goga hooks`](hooks.md) | Inspect the hooks registered by installed tool packages | ## Global Options diff --git a/docs/tools.md b/docs/tools.md index ada12ba2..4b26025d 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -80,7 +80,7 @@ Each tool package follows a standard layout: ``` goga_tool_<name>/ -├── __init__.py # main(argv) entry point for CLI +├── __init__.py # main(argv) CLI entry; optional install()/register_hooks() ├── skills/ # Required — at least one skill │ └── <skill>/ │ └── SKILL.md # Agent skill definition @@ -95,6 +95,12 @@ A valid tool must: - Each skill directory must include a `SKILL.md` file - Expose a `main(argv: list[str])` function for CLI execution +A tool package may define three facade callbacks, separated by nature: +`main` (the CLI call of the tool — execution), `install` (the post-install +lifecycle hook), and `register_hooks` (the domain-extension registration, +see [Domain extensions](#domain-extensions)). `main` is required; the +other two are optional. + A tool **may** additionally expose an `install(user: str | None = None)` callable in its facade package — the post-install hook. `goga install` calls it after a successful pip, passing the initiating user (`SUDO_USER` when goga @@ -103,25 +109,6 @@ declared keyword-capable; otherwise the hook is called with no arguments. A missing or non-callable `install` is skipped quietly. See [`goga install` — Post-install hooks](cli/install.md#post-install-hooks). -A tool **may** also expose a `register_topic_statuses(statuses)` callable — -the topic-status hook. At every command start that computes topic statuses, -goga imports each installed `goga_tool_*` package and calls the callable -with a registry scoped to the package: - -```python -def register_topic_statuses(statuses): - statuses.register("published", "mkdocs/published.md", after="planned") -``` - -The entry's name is shown qualified as `<tool>.<name>` (here -`mkdocs.published`), its `filepath` is the artifact path relative to the -topic directory (nested paths allowed), and `before=`/`after=` anchor it to -an existing scale entry — at least one anchor is required, both define a -range. Built-in entries are immutable. A bad registration — an unknown -anchor, an invalid range, or a crashed callback — is skipped with a warning -on stderr and never aborts the command; only a package that fails to import -is fatal. See [Topics](cli/topics.md) for the status scale itself. - A `pipelines/` directory is **optional**. When present, `goga connect` copies its flat `*.yml` files into `~/.goga/pipelines/` **namespaced as `<tool>:<name>.yml`** (where `<tool>` is the package name with the @@ -134,6 +121,45 @@ same `--force-overwrite` semantics used for tool-skill installation. See [Pipelines / Shipped Pipelines](pipelines/shipped.md) for the full installation algorithm. +## Domain extensions + +A tool **may** expose a `register_hooks(hooks)` callable in its facade package — the registration of domain hooks. goga calls it when a command first reaches a hook checkpoint of the run, or when you inspect the registry with [`goga hooks`](cli/hooks.md); commands that use no hooks never call it. Registration is never cached — package edits apply from the next run, without reinstall. + +```python +# inside the goga_tool_<tool> package +def register_hooks(hooks): + hooks.subscribe("statuses", "register_statuses", "published", register_published) + + +def register_published(context): + context.register("published", "mkdocs/published.md", after="planned") +``` + +`hooks.subscribe(domain, action, name, hook)` registers one hook: + +- `domain` + `action` — the action address: the semantic owner domain and the action name within it (`"statuses"` / `"register_statuses"` is the topic-status action). +- `name` — the hook name, unique per tool per address; registrations are shown as `<tool>.<name>`. +- `hook` — the callable executed when the action fires. + +The tool identity is assigned by goga from the package name — a package never names itself, and identical hook names of different tools never collide. Enumeration is deterministic: packages in alphabetical order of top-level module name, subscriptions delivered in enumeration order. + +### The hook signature + +A hook receives values only for the parameters it declares by the fixed offered names — `context` and `self`: + +- `context` — the delivered object of the action. Read attributes and call methods freely; attribute assignment is blocked. What the object carries is fixed by the owner domain's contract — for `register_statuses` it is the status registration surface (`register(name, filepath, before=..., after=...)`, names stored qualified `<tool>.<name>`; see [Topics](cli/topics.md) for the scale rules). +- `self` — the isolated context of your tool. One instance links all its hook invocations of a run; freely mutable by your tool, invisible to the domains. + +The declaration order does not matter; names you did not declare receive nothing. + +### Error classes and diagnostics + +Each action in the catalog fixes how a failing hook is treated. The topic-status action is **soft**: a failing hook is skipped with a stderr warning naming the tool, the action, and the reason, and the command continues. A **hard** action stops the command at the first failing hook with a clean error — the class is chosen by the owner domain when it declares the action. + +At registration: a wrong address, an empty name, or a repeated name on the same address is refused with a stderr warning naming the tool, the action, and the reason — the registration is skipped, the rest apply. A crashing callback is a warning; the registrations made before the crash survive. A broken package import is the only fatal case: a clean error naming the package. + +> **Migration note.** The old `register_topic_statuses(statuses)` callback is gone. After a goga update, a package still carrying it loses its statuses **without any diagnostic** — they silently disappear from the scale. Moving to `register_hooks` is the package author's responsibility. + ## Optional injections `main` may optionally declare a keyword-capable `ast` parameter to receive the diff --git a/goga/commands/CODEMANIFEST b/goga/commands/CODEMANIFEST index 554e7045..ed4433f6 100644 --- a/goga/commands/CODEMANIFEST +++ b/goga/commands/CODEMANIFEST @@ -51,6 +51,11 @@ Imports: Usages: - topics-command From: goga/commands/topics + - Types: + - hooks + Usages: + - hooks-command + From: goga/commands/hooks Usages: convention: .goga/usages/conventions.md @@ -81,6 +86,9 @@ Annotations: | command group: the board table, the creation flow, the switching flow, and the exit codes. + Use the `hooks-command` practice for consumer scenarios of the hooks + command: the registry tree, the tool slice, and the exit codes. + --- ->lint: {} @@ -98,6 +106,7 @@ Annotations: | ->uninstall: {} ->history: {} ->topics: {} +->hooks: {} --- diff --git a/goga/commands/hooks/.usages/hooks-command.md b/goga/commands/hooks/.usages/hooks-command.md new file mode 100644 index 00000000..cabaef03 --- /dev/null +++ b/goga/commands/hooks/.usages/hooks-command.md @@ -0,0 +1,30 @@ +# commands — the hooks command + +How to inspect the hooks registered by the installed tool packages. For +goga users; the command reads the registry and prints it. + +## The tree + + goga hooks + +One tool line per tool with registrations; under a tool, one domain line +per domain; under a domain, the actions the tool subscribed to. A refused +registration prints its reason. A tool without registrations prints its +line alone. No root line. + +## The slice + + goga hooks --tool my-tool + goga hooks -t my-tool -t other-tool + +The repeatable option narrows the tree to the named tools — the name +without the goga_tool_ prefix, as the tool line of the tree shows it. +A requested tool without registrations shows its empty entry. + +## Behavior + +- The command assembles the registry once at start; a broken package + import fails the command with the package name. +- The view states the fact of registration, not whether a hook ran in a + particular command. +- An empty result prints nothing and exits 0. diff --git a/goga/commands/hooks/CODEMANIFEST b/goga/commands/hooks/CODEMANIFEST new file mode 100644 index 00000000..60fa37cc --- /dev/null +++ b/goga/commands/hooks/CODEMANIFEST @@ -0,0 +1,100 @@ +Imports: + - Types: + - HookRegistry + - ToolHooks + From: goga/hooks + +Usages: + convention: .goga/usages/conventions.md + click: .goga/usages/cooks/click.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + Use the `click` practice to build the command: the top-level command + registration in the root application group, the repeatable --tool/-t + option via multiple=True on a single Option, echo, and exit-code + propagation. + + This cell is the CLI surface of the hooks inspection: a thin wrapper + that creates the run registry, assembles it once, slices it by tool, + and renders the tree. No registry computation, no delivery, no action + emission live here. A broken package import surfaces as a clean CLI + error — stderr, non-zero exit, no traceback. Use relative imports. + +--- + +"hooks(tools: tuple[str, ...]) -> exit_code: int": + location: hooks.py + annotations: | + The goga hooks command — the inspection of the hooks registered by + the installed tool packages. Exported via __all__ and registered in + the root application group. + + `tools`: --tool/-t values (repeatable) — the tool names of the slice, + without the package prefix; empty means every tool + `exit_code`: 0 on success (an empty view included), 1 on error + + Apply the `convention` CLI command docstring rule for the --help text + (rendered verbatim by Click; omit Args/Returns/Raises). + Use the `click` practice for the repeatable option and the color + rules. + + Algorithm: + 1. Create the run registry via `HookRegistry` and assemble it via its + build_once method + 2. Read the per-tool view via the by_tool method + 3. A non-empty `tools` keeps the entries of the named tools — a + requested name without registrations keeps its entry with an empty + list, not an error + 4. Render the view via `render_hooks_tree` + 5. An empty view renders nothing — exit 0 + + Requirements: + - A broken package import surfaces as a clean CLI error naming the + package + - The command shows the fact of registration — never the application + of a hook in some command + + Constraints: + - Do not emit any action here — the inspection reads the registry only + +"render_hooks_tree(view: list[ToolHooks])": + location: render.py + annotations: | + Render the registry view as the tool tree — tool, then domain, then + action, with the refused registrations and their reasons. + + `view`: the per-tool entries — the already sliced set + + Use the `click` practice for echo and the color rules. + + Algorithm: + 1. Print one tool line per entry — the tool identity + 2. Under a tool, print one domain line per distinct domain of its + subscriptions, ordered alphabetically + 3. Under a domain, print one action line per subscription of that + domain + 4. Print every refused registration of the tool with its reason + 5. An empty `view` prints nothing + + Requirements: + - The tree carries no root line — the tools are the top level + - A tool without subscriptions and without refusals prints its line + alone + + Constraints: + - Read-only on `view` — do not mutate, do not re-sort the tool entries + +--- + +Author: Goga +CreatedAt: 31/08/26 +Description: | + The goga hooks command — the inspection of the hooks registered by the + installed tool packages. diff --git a/goga/commands/tool/CODEMANIFEST b/goga/commands/tool/CODEMANIFEST index ca5c54f4..77d7677a 100644 --- a/goga/commands/tool/CODEMANIFEST +++ b/goga/commands/tool/CODEMANIFEST @@ -17,7 +17,7 @@ Annotations: | Apply `click` to implement the CLI command. The dispatcher resolves the tool package and forwards captured arguments together with the optional injections the tool entry point declares. Use the `registering-statuses` practice for the topic-status registration - callback a tool package may expose alongside its entry point. + a tool package performs alongside its entry point. --- diff --git a/goga/history/.usages/registering-statuses.md b/goga/history/.usages/registering-statuses.md index c3b8e3e8..c3ba598d 100644 --- a/goga/history/.usages/registering-statuses.md +++ b/goga/history/.usages/registering-statuses.md @@ -3,36 +3,39 @@ How a `goga_tool_*` package attaches its own statuses to the topic status scale. For tool package authors; no goga code changes are needed. -goga calls `register_topic_statuses(statuses)` in your package at every -command start that computes a topic status. The `statuses` object is a -controlled registration surface scoped to your package: every name you -register is stored qualified with your tool prefix, so registrations from -different tools never collide and a topic can carry several statuses at -once. - -## The callback +Subscribe a hook to the status action of the statuses domain inside your +`register_hooks` callback: ```python # inside the goga_tool_<tool> package -def register_topic_statuses(statuses): - statuses.register("published", "mkdocs/published.md", after="planned") +def register_hooks(hooks): + hooks.subscribe("statuses", "register_statuses", "published", register_published) + + +def register_published(context): + context.register("published", "mkdocs/published.md", after="planned") ``` -- `name` — the status name as your tool defines it; shown as - `<tool>.<name>`. -- `filepath` — the artifact path relative to the topic directory; nested - paths are allowed. -- `before` / `after` — anchors: qualified names of statuses this one - precedes or follows, controlling where it sits in the scale. At least one - anchor is required; both given define a placement range. +The hook receives `context` — the registration surface scoped to your +tool. Every name you register is stored qualified with your tool prefix, +so registrations from different tools never collide and a topic can carry +several statuses at once. A hook may also declare `self` — the isolated +context of your tool; one instance links all its hook invocations of a +run, freely mutable, invisible to the domains. ## Rules and failure behavior - The built-in statuses are immutable — registration is add-only. -- A registration missing an anchor, carrying empty values, an unresolvable - anchor, or an invalid range is skipped with a stderr warning; it never - aborts the command and never cancels other registrations. +- `name` — the status name as your tool defines it; shown as + `<tool>.<name>`. +- `filepath` — the artifact path relative to the topic directory; nested + paths allowed. +- `before` / `after` — anchors: qualified names of statuses this one + precedes or follows; at least one anchor is required, both given define + a placement range. +- A registration missing an anchor, carrying empty values, an + unresolvable anchor, or an invalid range is skipped with a stderr + warning; it never aborts the command and never cancels other + registrations. - Two tools may reference the same artifact path — both statuses apply independently. -- A package import failure is the only fatal case: a clean error naming the - package. diff --git a/goga/history/statuses/CODEMANIFEST b/goga/history/statuses/CODEMANIFEST index c93ad8c5..4911a127 100644 --- a/goga/history/statuses/CODEMANIFEST +++ b/goga/history/statuses/CODEMANIFEST @@ -1,13 +1,15 @@ +Imports: + - Types: + - HookRegistry + - emit_hook_event + - declared_actions + Usages: + - registering-hooks + - declaring-actions + From: goga/hooks + Usages: convention: .goga/usages/conventions.md - registration: | - The tool-package registration contract. An installed goga_tool_* package - may expose a module-level register_topic_statuses(statuses) callable; - when present, goga calls it at every scale assembly with a - StatusRegistry scoped to the package. The callable registers tool - statuses via statuses.register(name, filepath, before=..., after=...); - names are stored qualified <tool>.<name>. A package without the - callable is a normal condition, not an error. Annotations: | The `convention` practice is used for: @@ -17,16 +19,18 @@ Annotations: | - Organizing the test infrastructure - Understanding the general principles and rules of development and testing in the project - Use the `registration` practice for the tool-package registration - contract behind the scale assembly. - This cell owns the topic status scale: the built-in artifact axis, the - registration of tool statuses over installed goga_tool_* packages, and the - computation of a topic's maximal present statuses. Pure scale logic — no - filesystem probing of topic directories, no git access, no CLI, no output - rendering. The built-in axis is immutable; tool extensions are add-only. - Package traversal is deterministic — installed goga_tool_* packages in - alphabetical order of top-level module name. Use relative imports. + semantics of tool-status registration — what a registration may carry, + the anchoring and the add-only rule over the immutable built-in axis — + and the computation of a topic's maximal present statuses. Tool packages + reach the scale through the status action: the cell emits the action and + each subscribed tool registers through the registry delivered to its + hook. Pure scale logic — no package enumeration, no facade imports, no + callback invocation, no filesystem probing of topic directories, no git + access, no CLI, no output rendering. Use the `registering-hooks` + practice for the tool-facing registration contract behind the action. + Use the `declaring-actions` practice for the emission contract of the + action. Use relative imports. --- @@ -135,11 +139,13 @@ Annotations: | location: registry.py annotations: | The controlled registration surface handed to a tool package — the only - way a tool status enters the scale. + way a tool status enters the scale. One registry instance is the context + view delivered to the hook of one subscribed tool; the hook registers + through it and nothing else. `builtin_stages`: the immutable built-in axis the registry extends `tool_prefix`: the qualifier applied to every name registered through - this registry — derived from the package name + this registry — the tool identity of the receiving hook Apply the `convention` practice for the data-model rules and intra-package imports. @@ -188,50 +194,49 @@ Annotations: | location: assembly.py annotations: | Assemble the full status scale — the built-in axis extended by every - installed tool package. + tool subscribed to the status action. `scale`: the assembled scale Apply the `convention` practice for docstring style and intra-package imports. + Use the `declaring-actions` practice for the emission contract. + Use the `registering-hooks` practice for the registration contract + behind the action. Algorithm: 1. Build the built-in axis of nine entries - 2. Enumerate the installed goga_tool_* packages in alphabetical order - of package name - 3. Import each package — a broken import is a clean error naming the - package - 4. A package without the callback of the `registration` practice is - skipped silently - 5. Call the callback of the `registration` practice with a registry - scoped to the package - 6. Any exception from the callback — a registration content error or a - crashed callback — ends that callback's registration with a warning - to stderr, keeping the entries it registered before failing; the - package import failure of step 3 remains the only fatal case - 7. Resolve the anchors of each surviving entry against the list + 2. Create the run registry via `HookRegistry` + 3. Emit the status action — the address resolved against + `declared_actions` — via `emit_hook_event`: the context view of one + receiving tool is a `StatusRegistry` over the axis, qualified by + the tool identity — at most one `StatusRegistry` per tool identity, + all hooks of the tool share it; a hook registers its statuses + through the delivered registry + 4. Collect the registries of the subscribed tools in enumeration order + 5. Resolve the anchors of each surviving entry against the list assembled by the moment the entry is processed — the built-in axis - plus the entries of the earlier packages and the earlier entries of + plus the entries of the earlier tools and the earlier entries of the current one; an anchor naming anything else, or an invalid placement range, skips the registration with a warning to stderr - 8. Assemble and return the scale + 6. Assemble and return the scale Requirements: - - The assembly runs at every command start that needs the scale — - before any output and before any mutation - - The scale assembles from the surviving registrations alone — one - broken registration never cancels the rest + - The emission assembles the registry on first use — the single build + of the run + - The scale assembles on the caller's demand at most once per run — + every command that renders statuses calls it before any output and + before any state change + - A failing or rejected registration never cancels the others - Entries sharing an anchor form one continuous block in registration order: an after-anchored entry lands at the end of its anchor's block, a before-anchored entry right in front of its anchor, and both anchors given define a range the entry must fit into - - Package enumeration mirrors goga/connect: importlib.metadata - .packages_distributions() filtered to top-level module names starting - with goga_tool_, sorted alphabetically by top-level module name Constraints: + - Do not enumerate the installed tool packages and do not import their + facades — the platform carries the tool packages - Do not cache the scale across command runs - - Do not let a registration problem abort the command --- diff --git a/goga/hooks/.usages/declaring-actions.md b/goga/hooks/.usages/declaring-actions.md new file mode 100644 index 00000000..c891fb4e --- /dev/null +++ b/goga/hooks/.usages/declaring-actions.md @@ -0,0 +1,39 @@ +# hooks — opening a domain action + +How a goga domain maintainer opens an extension point for installed tool +packages. For domain maintainers inside goga. + +## Declare the action + +Add one record to the action catalog — the domain, the action name, and +the error class: + +- `soft` — a failing hook is skipped with a stderr warning; the command + continues. +- `hard` — the first failing hook stops the command with a clean error. + +## Define the context contract + +The action's context is your own object. Its members are the single +channel tools have into your domain — publish the surface you want tools +to call, and state in your contract what each member does and when the +event fires. When tools write through the context, give each receiving +tool its own view of it — the emission builds the view per tool. + +## Emit at the checkpoint + +```python +emit_hook_event(HookRegistry(), "<domain>", "<action>", context_for=build_view) +``` + +- The emission assembles the registry on first use — the single build of + the run; there is no separate build step. +- `context_for` takes a tool identity and returns the object that tool's + hooks receive — return the same instance to share, a scoped view to + isolate. + +## What the platform carries for you + +Package enumeration, facade imports, the callback call, envelope +validation, delivery, injection, error classes, and diagnostics — your +domain declares the action and emits it; nothing else. diff --git a/goga/hooks/.usages/registering-hooks.md b/goga/hooks/.usages/registering-hooks.md new file mode 100644 index 00000000..1ed4c025 --- /dev/null +++ b/goga/hooks/.usages/registering-hooks.md @@ -0,0 +1,65 @@ +# hooks — registering domain hooks + +How a `goga_tool_*` package subscribes its hooks to the actions of goga +domains. For tool package authors; no goga code changes are needed. + +A package may define three facade callbacks, separated by nature: `main` +(the CLI call of the tool), `install` (the post-install lifecycle hook), +and `register_hooks` (the domain extension registration described here). + +## The callback + +```python +# inside the goga_tool_<tool> package +def register_hooks(hooks): + hooks.subscribe("statuses", "register_statuses", "my_status", register_my_status) +``` + +- `domain` — the semantic owner of the action; with the action name it is + the address of the subscription. +- `action` — the action name within the domain. +- `name` — the hook name, unique per tool per address. +- `hook` — the callable executed when the action fires. + +The tool identity is assigned by goga from the package name — a package +never names itself, and identical hook names of different tools never +collide. + +## The hook signature + +A hook receives values only for the parameters it declares by the fixed +offered names: + +```python +def register_my_status(self, context): + context.register("published", "mkdocs/published.md", after="planned") +``` + +- `self` — the isolated context of your tool; one instance links all its + hook invocations of a run; freely mutable by your tool. +- `context` — the delivered object of the action; read attributes and + call methods freely, attribute assignment is blocked. + +The declaration order does not matter; names you did not declare receive +nothing. + +## When hooks run + +goga calls `register_hooks` when a command first reaches a hook +checkpoint of the run — or when you inspect the registry with +`goga hooks`. Commands that use no hooks never call it. Registration is +never cached — package edits apply from the next run, without reinstall. + +## Failure behavior + +- A wrong address, an empty name, or a repeated name on the same address — + a stderr warning naming your tool, the action, and the reason; the + registration is skipped, the rest apply. +- A crashing callback — a warning; the registrations made before the + crash survive. +- A failing hook — a warning, the hook is skipped, the sequence continues + (soft action); or a command error stopping at the first failure (hard + action). The action's error class is fixed by the domain in the action + catalog. +- A broken package import is the only fatal case: a clean error naming + the package. diff --git a/goga/hooks/CODEMANIFEST b/goga/hooks/CODEMANIFEST new file mode 100644 index 00000000..17e467ba --- /dev/null +++ b/goga/hooks/CODEMANIFEST @@ -0,0 +1,45 @@ +Imports: + - Types: + - declared_actions + From: goga/hooks/catalog + - Types: + - HookRegistry + - ToolHooks + From: goga/hooks/registry + - Types: + - emit_hook_event + From: goga/hooks/dispatch + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell is the facade of the hooks platform — the extension surface of + the goga domains for installed tool packages. It exposes the platform + API: the declared action catalog, the run registry with its inspection + view, and the emission of an action at a domain checkpoint — the emission + assembles the registry on first use. Consumers address the platform + through this facade only. Apply the `convention` practice for the code + style and intra-package imports. Use relative imports. + +--- + +->declared_actions: {} +->HookRegistry: {} +->ToolHooks: {} +->emit_hook_event: {} + +--- + +Author: Goga +CreatedAt: 31/08/26 +Description: | + Facade of the hooks platform — the extension surface of the goga domains + for installed tool packages. diff --git a/goga/hooks/catalog/CODEMANIFEST b/goga/hooks/catalog/CODEMANIFEST new file mode 100644 index 00000000..aebdf3dd --- /dev/null +++ b/goga/hooks/catalog/CODEMANIFEST @@ -0,0 +1,81 @@ +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the action catalog — the map of subscription addresses of + the domains, each carrying its error class. The catalog is the single + source of known addresses: a registration envelope is validated against + it and an emitted address resolves through it. The catalog is data only — + no package enumeration, no subscription state, no delivery. A domain + opening an action extends the catalog additively; published records are + never rewritten. The error class of a record states how a failing hook of + the action is treated — soft failures are skipped with a warning, hard + failures stop the command. Use relative imports. + +--- + +"Action(domain: str, name: str, error_class: str)": + location: catalog.py + annotations: | + One catalog record — a named subscription address with its error class. + + `domain`: the semantic owner of the action — the domain whose + checkpoint emits the event + `name`: the action name within its domain + `error_class`: the failure treatment of the action's hooks — soft or hard + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - `domain` and `name` are non-empty; the pair is unique in the catalog + - `error_class` is exactly soft or hard + - The record carries no behavior — the context form and the event + moment belong to the contract of the owner domain + properties: + "domain -> str": | + The semantic owner domain of the action. + "name -> str": | + The action name within its domain. + "error_class -> str": | + The failure treatment of the action's hooks — soft or hard. + +"declared_actions() -> actions: list[Action]": + location: catalog.py + annotations: | + The declared action catalog — the single source of known addresses. + + `actions`: every declared record, ordered by domain then by name + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Return the catalog records ordered by domain, then by name + + Requirements: + - Deterministic — the same records in the same order on every call + - Complete — no filtering and no partial views + - The catalog carries the statuses action — the record + domain="statuses", name="register_statuses", error_class="soft": a + failing hook of the action is skipped with a warning and the command + continues + + Constraints: + - Do not derive records from installed packages or imports — the + catalog is maintained data, not discovery + +--- + +Author: Goga +CreatedAt: 31/08/26 +Description: | + Owner of the action catalog — the subscription addresses of the domains + with their error classes. diff --git a/goga/hooks/dispatch/CODEMANIFEST b/goga/hooks/dispatch/CODEMANIFEST new file mode 100644 index 00000000..fda721da --- /dev/null +++ b/goga/hooks/dispatch/CODEMANIFEST @@ -0,0 +1,141 @@ +Imports: + - Types: + - declared_actions + From: goga/hooks/catalog + - Types: + - HookRegistry + - Subscription + - ToolContext + From: goga/hooks/registry + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the delivery of events: the transparent mediation of the + emitted domain object, the injection of hook arguments by fixed declared + names, and the emission of an action to its subscribed hooks under the + action's error class. Delivery is fire-and-forget — the single channel a + tool has towards a domain is calling members of the delivered object; + nothing is collected after the event. Every diagnostic names the tool, + the action, and the reason. Use relative imports. + +--- + +"wrap_context(target: object) -> proxy: object": + location: delivery.py + annotations: | + Wrap an emitted domain object for delivery to a hook. + + `target`: the object the emitting checkpoint hands over + `proxy`: the delivery view of `target` + + Apply the `convention` practice for docstring style and intra-package + imports. + + Requirements: + - Attribute reads pass through — the proxy resolves them on `target` + - A property member of `target` reads through like any attribute + - Method calls pass through — a called member runs on `target` with the + arguments the caller gave + - Attribute assignment is blocked — a clean error, `target` stays + untouched + - The proxy exposes no reference to `target` + + Constraints: + - Do not copy or reinterpret the object — mediation is transparent for + reads and calls + - Do not mediate special methods — the proxy intercepts plain attribute + access and calls only; dunder lookup on the proxy itself follows the + language default and never reaches `target` + +"build_hook_arguments(hook: Callable, context: object, self_context: ToolContext) -> args: dict[str, object]": + location: delivery.py + annotations: | + Project a hook signature against the offered injection names. + + `hook`: the registered callable + `context`: the delivery view of the emitted object + `self_context`: the isolated context of the hook's own tool + `args`: the keyword arguments to call `hook` with + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Examine the signature of `hook` and enumerate its keyword-capable + parameters + 2. A declared parameter named `context` receives `context`; a declared + parameter named self receives `self_context` + 3. Parameters with other names receive nothing — the offered-name set is + the single source of the opt-in + 4. No declared offered name yields an empty mapping + + Requirements: + - The declaration order does not matter — values land by name + - The mapping never carries a value the hook did not declare + + Constraints: + - Do not call `hook` — the call belongs to the emission + +"emit_hook_event(registry: HookRegistry, domain: str, action: str, context_for: Callable)": + location: emit.py + annotations: | + Emit an action of a domain checkpoint to its subscribed hooks. + + `registry`: the run registry — assembled on first use + `domain`: the emitting domain — the semantic owner of the action + `action`: the action name within the domain + `context_for`: builds the context view of one receiving tool — takes the + tool identity, returns the object that tool's hooks receive + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Assemble the registry via its build_once method — the first + emission of a run performs the single build + 2. Resolve `domain` and `action` against `declared_actions` — an unknown + address is a clean error of the emitting side + 3. Take the `Subscription` entries of the address from `registry`, in + enumeration order + 4. For each subscription, in enumeration order: resolve the receiving + tool's context view via `context_for` — one view per distinct tool + of the address, built at the tool's first subscription and reused + by its remaining subscriptions; wrap it via `wrap_context`, project + the call arguments via `build_hook_arguments` with the tool's own + context from the registry, and call the hook + 5. Treat a failure per the action's error class: soft — a warning on + stderr naming the tool, the action, and the reason, the hook is + skipped, the sequence continues; hard — a clean error naming the tool + and the reason, the sequence stops at the first failure + 6. An address without subscriptions emits nothing + + Requirements: + - Fire-and-forget — nothing is returned and nothing is collected after + the event + - Each tool receives the proxy of its own context view — tools never + share a mutable object + - An exception leaving a hook or the projection is a hook failure and + nothing else + - A failure of `context_for` is a clean error of the emitting side — + never treated as a hook failure + + Constraints: + - Do not rebuild an assembled registry — the build runs once per run + - Do not deliver a hook any value it did not declare + +--- + +Author: Goga +CreatedAt: 31/08/26 +Description: | + Owner of the event delivery — the transparent context mediation, the + fixed-name injection, and the emission under the action's error class. diff --git a/goga/hooks/registry/CODEMANIFEST b/goga/hooks/registry/CODEMANIFEST new file mode 100644 index 00000000..97471238 --- /dev/null +++ b/goga/hooks/registry/CODEMANIFEST @@ -0,0 +1,154 @@ +Imports: + - Types: + - ToolPackage + - enumerate_tool_packages + - call_register_hooks + - HookRegistrar + - Subscription + - RejectedRegistration + From: goga/hooks/tools + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the run registry: the single assembly of one run, the + read side of the assembled subscriptions, the isolated per-tool + contexts, and the per-tool inspection view. The assembly happens once — + on the first build_once call — and every diagnostic names the tool and + the reason. Pure state and read logic — no package access and no + delivery; those belong to the other zones of the platform. Use relative + imports. + +--- + +"HookRegistry()": + location: state.py + annotations: | + The run registry — the assembled state of one run, built on first use. + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Created empty and cheap — no enumeration and no imports happen at + construction + properties: + "subscriptions -> list[Subscription]": | + Every accepted subscription after the single build, in enumeration + order. + "rejections -> list[RejectedRegistration]": | + Every rejected envelope after the single build, in enumeration order. + methods: + "build_once()": | + Assemble the registry of the run — the single build. + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. An already assembled registry does nothing — the build runs once + per object, every run uses a fresh registry + 2. Enumerate the installed tool packages — `ToolPackage` identities — + via `enumerate_tool_packages` + 3. For each package in order: create a registration surface + qualified by the package identity via `HookRegistrar` and run its + callback via `call_register_hooks` + 4. A package without the callback is skipped quietly + 5. An exception raised by a callback ends that callback's + registration: a warning on stderr naming the tool and the reason, + the registrations made before the failure survive, the next + package is processed + 6. A broken package import is a clean error naming the package — the + single fatal case + 7. Collect the accepted `Subscription` entries and the + `RejectedRegistration` envelopes of every registrar into this + registry, in enumeration order + + Requirements: + - One tool's failure never cancels another tool's registrations + "subscriptions_for(domain: str, action: str) -> subscriptions: list[Subscription]": | + The subscriptions of one action address. + + `domain`: the owner domain of the action + `action`: the action name within the domain + `subscriptions`: the address's subscriptions, in enumeration order + + Algorithm: + 1. Keep the subscriptions whose domain and action match exactly + + Requirements: + - An address without subscriptions yields an empty list — not an error + "self_context(tool: str) -> context: ToolContext": | + The isolated runtime context of one tool. + + `tool`: the tool identity + `context`: the tool's own context instance + + Requirements: + - One instance per tool per run — repeated calls return the same + value, linking the tool's invocations + - Contexts of different tools never share state; a domain never + receives one + "by_tool() -> view: list[ToolHooks]": | + The per-tool inspection view of the registry. + + `view`: one entry per tool with a subscription or a rejection, + ordered alphabetically by tool + + Requirements: + - A tool with both accepted and rejected registrations carries both + - The view states the fact of registration — never the application + +"ToolContext(tool: str)": + location: state.py + annotations: | + The isolated runtime context of one tool — links the invocations of + its hooks within one run and stays invisible to the domains. + + `tool`: the environment-assigned tool identity of the owner + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Freely mutable by its own tool — the only state a hook may write + without restriction, unlike a delivered domain context + properties: + "tool -> str": | + The tool identity of the owning tool. + +"ToolHooks(tool: str, subscriptions: list[Subscription], rejections: list[RejectedRegistration])": + location: state.py + annotations: | + The per-tool inspection entry — one tool with its registrations and + refusals. + + `tool`: the tool identity + `subscriptions`: the tool's accepted subscriptions + `rejections`: the tool's refused envelopes + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "tool -> str": | + The tool identity of the entry. + "subscriptions -> list[Subscription]": | + The tool's accepted subscriptions. + "rejections -> list[RejectedRegistration]": | + The tool's refused envelopes. + +--- + +Author: Goga +CreatedAt: 31/08/26 +Description: | + Owner of the run registry — the single assembly, the read side, the + per-tool contexts, and the inspection view. diff --git a/goga/hooks/tools/CODEMANIFEST b/goga/hooks/tools/CODEMANIFEST new file mode 100644 index 00000000..bcc910a8 --- /dev/null +++ b/goga/hooks/tools/CODEMANIFEST @@ -0,0 +1,225 @@ +Imports: + - Types: + - declared_actions + From: goga/hooks/catalog + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the tool-facing surface of the platform: the identity of a + package derived from its pip name, the deterministic enumeration over the + environment, the facade import with the invocation of the single + registration callback, and the registration envelope with its + platform-side validation. Nothing else in the platform reaches the + packages or hands them a surface. A package runs at the trust level of + its installation — no isolation, no sandbox. The single fatal case of + the registry build lives here: a broken package import. Use relative + imports. + +--- + +"ToolPackage(module_name: str)": + location: packages.py + annotations: | + The identity of one installed tool package. + + `module_name`: the top-level module name of the installed package + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The tool identity is derived by dropping the goga_tool_ prefix and + turning underscores into hyphens — the canonical hyphen form of the + identity + - The facade is `module_name` verbatim — the importable facade module + - The identity is assigned by the environment — a package never names + itself + properties: + "tool -> str": | + The tool identity — the canonical hyphen form without the prefix. + "facade -> str": | + The importable name of the facade module. + +"enumerate_tool_packages() -> packages: list[ToolPackage]": + location: packages.py + annotations: | + Enumerate the installed tool packages of the environment. + + `packages`: one identity per installed package, in alphabetical order + of the top-level module name + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Read the installed distributions mapping of the environment + 2. Keep the top-level module names starting with the goga_tool_ + prefix + 3. Sort them alphabetically + 4. Wrap each into a package identity and return them + + Requirements: + - Deterministic — the same environment yields the same packages in the + same order on every run + - An environment without tool packages yields an empty list — not an + error + + Constraints: + - Do not import a package here — the facade import belongs to the + callback invocation + +"HookRegistrar(tool: str)": + location: registration.py + annotations: | + The controlled registration surface handed to one tool — the only way + a subscription enters the registry. Scoped to one tool identity: every + subscription made through the registrar is qualified by it. + + `tool`: the environment-assigned tool identity — the qualifier of every + registration made through this surface + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "tool -> str": | + The tool identity this surface qualifies registrations with. + "subscriptions -> list[Subscription]": | + The accepted subscriptions, in registration order. + "rejections -> list[RejectedRegistration]": | + The rejected envelopes, in attempted order. + methods: + "subscribe(domain: str, action: str, name: str, hook: Callable)": | + Register one hook subscription — the registration envelope. + + `domain`: the owner domain of the action + `action`: the action name within the domain + `name`: the hook name — unique per tool per address + `hook`: the callable executed when the action fires + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Resolve `domain` and `action` against `declared_actions` — an + unknown address is rejected + 2. Validate the envelope — a non-empty `name` and a callable `hook`; + a violation is rejected + 3. Reject a repeated registration of the same `name` on the same + address by this tool + 4. Every rejection is recorded and announced as a warning on stderr + naming the tool, the action, and the reason + 5. An accepted envelope appends one subscription + + Requirements: + - A rejected envelope never cancels the accepted subscriptions of the + same tool — partial registrations survive + - The registrar never raises on an invalid envelope — rejection is + data, not an exception + + Constraints: + - Do not call `hook` or inspect its signature — the delivery + projection belongs to the delivery zone + - Do not resolve the tool identity — it is assigned by the caller + +"Subscription(tool: str, domain: str, action: str, name: str, hook: Callable)": + location: registration.py + annotations: | + One accepted subscription — a hook bound to an action address, + qualified by a tool identity. + + `tool`: the tool identity that registered the subscription + `domain`: the owner domain of the subscribed action + `action`: the subscribed action name + `name`: the hook name within its tool + `hook`: the registered callable + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "tool -> str": | + The tool identity that registered the subscription. + "domain -> str": | + The owner domain of the subscribed action. + "action -> str": | + The subscribed action name. + "name -> str": | + The hook name within its tool. + "hook -> Callable": | + The registered callable. + +"RejectedRegistration(tool: str, domain: str, action: str, name: str, reason: str)": + location: registration.py + annotations: | + One rejected registration envelope with the reason of the refusal — the + data behind the inspection view of refused registrations. + + `tool`: the tool identity that attempted the registration + `domain`: the owner domain of the addressed action + `action`: the addressed action name + `name`: the attempted hook name — an empty string when the envelope + did not carry one + `reason`: the reason of the refusal + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "tool -> str": | + The tool identity that attempted the registration. + "domain -> str": | + The owner domain of the addressed action. + "action -> str": | + The addressed action name. + "name -> str": | + The attempted hook name — empty when the envelope did not carry one. + "reason -> str": | + The reason of the refusal. + +"call_register_hooks(package: ToolPackage, registrar: HookRegistrar) -> invoked: bool": + location: packages.py + annotations: | + Import the facade of one tool package and run its registration callback. + + `package`: the identity of the target package + `registrar`: the registration surface scoped to the package's tool + `invoked`: True when the callback ran, False on a quiet skip + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Import the facade module of `package` — a missing facade module is a + quiet skip + 2. A broken import of an existing package is a clean error naming the + package + 3. A registration callback on the facade is absent -> a quiet skip, + False + 4. Call the callback with `registrar` as its single argument -> True + + Requirements: + - A package without the callback is a normal condition — no warning and + no error + - The callback runs at the trust level of the installed package + - An exception raised by the callback propagates unchanged — the + isolation decision belongs to the caller + + Constraints: + - Do not pass any argument besides `registrar` — the callback contract + is a single registration surface + - Do not isolate or sandbox the callback + +--- + +Author: Goga +CreatedAt: 31/08/26 +Description: | + Owner of the tool-package access and registration — package identities, + enumeration, the facade callback, and the registration envelope. diff --git a/mkdocs.yml b/mkdocs.yml index 17a4d6bf..50c14c8d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -82,6 +82,7 @@ nav: - History: cli/history.md - Topics: cli/topics.md - Tool: cli/tool.md + - Hooks: cli/hooks.md - Architecture: - architecture/index.md - AST Nodes: architecture/ast-nodes.md From 29773aad718470c07cf36fc88ebe110a6e6052ee Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 03:51:00 +0000 Subject: [PATCH 132/229] feat: implement the hooks action catalog cell (Action, declared_actions) --- goga/hooks/catalog/__init__.py | 14 ++++ goga/hooks/catalog/catalog.py | 56 +++++++++++++++ tests/hooks/__init__.py | 1 + tests/hooks/catalog/__init__.py | 1 + tests/hooks/catalog/test_catalog.py | 108 ++++++++++++++++++++++++++++ 5 files changed, 180 insertions(+) create mode 100644 goga/hooks/catalog/__init__.py create mode 100644 goga/hooks/catalog/catalog.py create mode 100644 tests/hooks/__init__.py create mode 100644 tests/hooks/catalog/__init__.py create mode 100644 tests/hooks/catalog/test_catalog.py diff --git a/goga/hooks/catalog/__init__.py b/goga/hooks/catalog/__init__.py new file mode 100644 index 00000000..17529c3f --- /dev/null +++ b/goga/hooks/catalog/__init__.py @@ -0,0 +1,14 @@ +"""Action catalog cell — the map of subscription addresses of the domains. + +The owner of the action catalog: every known address with its error class. +The catalog is data only — a registration envelope is validated against it +and an emitted address resolves through it. Importing the package imports +no packages and enumerates nothing. +""" + +from .catalog import Action, declared_actions + +__all__: list[str] = [ + "Action", + "declared_actions", +] diff --git a/goga/hooks/catalog/catalog.py b/goga/hooks/catalog/catalog.py new file mode 100644 index 00000000..0f498cfd --- /dev/null +++ b/goga/hooks/catalog/catalog.py @@ -0,0 +1,56 @@ +"""The action catalog of the hooks platform. + +The entities declared in the cell CODEMANIFEST with ``location: catalog.py``: +the catalog record ``Action`` and the routine ``declared_actions``. The +catalog is the single source of known subscription addresses of the domains — +supported data only. No package enumeration, no subscription state, no +delivery; a domain opening an action extends the catalog additively and +published records are never rewritten. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, kw_only=True) +class Action: + """One catalog record — a named subscription address with its error class. + + The record carries no behavior: the context form and the event moment + belong to the contract of the owner domain. + + Attributes: + domain: The semantic owner domain of the action — the domain whose + checkpoint emits the event. + name: The action name within its domain. + error_class: The failure treatment of the action's hooks — soft or + hard. + + Requirements: + ``domain`` and ``name`` are non-empty; the pair is unique in the + catalog; ``error_class`` is exactly soft or hard. + """ + + domain: str + name: str + error_class: str + + +_DECLARED_ACTIONS: list[Action] = [ # supported data, not discovery + Action(domain="statuses", name="register_statuses", error_class="soft"), +] + + +def declared_actions() -> list[Action]: + """Return the declared action catalog — the single source of known addresses. + + Every declared record, ordered by domain then by name. A new list on + every call — the catalog constant is never mutated and its records are + frozen. + + Returns: + Every declared record, complete and unfiltered, ordered by domain + then by name. + """ + return sorted(_DECLARED_ACTIONS, key=lambda action: (action.domain, action.name)) diff --git a/tests/hooks/__init__.py b/tests/hooks/__init__.py new file mode 100644 index 00000000..9a161256 --- /dev/null +++ b/tests/hooks/__init__.py @@ -0,0 +1 @@ +"""Tests of the hooks platform cells — ``goga/hooks``.""" diff --git a/tests/hooks/catalog/__init__.py b/tests/hooks/catalog/__init__.py new file mode 100644 index 00000000..3dbe09b6 --- /dev/null +++ b/tests/hooks/catalog/__init__.py @@ -0,0 +1 @@ +"""Tests of the action catalog cell — ``goga/hooks/catalog``.""" diff --git a/tests/hooks/catalog/test_catalog.py b/tests/hooks/catalog/test_catalog.py new file mode 100644 index 00000000..887481f8 --- /dev/null +++ b/tests/hooks/catalog/test_catalog.py @@ -0,0 +1,108 @@ +"""Contract and logic tests for the entities declared in +``goga/hooks/catalog/CODEMANIFEST`` with ``location: catalog.py``: + +- ``Action(domain, name, error_class)`` — one catalog record, a named + subscription address with its error class +- ``declared_actions()`` — the declared action catalog, the single source of + known addresses + +Supported data only — no mocks: the catalog is maintained data, not +discovery over installed packages. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import typing + +import pytest +from goga.hooks.catalog import Action, declared_actions + +# --- Contract tests --- + + +class TestCatalogContract: + def test_entities_are_importable_from_the_package_facade(self) -> None: + """Both entities live on the cell package and its ``__all__`` is exact.""" + import goga.hooks.catalog as cell + + assert cell.Action is Action + assert cell.declared_actions is declared_actions + assert cell.__all__ == ["Action", "declared_actions"] + + def test_action_is_a_kw_only_frozen_dataclass(self) -> None: + """``Action(domain=..., name=..., error_class=...)`` — keyword-only, frozen.""" + action = Action(domain="statuses", name="register_statuses", error_class="soft") + + assert action.domain == "statuses" + assert action.name == "register_statuses" + assert action.error_class == "soft" + + assert dataclasses.is_dataclass(Action) + assert Action.__dataclass_params__.frozen + assert Action.__dataclass_params__.kw_only + + with pytest.raises(TypeError): + Action("statuses", "register_statuses", "soft") # type: ignore[misc] + + def test_action_assignment_raises_frozen_instance_error(self) -> None: + """A published record is never rewritten.""" + action = Action(domain="statuses", name="register_statuses", error_class="soft") + + with pytest.raises(dataclasses.FrozenInstanceError): + action.domain = "other" # type: ignore[misc] + + def test_action_carries_exactly_the_three_declared_fields(self) -> None: + """No computed properties, no extra state — the record is data only.""" + field_names = [field.name for field in dataclasses.fields(Action)] + + assert field_names == ["domain", "name", "error_class"] + + def test_declared_actions_signature(self) -> None: + """``declared_actions() -> list[Action]`` — no parameters.""" + parameters = inspect.signature(declared_actions).parameters + return_hint = typing.get_type_hints(declared_actions)["return"] + + assert list(parameters) == [] + assert return_hint == list[Action] + + +# --- Logic tests --- + + +class TestDeclaredActions: + def test_declared_actions_carries_the_statuses_action(self) -> None: + """The seed record — the statuses registration action, soft failures.""" + records = {(action.domain, action.name): action for action in declared_actions()} + + assert ("statuses", "register_statuses") in records + assert records[("statuses", "register_statuses")].error_class == "soft" + + def test_declared_actions_is_deterministic_and_complete(self) -> None: + """Same records in ``(domain, name)`` order on every call, unfiltered. + + A fresh list per call — mutating a returned list never reaches the + catalog constant. + """ + first = declared_actions() + second = declared_actions() + + assert first == second + assert first is not second + assert [(a.domain, a.name) for a in first] == sorted((a.domain, a.name) for a in first) + + first.clear() + + assert declared_actions() == second + assert first != second + + def test_declared_actions_records_are_well_formed(self) -> None: + """Non-empty addresses, valid error classes, unique pairs.""" + records = declared_actions() + pairs = [(action.domain, action.name) for action in records] + + assert all(action.domain for action in records) + assert all(action.name for action in records) + assert {action.error_class for action in records} <= {"soft", "hard"} + assert len(pairs) == len(set(pairs)) From e3d34a160ed6f693827dbf5d81d4f41a59275685 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 03:58:24 +0000 Subject: [PATCH 133/229] feat: implement tool-package access (ToolPackage, enumerate_tool_packages, call_register_hooks) --- goga/hooks/tools/packages.py | 115 ++++++++++++++ tests/hooks/conftest.py | 89 +++++++++++ tests/hooks/tools/__init__.py | 1 + tests/hooks/tools/test_packages.py | 232 +++++++++++++++++++++++++++++ 4 files changed, 437 insertions(+) create mode 100644 goga/hooks/tools/packages.py create mode 100644 tests/hooks/conftest.py create mode 100644 tests/hooks/tools/__init__.py create mode 100644 tests/hooks/tools/test_packages.py diff --git a/goga/hooks/tools/packages.py b/goga/hooks/tools/packages.py new file mode 100644 index 00000000..a5eac0d0 --- /dev/null +++ b/goga/hooks/tools/packages.py @@ -0,0 +1,115 @@ +"""The tool-package access of the hooks platform. + +The entities declared in the cell CODEMANIFEST with ``location: packages.py``: +the package identity ``ToolPackage``, the environment enumeration +``enumerate_tool_packages``, and the facade callback invocation +``call_register_hooks``. This module is the only place in the platform that +reaches the installed packages: identities are read from the environment, the +enumeration imports nothing, and the single import of a facade happens inside +the callback invocation. A package runs at the trust level of its +installation — no isolation, no sandbox. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from importlib import import_module +from importlib.metadata import packages_distributions +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # the registration envelope lives in the sibling module + from .registration import HookRegistrar + +_TOOL_PACKAGE_PREFIX = "goga_tool_" + + +@dataclass(frozen=True, kw_only=True) +class ToolPackage: + """The identity of one installed tool package. + + The identity is assigned by the environment — a package never names + itself. The record stores the top-level module name and derives the + canonical hyphen form from it; both are computed reads, never fields. + + Attributes: + module_name: The top-level module name of the installed package. + + Requirements: + ``tool`` drops the ``goga_tool_`` prefix and turns underscores into + hyphens; ``facade`` is ``module_name`` verbatim. + """ + + module_name: str + + @property + def tool(self) -> str: + """The tool identity — the canonical hyphen form without the prefix.""" + return self.module_name.removeprefix(_TOOL_PACKAGE_PREFIX).replace("_", "-") + + @property + def facade(self) -> str: + """The importable name of the facade module.""" + return self.module_name + + +def enumerate_tool_packages() -> list[ToolPackage]: + """Enumerate the installed tool packages of the environment. + + One identity per installed package, in alphabetical order of the + top-level module name. The environment is only read here — no package is + imported: the facade import belongs to the callback invocation. An + environment without tool packages yields an empty list, not an error. + + Returns: + One identity per installed tool package, alphabetically ordered. + """ + names = sorted(name for name in packages_distributions() if name.startswith(_TOOL_PACKAGE_PREFIX)) + + return [ToolPackage(module_name=name) for name in names] + + +def call_register_hooks(package: ToolPackage, registrar: HookRegistrar) -> bool: + """Import the facade of one tool package and run its registration callback. + + The single import of a tool package in the whole platform. A missing + facade module and a facade without a callable ``register_hooks`` are + normal conditions — a quiet skip, no warning and no error. A broken + import of an existing package is the single fatal case of the platform: + a clean error naming the package. An exception of the callback itself + propagates unchanged — the isolation decision belongs to the caller. + + Args: + package: The identity of the target package. + registrar: The registration surface scoped to the package's tool. + + Returns: + True when the callback ran, False on a quiet skip. + + Raises: + ImportError: The package exists but its facade fails to import — the + message names the package. + """ + try: + module = import_module(package.facade) + + except ModuleNotFoundError as exc: + # The import machinery records on `exc.name` the module it could not + # resolve, and that equals the facade only when the tool package + # itself is missing. A deeper miss means the package was found — the + # honest cause must not be masked by a quiet skip. + if exc.name == package.facade: + return False + + raise ImportError(f"package {package.facade} failed to import: {exc}") from exc + + except Exception as exc: # a facade that fails to parse, and the like + raise ImportError(f"package {package.facade} failed to import: {exc}") from exc + + callback = getattr(module, "register_hooks", None) + + if not callable(callback): + return False + + callback(registrar) + + return True diff --git a/tests/hooks/conftest.py b/tests/hooks/conftest.py new file mode 100644 index 00000000..c6a12917 --- /dev/null +++ b/tests/hooks/conftest.py @@ -0,0 +1,89 @@ +"""Shared fixtures of the hooks platform tests — the environment boundary. + +The platform reaches the outside world at exactly two points: the +installed-distributions mapping read by ``packages_distributions`` and the +``sys.modules`` entry of a ``goga_tool_*`` package. The fixtures below pin +those two points and nothing else — the platform code under test runs for +real. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable +from types import ModuleType +from typing import Any +from unittest import mock + +import pytest + +ENUMERATION_TARGET = "goga.hooks.tools.packages.packages_distributions" +"""The attribute the enumeration reads — the single enumeration mock point.""" + + +@pytest.fixture +def pin_package_environment( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[dict[str, list[str]]], mock.MagicMock]: + """Factory: pin the installed-packages mapping the enumeration reads. + + ``mapping`` carries the shape of ``packages_distributions()`` — a + top-level module name mapped to the distributions providing it. Names + without the ``goga_tool_`` prefix stay in the mapping on purpose: they + prove the filter. Returns the boundary mock, so a test can also assert + how often the environment was read. + + Args: + monkeypatch: the pytest patcher restoring the boundary on teardown. + + Returns: + The pinning factory: mapping in, boundary mock out. + """ + + def _pin(mapping: dict[str, list[str]]) -> mock.MagicMock: + boundary = mock.MagicMock(return_value=mapping) + + monkeypatch.setattr(ENUMERATION_TARGET, boundary) + + return boundary + + return _pin + + +@pytest.fixture +def install_tool_package( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[..., ModuleType]: + """Factory: install one fake ``goga_tool_*`` package into ``sys.modules``. + + ``register_hooks`` becomes the facade callback of the package; every + further keyword argument is set on the module verbatim (``main``, + ``register_topic_statuses``, ...). Each call installs one package and + each installation is undone on teardown — one restored ``sys.modules`` + entry per fake package. + + Args: + monkeypatch: the pytest patcher restoring ``sys.modules`` on teardown. + + Returns: + The installing factory: module name in, the installed module out. + """ + + def _install( + module_name: str, + register_hooks: Callable[[Any], None] | None = None, + **attributes: Any, + ) -> ModuleType: + module = ModuleType(module_name) + + if register_hooks is not None: + module.register_hooks = register_hooks + + for name, value in attributes.items(): + setattr(module, name, value) + + monkeypatch.setitem(sys.modules, module_name, module) + + return module + + return _install diff --git a/tests/hooks/tools/__init__.py b/tests/hooks/tools/__init__.py new file mode 100644 index 00000000..eaa4a8d8 --- /dev/null +++ b/tests/hooks/tools/__init__.py @@ -0,0 +1 @@ +"""Tests of the tool-package access cell — ``goga/hooks/tools``.""" diff --git a/tests/hooks/tools/test_packages.py b/tests/hooks/tools/test_packages.py new file mode 100644 index 00000000..9f315e03 --- /dev/null +++ b/tests/hooks/tools/test_packages.py @@ -0,0 +1,232 @@ +"""Contract and logic tests for the entities declared in +``goga/hooks/tools/CODEMANIFEST`` with ``location: packages.py``: + +- ``ToolPackage(module_name)`` — the identity of one installed tool package +- ``enumerate_tool_packages()`` — the deterministic environment enumeration +- ``call_register_hooks(package, registrar)`` — the facade import with the + invocation of the single registration callback + +The environment boundary is pinned by the shared fixtures of +``tests/hooks/conftest.py`` and by ``mock.patch`` on ``import_module``; the +module under test runs for real. +""" + +from __future__ import annotations + +import dataclasses +import importlib +import inspect +import typing +from types import ModuleType +from unittest import mock + +import pytest +from goga.hooks.tools import packages +from goga.hooks.tools.packages import ( + ToolPackage, + call_register_hooks, + enumerate_tool_packages, +) + +# --- Contract tests --- + + +class TestPackagesContract: + def test_entities_are_importable_from_the_module(self) -> None: + """The three entities live on ``packages.py`` — the cell's only code here.""" + assert packages.ToolPackage is ToolPackage + assert packages.enumerate_tool_packages is enumerate_tool_packages + assert packages.call_register_hooks is call_register_hooks + + def test_module_defines_no_public_entity_beyond_the_three(self) -> None: + """No extra API, and nothing is pulled in from the catalog cell.""" + module = importlib.import_module("goga.hooks.tools.packages") + defined = { + name + for name, value in vars(module).items() + if not name.startswith("_") and getattr(value, "__module__", None) == module.__name__ + } + origin_modules = {str(getattr(value, "__module__", "")) for value in vars(module).values()} + + assert defined == {"ToolPackage", "enumerate_tool_packages", "call_register_hooks"} + assert not any(origin.startswith("goga.hooks.catalog") for origin in origin_modules) + + def test_tool_package_is_kw_only_frozen_with_computed_properties(self) -> None: + """``ToolPackage(module_name=...)`` — keyword-only, frozen, one field. + + ``tool`` and ``facade`` are computed properties, not fields: the + record stores the pip name and derives the identity from it. + """ + package = ToolPackage(module_name="goga_tool_x") + + assert dataclasses.is_dataclass(ToolPackage) + assert ToolPackage.__dataclass_params__.frozen + assert ToolPackage.__dataclass_params__.kw_only + assert package.module_name == "goga_tool_x" + + assert [field.name for field in dataclasses.fields(ToolPackage)] == ["module_name"] + + with pytest.raises(TypeError): + ToolPackage("goga_tool_x") # type: ignore[misc] + + with pytest.raises(dataclasses.FrozenInstanceError): + package.module_name = "goga_tool_other" # type: ignore[misc] + + def test_enumerate_tool_packages_signature(self) -> None: + """``enumerate_tool_packages() -> list[ToolPackage]`` — no parameters.""" + parameters = inspect.signature(enumerate_tool_packages).parameters + return_hint = typing.get_type_hints(enumerate_tool_packages)["return"] + + assert list(parameters) == [] + assert return_hint == list[ToolPackage] + + def test_call_register_hooks_signature(self) -> None: + """``call_register_hooks(package, registrar) -> bool`` — two parameters.""" + signature = inspect.signature(call_register_hooks) + + assert list(signature.parameters) == ["package", "registrar"] + assert signature.return_annotation == "bool" + + +# --- Logic tests: the identity and the enumeration --- + + +class TestToolPackageIdentity: + def test_tool_package_identity_is_canonical_hyphen_form(self) -> None: + """The prefix is dropped, underscores become hyphens; facade verbatim.""" + package = ToolPackage(module_name="goga_tool_my_tool") + + assert package.tool == "my-tool" + assert package.facade == "goga_tool_my_tool" + + assert ToolPackage(module_name="goga_tool_mkdocs").tool == "mkdocs" + + +class TestEnumerateToolPackages: + def test_enumerate_tool_packages_filters_prefix_and_sorts(self, pin_package_environment) -> None: + """Only ``goga_tool_*`` names, alphabetically — and no import happens.""" + pin_package_environment( + { + "other_pkg": ["other-pkg"], + "goga_tool_b": ["goga-tool-b"], + "goga_tool_a": ["goga-tool-a"], + } + ) + + with mock.patch.object(packages, "import_module") as import_mock: + result = enumerate_tool_packages() + + import_mock.assert_not_called() + + assert [package.module_name for package in result] == ["goga_tool_a", "goga_tool_b"] + + def test_enumerate_tool_packages_empty_environment(self, pin_package_environment) -> None: + """An environment without tool packages yields an empty list, not an error.""" + pin_package_environment({}) + + assert enumerate_tool_packages() == [] + + def test_enumerate_tool_packages_reads_the_boundary_once_per_call( + self, + pin_package_environment, + ) -> None: + """One environment read per call — the enumeration is a single pass.""" + boundary = pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + + first = enumerate_tool_packages() + second = enumerate_tool_packages() + + assert first == second + assert boundary.call_count == 2 + + +# --- Logic tests: the facade callback invocation --- + + +class TestCallRegisterHooks: + def test_call_register_hooks_invokes_callback_with_single_registrar( + self, + install_tool_package, + ) -> None: + """The callback runs with the registrar as its single argument.""" + captured: list[object] = [] + install_tool_package("goga_tool_demo", register_hooks=captured.append) + + registrar = object() + + assert call_register_hooks(ToolPackage(module_name="goga_tool_demo"), registrar) is True + assert captured == [registrar] + + def test_call_register_hooks_missing_package_is_quiet_skip(self, capsys) -> None: + """The metadata names a package whose module is absent — a silent False.""" + absent = ToolPackage(module_name="goga_tool_absent_from_the_env") + + assert call_register_hooks(absent, object()) is False + assert capsys.readouterr().err == "" + + def test_call_register_hooks_missing_callback_is_quiet_skip( + self, + install_tool_package, + capsys, + ) -> None: + """A facade without ``register_hooks`` is a normal condition — no noise.""" + install_tool_package("goga_tool_silent") + + package = ToolPackage(module_name="goga_tool_silent") + + assert call_register_hooks(package, object()) is False + assert capsys.readouterr().err == "" + + def test_call_register_hooks_non_callable_callback_is_quiet_skip( + self, + install_tool_package, + capsys, + ) -> None: + """A ``register_hooks`` attribute that is not callable — a silent False.""" + install_tool_package("goga_tool_broken_promise", register_hooks=None) + package = ToolPackage(module_name="goga_tool_broken_promise") + + assert call_register_hooks(package, object()) is False + assert capsys.readouterr().err == "" + + def test_call_register_hooks_broken_import_raises_clean_error_naming_package(self) -> None: + """A foreign, transitive import failure — the single fatal case.""" + package = ToolPackage(module_name="goga_tool_demo") + failure = ModuleNotFoundError("No module named 'dep'", name="dep") + + with ( + mock.patch.object(packages, "import_module", side_effect=failure), + pytest.raises(ImportError, match=r"package goga_tool_demo failed to import") as info, + ): + call_register_hooks(package, object()) + + assert info.value.__cause__ is failure + + def test_call_register_hooks_syntax_error_raises_clean_error_naming_package(self) -> None: + """A package that fails to parse — wrapped the same clean way.""" + package = ToolPackage(module_name="goga_tool_demo") + failure = SyntaxError("invalid syntax (goga_tool_demo/__init__.py, line 1)") + + with ( + mock.patch.object(packages, "import_module", side_effect=failure), + pytest.raises(ImportError, match=r"package goga_tool_demo failed to import"), + ): + call_register_hooks(package, object()) + + def test_call_register_hooks_callback_exception_propagates_unchanged(self) -> None: + """A callback crash is not wrapped and not swallowed — the caller decides.""" + failure = ValueError("boom") + + def callback(hooks: object) -> None: + raise failure + + module = ModuleType("goga_tool_demo") + module.register_hooks = callback + + with ( + mock.patch.object(packages, "import_module", return_value=module), + pytest.raises(ValueError, match="boom") as info, + ): + call_register_hooks(ToolPackage(module_name="goga_tool_demo"), object()) + + assert info.value is failure From bb2c4d603c8e66a89d295b077c86f0cbe2e17ae7 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 04:03:57 +0000 Subject: [PATCH 134/229] feat: implement the registration envelope (HookRegistrar, Subscription, RejectedRegistration) --- goga/hooks/tools/__init__.py | 19 ++ goga/hooks/tools/registration.py | 184 ++++++++++++++ tests/hooks/tools/test_registration.py | 329 +++++++++++++++++++++++++ 3 files changed, 532 insertions(+) create mode 100644 goga/hooks/tools/__init__.py create mode 100644 goga/hooks/tools/registration.py create mode 100644 tests/hooks/tools/test_registration.py diff --git a/goga/hooks/tools/__init__.py b/goga/hooks/tools/__init__.py new file mode 100644 index 00000000..2c175ff2 --- /dev/null +++ b/goga/hooks/tools/__init__.py @@ -0,0 +1,19 @@ +"""Tool-package access and registration cell — the tool-facing surface. + +The owner of the package identities, the environment enumeration, the facade +callback invocation, and the registration envelope. A subscription enters the +platform through this surface alone. Importing the package imports no tool +package and enumerates nothing. +""" + +from .packages import ToolPackage, call_register_hooks, enumerate_tool_packages +from .registration import HookRegistrar, RejectedRegistration, Subscription + +__all__: list[str] = [ + "HookRegistrar", + "RejectedRegistration", + "Subscription", + "ToolPackage", + "call_register_hooks", + "enumerate_tool_packages", +] diff --git a/goga/hooks/tools/registration.py b/goga/hooks/tools/registration.py new file mode 100644 index 00000000..b4d376b7 --- /dev/null +++ b/goga/hooks/tools/registration.py @@ -0,0 +1,184 @@ +"""The registration envelope of the hooks platform. + +The entities declared in the cell CODEMANIFEST with ``location: +registration.py``: the registration surface ``HookRegistrar`` and the two +value records ``Subscription`` and ``RejectedRegistration``. This module is +the only way a subscription enters the platform — an address is resolved +against the catalog, the envelope is validated, and a refusal is recorded as +data with a stderr warning, never raised. The registrar never calls a hook +and never resolves a tool identity: the identity is assigned by the caller +that owns the package. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable +from dataclasses import dataclass, field + +from ..catalog import declared_actions + + +@dataclass(kw_only=True) +class HookRegistrar: + """The controlled registration surface handed to one tool. + + Scoped to one tool identity: every registration made through the surface + is qualified by it. An invalid envelope is refused as data — the registrar + never raises on one, and a refusal never cancels the accepted + registrations of the same tool. + + Attributes: + tool: The tool identity every registration made through this surface + is qualified with — assigned by the caller, never resolved here. + """ + + tool: str + _subscriptions: list[Subscription] = field(init=False, default_factory=list, repr=False) + _rejections: list[RejectedRegistration] = field(init=False, default_factory=list, repr=False) + + @property + def subscriptions(self) -> list[Subscription]: + """The accepted subscriptions, in registration order — a read copy.""" + return list(self._subscriptions) + + @property + def rejections(self) -> list[RejectedRegistration]: + """The rejected envelopes, in attempted order — a read copy.""" + return list(self._rejections) + + def subscribe(self, domain: str, action: str, name: str, hook: Callable[..., object]) -> None: + """Register one hook subscription — the registration envelope. + + Args: + domain: The owner domain of the action. + action: The action name within the domain. + name: The hook name — unique per tool per address. + hook: The callable executed when the action fires. + + Algorithm: + 1. Resolve ``domain`` and ``action`` against ``declared_actions`` + — an unknown address is rejected + 2. Validate the envelope — a non-empty ``name`` and a callable + ``hook``; a violation is rejected + 3. Reject a repeated registration of the same ``name`` on the + same address by this tool + 4. Every rejection is recorded and announced as a warning on + stderr naming the tool, the action, and the reason + 5. An accepted envelope appends one subscription + + Requirements: + A rejected envelope never cancels the accepted subscriptions of + the same tool, and the registrar never raises on an invalid + envelope — rejection is data, not an exception. + + Constraints: + Do not call ``hook`` or inspect its signature — the delivery + projection belongs to the delivery zone. + + A rejection carries the attempted name as an empty string when the + envelope did not carry a usable one, so the inspection view states + what was refused even for an ill-formed name. + """ + + def reject(reason: str) -> None: + self._rejections.append( + RejectedRegistration( + tool=self.tool, + domain=domain, + action=action, + name=name if isinstance(name, str) else "", + reason=reason, + ) + ) + + print(f"Warning: rejected hook of tool {self.tool} on {domain}.{action}: {reason}", file=sys.stderr) + + known = any( + record.domain == domain and record.name == action for record in declared_actions() + ) + + if not known: + reject(f"unknown action {domain}.{action}") + + return + + if not (isinstance(name, str) and name): + reject("name must be a non-empty string") + + return + + if not callable(hook): + reject("hook must be callable") + + return + + repeated = any( + subscription.domain == domain + and subscription.action == action + and subscription.name == name + for subscription in self._subscriptions + ) + + if repeated: + reject("repeated name on the same address") + + return + + self._subscriptions.append( + Subscription(tool=self.tool, domain=domain, action=action, name=name, hook=hook) + ) + + +@dataclass(frozen=True, kw_only=True) +class Subscription: + """One accepted subscription — a hook bound to an address, qualified by a tool. + + The record carries no behavior: the delivery decides how the hook is + called, the registry decides when. + + Attributes: + tool: The tool identity that registered the subscription. + domain: The owner domain of the subscribed action. + action: The subscribed action name. + name: The hook name within its tool. + hook: The registered callable. + + Requirements: + The identity of the record is the triple ``tool``, ``domain``, + ``action`` with the ``name`` — the registrar enforces its uniqueness + per tool per address before a subscription exists. + """ + + tool: str + domain: str + action: str + name: str + hook: Callable[..., object] + + +@dataclass(frozen=True, kw_only=True) +class RejectedRegistration: + """One refused registration envelope with the reason of the refusal. + + The data behind the inspection view of refused registrations — it states + what was attempted and why it did not apply, nothing more. + + Attributes: + tool: The tool identity that attempted the registration. + domain: The owner domain of the addressed action. + action: The addressed action name. + name: The attempted hook name — an empty string when the envelope did + not carry a usable one. + reason: The reason of the refusal. + + Requirements: + The reason is one of the platform refusal strings, so the inspection + view stays stable for the reader. + """ + + tool: str + domain: str + action: str + name: str + reason: str diff --git a/tests/hooks/tools/test_registration.py b/tests/hooks/tools/test_registration.py new file mode 100644 index 00000000..47c10c06 --- /dev/null +++ b/tests/hooks/tools/test_registration.py @@ -0,0 +1,329 @@ +"""Contract and logic tests for the entities declared in +``goga/hooks/tools/CODEMANIFEST`` with ``location: registration.py``: + +- ``HookRegistrar(tool)`` — the controlled registration surface of one tool +- ``Subscription(tool, domain, action, name, hook)`` — one accepted envelope +- ``RejectedRegistration(tool, domain, action, name, reason)`` — one refused + envelope with its reason + +The catalog read is the only seam: a test that needs a wider catalog +monkeypatches ``declared_actions`` in the namespace of the module under test. +The registrar itself runs for real — a hook is never called, so a plain +function stands in for one. +""" + +from __future__ import annotations + +import dataclasses +import importlib +import inspect +import typing + +import pytest +from goga.hooks.catalog import Action +from goga.hooks.tools import HookRegistrar, RejectedRegistration, Subscription, registration + +_CELL_ALL = [ + "HookRegistrar", + "RejectedRegistration", + "Subscription", + "ToolPackage", + "call_register_hooks", + "enumerate_tool_packages", +] + + +def _hook() -> None: + """A stand-in registered callable — the registrar never calls it.""" + + +# --- Contract tests --- + + +class TestRegistrationContract: + def test_entities_are_importable_from_the_package_facade(self) -> None: + """All three entities live on the cell package and its ``__all__`` is exact.""" + import goga.hooks.tools as cell + + assert cell.HookRegistrar is HookRegistrar + assert cell.Subscription is Subscription + assert cell.RejectedRegistration is RejectedRegistration + assert cell.__all__ == _CELL_ALL + + for name in _CELL_ALL: + assert getattr(cell, name, None) is not None + + def test_module_defines_no_public_entity_beyond_the_three(self) -> None: + """No extra API on ``registration.py``.""" + module = importlib.import_module("goga.hooks.tools.registration") + defined = { + name + for name, value in vars(module).items() + if not name.startswith("_") and getattr(value, "__module__", None) == module.__name__ + } + + assert defined == {"HookRegistrar", "RejectedRegistration", "Subscription"} + + def test_cell_does_not_import_from_registry_or_dispatch(self) -> None: + """The cell stays below registry and dispatch in the dependency order.""" + modules = [ + importlib.import_module("goga.hooks.tools"), + importlib.import_module("goga.hooks.tools.packages"), + importlib.import_module("goga.hooks.tools.registration"), + ] + + for module in modules: + origins = {str(getattr(value, "__module__", "")) for value in vars(module).values()} + + assert not any(origin.startswith("goga.hooks.registry") for origin in origins) + assert not any(origin.startswith("goga.hooks.dispatch") for origin in origins) + + def test_hook_registrar_is_a_kw_only_accumulating_dataclass(self) -> None: + """``HookRegistrar(tool=...)`` — keyword-only, mutable, private state.""" + registrar = HookRegistrar(tool="t") + + assert registrar.tool == "t" + assert dataclasses.is_dataclass(HookRegistrar) + assert HookRegistrar.__dataclass_params__.kw_only + assert not HookRegistrar.__dataclass_params__.frozen + + assert [(f.name, f.init, f.repr) for f in dataclasses.fields(HookRegistrar)] == [ + ("tool", True, True), + ("_subscriptions", False, False), + ("_rejections", False, False), + ] + + with pytest.raises(TypeError): + HookRegistrar("t") # type: ignore[misc] + + def test_subscribe_signature(self) -> None: + """``subscribe(self, domain, action, name, hook)`` — nothing else.""" + signature = inspect.signature(HookRegistrar.subscribe) + return_hint = typing.get_type_hints(HookRegistrar.subscribe)["return"] + + assert list(signature.parameters) == ["self", "domain", "action", "name", "hook"] + assert return_hint is type(None) + + def test_subscription_is_a_kw_only_frozen_record(self) -> None: + """``Subscription`` carries exactly the five declared fields, frozen.""" + subscription = Subscription( + tool="t", + domain="statuses", + action="register_statuses", + name="published", + hook=_hook, + ) + + assert subscription.tool == "t" + assert subscription.domain == "statuses" + assert subscription.action == "register_statuses" + assert subscription.name == "published" + assert subscription.hook is _hook + + assert dataclasses.is_dataclass(Subscription) + assert Subscription.__dataclass_params__.frozen + assert Subscription.__dataclass_params__.kw_only + assert [f.name for f in dataclasses.fields(Subscription)] == [ + "tool", + "domain", + "action", + "name", + "hook", + ] + + with pytest.raises(TypeError): + Subscription("t", "statuses", "register_statuses", "published", _hook) # type: ignore[misc] + + with pytest.raises(dataclasses.FrozenInstanceError): + subscription.name = "other" # type: ignore[misc] + + def test_rejected_registration_is_a_kw_only_frozen_record(self) -> None: + """``RejectedRegistration`` carries exactly the five declared fields, frozen.""" + rejection = RejectedRegistration( + tool="t", + domain="statuses", + action="register_statuses", + name="dup", + reason="repeated name on the same address", + ) + + assert rejection.tool == "t" + assert rejection.domain == "statuses" + assert rejection.action == "register_statuses" + assert rejection.name == "dup" + assert rejection.reason == "repeated name on the same address" + + assert dataclasses.is_dataclass(RejectedRegistration) + assert RejectedRegistration.__dataclass_params__.frozen + assert RejectedRegistration.__dataclass_params__.kw_only + assert [f.name for f in dataclasses.fields(RejectedRegistration)] == [ + "tool", + "domain", + "action", + "name", + "reason", + ] + + with pytest.raises(TypeError): + RejectedRegistration("t", "statuses", "register_statuses", "dup", "why") # type: ignore[misc] + + with pytest.raises(dataclasses.FrozenInstanceError): + rejection.reason = "other" # type: ignore[misc] + + def test_registrar_reads_start_empty(self) -> None: + """``subscriptions`` and ``rejections`` are readable and start empty.""" + registrar = HookRegistrar(tool="t") + + assert isinstance(registrar.subscriptions, list) + assert isinstance(registrar.rejections, list) + assert registrar.subscriptions == [] + assert registrar.rejections == [] + + +# --- Logic tests: the accepted envelope --- + + +class TestSubscribeAccepts: + def test_subscribe_accepts_envelope_and_qualifies_with_registrar_tool(self) -> None: + """One accepted envelope becomes one subscription qualified by the tool.""" + registrar = HookRegistrar(tool="my-tool") + + registrar.subscribe("statuses", "register_statuses", "published", _hook) + + assert registrar.subscriptions == [ + Subscription( + tool="my-tool", + domain="statuses", + action="register_statuses", + name="published", + hook=_hook, + ) + ] + assert registrar.rejections == [] + + def test_subscribe_same_name_different_address_is_allowed(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Uniqueness is per tool per address — the same name elsewhere applies.""" + monkeypatch.setattr( + registration, + "declared_actions", + lambda: [ + Action(domain="statuses", name="register_statuses", error_class="soft"), + Action(domain="statuses", name="register_other", error_class="soft"), + ], + ) + registrar = HookRegistrar(tool="t") + + registrar.subscribe("statuses", "register_statuses", "x", _hook) + registrar.subscribe("statuses", "register_other", "x", _hook) + + assert [(s.domain, s.action, s.name) for s in registrar.subscriptions] == [ + ("statuses", "register_statuses", "x"), + ("statuses", "register_other", "x"), + ] + assert registrar.rejections == [] + + def test_subscribe_keeps_registration_order(self) -> None: + """The accepted subscriptions read back in the order they arrived.""" + registrar = HookRegistrar(tool="t") + + for name in ("third", "first", "second"): + registrar.subscribe("statuses", "register_statuses", name, _hook) + + assert [s.name for s in registrar.subscriptions] == ["third", "first", "second"] + + def test_subscribe_does_not_call_or_inspect_the_hook(self) -> None: + """The callable is recorded verbatim — never called, never inspected.""" + calls: list[object] = [] + + def counted() -> None: + calls.append(counted) + + registrar = HookRegistrar(tool="t") + + registrar.subscribe("statuses", "register_statuses", "counted", counted) + registrar.subscribe("statuses", "register_statuses", "builtin", dict) # no signature + + assert calls == [] + assert [s.hook for s in registrar.subscriptions] == [counted, dict] + assert registrar.rejections == [] + + def test_subscription_and_rejection_reads_are_copies(self) -> None: + """Mutating a returned list never reaches the registrar state.""" + registrar = HookRegistrar(tool="t") + registrar.subscribe("statuses", "register_statuses", "published", _hook) + + registrar.subscriptions.clear() + registrar.rejections.clear() + + assert [s.name for s in registrar.subscriptions] == ["published"] + assert registrar.rejections == [] + + +# --- Logic tests: the refused envelope --- + + +class TestSubscribeRejects: + def test_subscribe_unknown_address_is_rejected_with_warning(self, capsys: pytest.CaptureFixture[str]) -> None: + """An address outside the catalog is refused — data, not an exception.""" + registrar = HookRegistrar(tool="t") + + registrar.subscribe("nope", "no_action", "n", _hook) + + assert registrar.subscriptions == [] + assert [r.reason for r in registrar.rejections] == ["unknown action nope.no_action"] + assert (registrar.rejections[0].tool, registrar.rejections[0].name) == ("t", "n") + + assert "Warning: rejected hook of tool t on nope.no_action: unknown action nope.no_action" in ( + capsys.readouterr().err + ) + + def test_subscribe_invalid_envelope_is_rejected_and_partial_registrations_survive( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Every violation is refused on its own; the accepted one survives.""" + registrar = HookRegistrar(tool="t") + + registrar.subscribe("statuses", "register_statuses", "ok", _hook) + registrar.subscribe("statuses", "register_statuses", "", _hook) + registrar.subscribe("statuses", "register_statuses", "not-callable", "not-callable") + registrar.subscribe("statuses", "register_statuses", "ok", _hook) + + assert [s.name for s in registrar.subscriptions] == ["ok"] + assert [r.reason for r in registrar.rejections] == [ + "name must be a non-empty string", + "hook must be callable", + "repeated name on the same address", + ] + + err = capsys.readouterr().err + assert err.count("Warning: rejected hook of tool t on statuses.register_statuses:") == 3 + + def test_subscribe_non_string_name_is_rejected_with_an_empty_name( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + """A name that is not a string lands in the refusal as an empty string.""" + registrar = HookRegistrar(tool="t") + + registrar.subscribe("statuses", "register_statuses", None, _hook) # type: ignore[arg-type] + + assert registrar.subscriptions == [] + assert registrar.rejections[0].name == "" + assert registrar.rejections[0].reason == "name must be a non-empty string" + assert capsys.readouterr().err.startswith( + "Warning: rejected hook of tool t on statuses.register_statuses:" + ) + + def test_subscribe_repeats_are_refused_per_registrar(self) -> None: + """Two tools hold separate registrars — the same name applies for both.""" + first = HookRegistrar(tool="a") + second = HookRegistrar(tool="b") + + for registrar in (first, second): + registrar.subscribe("statuses", "register_statuses", "published", _hook) + + assert [s.tool for s in first.subscriptions] == ["a"] + assert [s.tool for s in second.subscriptions] == ["b"] + assert first.rejections == [] + assert second.rejections == [] From e8b22ad7844dfcaf127208a9000f0ed764ee5e00 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 04:08:38 +0000 Subject: [PATCH 135/229] feat: implement the run registry (HookRegistry, ToolContext, ToolHooks) --- goga/hooks/registry/__init__.py | 15 + goga/hooks/registry/state.py | 192 +++++++++++++ tests/hooks/registry/__init__.py | 1 + tests/hooks/registry/test_state.py | 430 +++++++++++++++++++++++++++++ 4 files changed, 638 insertions(+) create mode 100644 goga/hooks/registry/__init__.py create mode 100644 goga/hooks/registry/state.py create mode 100644 tests/hooks/registry/__init__.py create mode 100644 tests/hooks/registry/test_state.py diff --git a/goga/hooks/registry/__init__.py b/goga/hooks/registry/__init__.py new file mode 100644 index 00000000..9603aa12 --- /dev/null +++ b/goga/hooks/registry/__init__.py @@ -0,0 +1,15 @@ +"""Run registry cell — the assembled state of one run. + +The owner of the single assembly, the read side of the assembled +subscriptions, the isolated per-tool contexts, and the per-tool inspection +view. Importing the package imports no tool package and enumerates nothing — +a registry is built once, on the first ``build_once`` call of its object. +""" + +from .state import HookRegistry, ToolContext, ToolHooks + +__all__: list[str] = [ + "HookRegistry", + "ToolContext", + "ToolHooks", +] diff --git a/goga/hooks/registry/state.py b/goga/hooks/registry/state.py new file mode 100644 index 00000000..d850bfb7 --- /dev/null +++ b/goga/hooks/registry/state.py @@ -0,0 +1,192 @@ +"""The run registry of the hooks platform. + +The entities declared in the cell CODEMANIFEST with ``location: state.py``: +the run registry ``HookRegistry``, the isolated runtime context +``ToolContext``, and the per-tool inspection entry ``ToolHooks``. The registry +is the state of one run: it assembles itself once — on the first +``build_once`` call — by walking the installed tool packages through the +tool-package access of the platform, and it offers the read side of what was +assembled. Pure state and read logic: the packages are reached through the +tools cell, the delivery belongs to the dispatch zone. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field + +from ..tools import ( + HookRegistrar, + RejectedRegistration, + Subscription, + call_register_hooks, + enumerate_tool_packages, +) + + +@dataclass(kw_only=True) +class HookRegistry: + """The run registry — the assembled state of one run, built on first use. + + Created empty and cheap: no package is enumerated and no module imported + at construction. The single build happens on the first ``build_once`` + call and never repeats on the same object — every run works over a fresh + registry, nothing is cached across runs. + """ + + _built: bool = field(init=False, default=False, repr=False) + _subscriptions: list[Subscription] = field(init=False, default_factory=list, repr=False) + _rejections: list[RejectedRegistration] = field(init=False, default_factory=list, repr=False) + _contexts: dict[str, ToolContext] = field(init=False, default_factory=dict, repr=False) + + @property + def subscriptions(self) -> list[Subscription]: + """Every accepted subscription, in enumeration order — a read copy.""" + return list(self._subscriptions) + + @property + def rejections(self) -> list[RejectedRegistration]: + """Every refused envelope, in enumeration order — a read copy.""" + return list(self._rejections) + + def build_once(self) -> None: + """Assemble the registry of the run — the single build. + + Algorithm: + 1. An already assembled registry does nothing — the flag is set + before the enumeration, so a nested or repeated build never + reads the environment twice + 2. Enumerate the installed tool packages via + ``enumerate_tool_packages`` + 3. For each package in order: create a ``HookRegistrar`` scoped + to the package identity and run its callback via + ``call_register_hooks`` + 4. An exception of a callback ends that callback's registration + only: a warning on stderr naming the tool and the reason, the + registrations made before the failure survive, the next + package is processed + 5. A broken package import — the platform-wrapped ``ImportError`` + naming the package — is the single fatal case and leaves the + build as a clean error + 6. The accepted subscriptions and the refused envelopes of every + registrar, also of a crashed one, join this registry in + enumeration order + + Requirements: + One tool's failure never cancels another tool's registrations. + + Raises: + ImportError: A tool package exists but its facade fails to + import — the message names the package. + """ + if self._built: + return + + self._built = True + + for package in enumerate_tool_packages(): + registrar = HookRegistrar(tool=package.tool) + + try: + call_register_hooks(package, registrar) + + except ImportError as exc: + # The import wrapper of the platform names the broken package; + # an ImportError raised by the tool callback itself is a crash + # like any other and is told apart by that message prefix. + if str(exc).startswith(f"package {package.facade} failed to import:"): + raise + + print(f"Warning: skipping hook registration of tool {package.tool}: {exc}", file=sys.stderr) + + except Exception as exc: + print(f"Warning: skipping hook registration of tool {package.tool}: {exc}", file=sys.stderr) + + self._subscriptions.extend(registrar.subscriptions) + self._rejections.extend(registrar.rejections) + + def subscriptions_for(self, domain: str, action: str) -> list[Subscription]: + """Return the subscriptions of one action address. + + Args: + domain: The owner domain of the action. + action: The action name within the domain. + + Returns: + The subscriptions of the address, in enumeration order — an + empty list when the address carries none, never an error. + """ + return [ + subscription + for subscription in self._subscriptions + if subscription.domain == domain and subscription.action == action + ] + + def self_context(self, tool: str) -> ToolContext: + """Return the isolated runtime context of one tool. + + Args: + tool: The tool identity of the owner. + + Returns: + The tool's own context — one instance per tool per run, so the + invocations of its hooks share their state. + """ + if tool not in self._contexts: + self._contexts[tool] = ToolContext(tool=tool) + + return self._contexts[tool] + + def by_tool(self) -> list[ToolHooks]: + """Return the per-tool inspection view of the registry. + + Returns: + One entry per tool with a subscription or a refusal, ordered + alphabetically by tool. The view states the fact of registration, + never the application. + """ + tools = sorted( + {subscription.tool for subscription in self._subscriptions} + | {rejection.tool for rejection in self._rejections} + ) + + return [ + ToolHooks( + tool=tool, + subscriptions=[ + subscription for subscription in self._subscriptions if subscription.tool == tool + ], + rejections=[rejection for rejection in self._rejections if rejection.tool == tool], + ) + for tool in tools + ] + + +@dataclass(kw_only=True) +class ToolContext: + """The isolated runtime context of one tool — its own state within a run. + + The only state a hook may write without restriction, unlike a delivered + domain context: a tool links the invocations of its hooks here and stays + invisible to the domains. + + Attributes: + tool: The environment-assigned tool identity of the owner. + """ + + tool: str + + +@dataclass(frozen=True, kw_only=True) +class ToolHooks: + """The per-tool inspection entry — one tool with its registrations. + + Attributes: + tool: The tool identity of the entry. + subscriptions: The tool's accepted subscriptions. + rejections: The tool's refused envelopes. + """ + + tool: str + subscriptions: list[Subscription] + rejections: list[RejectedRegistration] diff --git a/tests/hooks/registry/__init__.py b/tests/hooks/registry/__init__.py new file mode 100644 index 00000000..846b9c31 --- /dev/null +++ b/tests/hooks/registry/__init__.py @@ -0,0 +1 @@ +"""Tests of the run registry cell — ``goga/hooks/registry``.""" diff --git a/tests/hooks/registry/test_state.py b/tests/hooks/registry/test_state.py new file mode 100644 index 00000000..0eb6cb4f --- /dev/null +++ b/tests/hooks/registry/test_state.py @@ -0,0 +1,430 @@ +"""Contract and logic tests for the entities declared in +``goga/hooks/registry/CODEMANIFEST`` with ``location: state.py``: + +- ``HookRegistry()`` — the run registry: the single assembly, the read side, + the isolated per-tool contexts, and the per-tool inspection view +- ``ToolContext(tool)`` — the isolated runtime context of one tool +- ``ToolHooks(tool, subscriptions, rejections)`` — the per-tool inspection + entry + +The environment boundary is pinned by the shared fixtures of +``tests/hooks/conftest.py`` — the enumeration mapping and the fake +``goga_tool_*`` modules. The registry, its registrars, and the package access +run for real; only the fatal broken-import case is mocked at the +``call_register_hooks`` seam. +""" + +from __future__ import annotations + +import dataclasses +import importlib +import inspect +import typing +from collections.abc import Callable + +import pytest +from goga.hooks.registry import HookRegistry, ToolContext, ToolHooks, state +from goga.hooks.tools import RejectedRegistration, Subscription + +_CELL_ALL = ["HookRegistry", "ToolContext", "ToolHooks"] + + +def _noop_hook(context: object) -> None: + """A stand-in registered callable — the registry never calls it.""" + + +def _subscribe(name: str) -> Callable[[object], None]: + """Build a facade callback subscribing one hook under ``name``.""" + + def register_hooks(hooks: object) -> None: + hooks.subscribe("statuses", "register_statuses", name, _noop_hook) # type: ignore[attr-defined] + + return register_hooks + + +# --- Contract tests --- + + +class TestRegistryContract: + def test_entities_are_importable_from_the_package_facade(self) -> None: + """All three entities live on the cell package and its ``__all__`` is exact.""" + import goga.hooks.registry as cell + + assert cell.HookRegistry is HookRegistry + assert cell.ToolContext is ToolContext + assert cell.ToolHooks is ToolHooks + assert cell.__all__ == _CELL_ALL + + for name in _CELL_ALL: + assert getattr(cell, name, None) is not None + + def test_module_defines_no_public_entity_beyond_the_three(self) -> None: + """No extra API, and nothing reaches the cells above the registry.""" + module = importlib.import_module("goga.hooks.registry.state") + defined = { + name + for name, value in vars(module).items() + if not name.startswith("_") and getattr(value, "__module__", None) == module.__name__ + } + origin_modules = {str(getattr(value, "__module__", "")) for value in vars(module).values()} + + assert defined == {"HookRegistry", "ToolContext", "ToolHooks"} + assert not any(origin.startswith("goga.hooks.dispatch") for origin in origin_modules) + assert not any(origin.startswith("goga.hooks.catalog") for origin in origin_modules) + + def test_hook_registry_constructs_with_no_arguments(self) -> None: + """``HookRegistry()`` — keyword-only dataclass, private build state.""" + registry = HookRegistry() + + assert isinstance(registry, HookRegistry) + assert dataclasses.is_dataclass(HookRegistry) + assert HookRegistry.__dataclass_params__.kw_only + assert not HookRegistry.__dataclass_params__.frozen + + assert [(f.name, f.init, f.repr) for f in dataclasses.fields(HookRegistry)] == [ + ("_built", False, False), + ("_subscriptions", False, False), + ("_rejections", False, False), + ("_contexts", False, False), + ] + + with pytest.raises(TypeError): + HookRegistry("anything") # type: ignore[misc] + + def test_method_signatures(self) -> None: + """The four methods carry exactly the declared parameters.""" + expected = { + "build_once": ["self"], + "subscriptions_for": ["self", "domain", "action"], + "self_context": ["self", "tool"], + "by_tool": ["self"], + } + return_hints = { + name: typing.get_type_hints(getattr(HookRegistry, name))["return"] for name in expected + } + + for name, parameters in expected.items(): + assert list(inspect.signature(getattr(HookRegistry, name)).parameters) == parameters + + assert return_hints["build_once"] is type(None) + assert return_hints["subscriptions_for"] == list[Subscription] + assert return_hints["self_context"] is ToolContext + assert return_hints["by_tool"] == list[ToolHooks] + + def test_reads_are_lists_and_start_empty(self) -> None: + """``subscriptions`` and ``rejections`` are readable lists before the build.""" + registry = HookRegistry() + + assert isinstance(registry.subscriptions, list) + assert isinstance(registry.rejections, list) + assert registry.subscriptions == [] + assert registry.rejections == [] + + def test_construction_enumerates_nothing(self, pin_package_environment) -> None: + """A fresh registry is cheap — the environment is not read at construction.""" + boundary = pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + + HookRegistry() + + boundary.assert_not_called() + + def test_reads_never_trigger_the_build(self, pin_package_environment) -> None: + """The read properties are pure reads — no enumeration behind them.""" + boundary = pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + registry = HookRegistry() + + assert registry.subscriptions == [] + assert registry.rejections == [] + assert registry.subscriptions_for("statuses", "register_statuses") == [] + assert registry.by_tool() == [] + + boundary.assert_not_called() + + def test_tool_context_is_kw_only_mutable_and_open(self) -> None: + """``ToolContext(tool=...)`` — keyword-only, mutable, no ``__slots__``.""" + context = ToolContext(tool="t") + + assert context.tool == "t" + assert dataclasses.is_dataclass(ToolContext) + assert ToolContext.__dataclass_params__.kw_only + assert not ToolContext.__dataclass_params__.frozen + assert [f.name for f in dataclasses.fields(ToolContext)] == ["tool"] + + context.own_state = {"runs": 1} # a hook writes its own context freely + + assert context.own_state == {"runs": 1} + assert not hasattr(ToolContext, "__slots__") + + with pytest.raises(TypeError): + ToolContext("t") # type: ignore[misc] + + def test_tool_hooks_is_a_kw_only_frozen_record(self) -> None: + """``ToolHooks`` carries exactly the three declared fields, frozen.""" + subscription = Subscription( + tool="t", + domain="statuses", + action="register_statuses", + name="published", + hook=_noop_hook, + ) + rejection = RejectedRegistration( + tool="t", + domain="statuses", + action="register_statuses", + name="dup", + reason="repeated name on the same address", + ) + entry = ToolHooks(tool="t", subscriptions=[subscription], rejections=[rejection]) + + assert entry.tool == "t" + assert entry.subscriptions == [subscription] + assert entry.rejections == [rejection] + + assert dataclasses.is_dataclass(ToolHooks) + assert ToolHooks.__dataclass_params__.frozen + assert ToolHooks.__dataclass_params__.kw_only + assert [f.name for f in dataclasses.fields(ToolHooks)] == [ + "tool", + "subscriptions", + "rejections", + ] + + with pytest.raises(TypeError): + ToolHooks("t", [], []) # type: ignore[misc] + + with pytest.raises(dataclasses.FrozenInstanceError): + entry.tool = "other" # type: ignore[misc] + + +# --- Logic tests: the single build --- + + +class TestBuildOnce: + def test_build_once_collects_in_enumeration_order( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """Subscriptions land in enumeration order, qualified by package identity.""" + pin_package_environment( + {"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]} + ) + install_tool_package("goga_tool_a", register_hooks=_subscribe("one")) + install_tool_package("goga_tool_b", register_hooks=_subscribe("two")) + registry = HookRegistry() + + registry.build_once() + + assert [(s.tool, s.name) for s in registry.subscriptions] == [("a", "one"), ("b", "two")] + + def test_build_once_is_idempotent( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """Two builds over one registry enumerate once and never double the state.""" + boundary = pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + install_tool_package("goga_tool_a", register_hooks=_subscribe("one")) + registry = HookRegistry() + + registry.build_once() + registry.build_once() + + assert boundary.call_count == 1 + assert [s.name for s in registry.subscriptions] == ["one"] + + def test_build_once_skips_a_package_without_the_callback_quietly( + self, + pin_package_environment, + install_tool_package, + capsys: pytest.CaptureFixture[str], + ) -> None: + """A facade without ``register_hooks`` is a quiet skip — no warning.""" + pin_package_environment( + {"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]} + ) + install_tool_package("goga_tool_a") # no callback on the facade + install_tool_package("goga_tool_b", register_hooks=_subscribe("two")) + registry = HookRegistry() + + registry.build_once() + + assert [s.name for s in registry.subscriptions] == ["two"] + assert capsys.readouterr().err == "" + + def test_build_once_collects_rejections_of_every_registrar( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """Refused envelopes are part of the assembled state, in order.""" + pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + + def register_hooks(hooks: object) -> None: + subscribe = hooks.subscribe # type: ignore[attr-defined] + subscribe("statuses", "register_statuses", "ok", _noop_hook) + subscribe("statuses", "register_statuses", "ok", _noop_hook) # repeated + subscribe("statuses", "no_such_action", "lost", _noop_hook) # unknown address + + install_tool_package("goga_tool_a", register_hooks=register_hooks) + registry = HookRegistry() + + registry.build_once() + + assert [r.reason for r in registry.rejections] == [ + "repeated name on the same address", + "unknown action statuses.no_such_action", + ] + assert [r.tool for r in registry.rejections] == ["a", "a"] + + def test_build_once_callback_crash_warns_and_keeps_partial_registrations( + self, + pin_package_environment, + install_tool_package, + capsys: pytest.CaptureFixture[str], + ) -> None: + """A crashed callback ends its own registration only — the rest runs.""" + pin_package_environment( + {"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]} + ) + + def crashing(hooks: object) -> None: + hooks.subscribe("statuses", "register_statuses", "first", _noop_hook) # type: ignore[attr-defined] + raise RuntimeError("kaput") + + install_tool_package("goga_tool_a", register_hooks=crashing) + install_tool_package("goga_tool_b", register_hooks=_subscribe("second")) + registry = HookRegistry() + + registry.build_once() + + assert [s.name for s in registry.subscriptions] == ["first", "second"] + assert "Warning: skipping hook registration of tool a: kaput" in capsys.readouterr().err + + def test_build_once_broken_import_is_fatal_and_not_swallowed( + self, + pin_package_environment, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The platform-wrapped broken import is the single fatal case.""" + pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + + def broken(package: object, registrar: object) -> bool: + facade = package.facade # type: ignore[attr-defined] + raise ImportError(f"package {facade} failed to import: boom") + + monkeypatch.setattr(state, "call_register_hooks", broken) + registry = HookRegistry() + + with pytest.raises(ImportError, match="goga_tool_a"): + registry.build_once() + + def test_build_once_callback_importerror_is_warning_not_fatal( + self, + pin_package_environment, + install_tool_package, + capsys: pytest.CaptureFixture[str], + ) -> None: + """An import failure raised inside a callback is a crash, not the fatal case.""" + pin_package_environment( + {"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]} + ) + + def crashing(hooks: object) -> None: + hooks.subscribe("statuses", "register_statuses", "first", _noop_hook) # type: ignore[attr-defined] + raise ModuleNotFoundError("No module named 'opt_dep'", name="opt_dep") + + install_tool_package("goga_tool_a", register_hooks=crashing) + install_tool_package("goga_tool_b", register_hooks=_subscribe("second")) + registry = HookRegistry() + + registry.build_once() + + assert [s.name for s in registry.subscriptions] == ["first", "second"] + assert "Warning: skipping hook registration of tool a" in capsys.readouterr().err + + +# --- Logic tests: the read side --- + + +class TestRegistryReads: + def test_subscriptions_for_returns_the_address_subscriptions_in_order( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """Exact address match, enumeration order.""" + pin_package_environment( + {"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]} + ) + install_tool_package("goga_tool_a", register_hooks=_subscribe("one")) + install_tool_package("goga_tool_b", register_hooks=_subscribe("two")) + registry = HookRegistry() + + registry.build_once() + + assert [ + s.name for s in registry.subscriptions_for("statuses", "register_statuses") + ] == ["one", "two"] + assert [ + s.name for s in registry.subscriptions_for("statuses", "register_other") + ] == [] + + def test_subscriptions_for_empty_address_is_not_an_error( + self, + pin_package_environment, + ) -> None: + """An address without subscriptions yields an empty list.""" + pin_package_environment({}) + registry = HookRegistry() + + registry.build_once() + + assert registry.subscriptions_for("statuses", "register_statuses") == [] + assert registry.subscriptions_for("nope", "no_action") == [] + + def test_self_context_returns_one_instance_per_tool(self) -> None: + """One context per tool per run — the tool's invocations share it.""" + registry = HookRegistry() + + first = registry.self_context("a") + second = registry.self_context("a") + other = registry.self_context("b") + + assert first is second + assert first is not other + assert first.tool == "a" + assert other.tool == "b" + + def test_by_tool_groups_alphabetically_with_rejections( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """One entry per tool, alphabetical — both lists on the same entry.""" + pin_package_environment( + {"goga_tool_b": ["goga-tool-b"], "goga_tool_a": ["goga-tool-a"]} + ) + install_tool_package("goga_tool_b", register_hooks=_subscribe("kept")) + + def refused(hooks: object) -> None: + subscribe = hooks.subscribe # type: ignore[attr-defined] + subscribe("statuses", "register_statuses", "kept", _noop_hook) + subscribe("statuses", "register_statuses", "dup", "not-callable") + + install_tool_package("goga_tool_a", register_hooks=refused) + registry = HookRegistry() + + registry.build_once() + + view = registry.by_tool() + + assert [entry.tool for entry in view] == ["a", "b"] + assert [s.name for s in view[0].subscriptions] == ["kept"] + assert [r.name for r in view[0].rejections] == ["dup"] + assert [s.name for s in view[1].subscriptions] == ["kept"] + assert view[1].rejections == [] + + def test_by_tool_of_an_empty_registry_is_empty(self) -> None: + """A registry without registrations carries no entry at all.""" + assert HookRegistry().by_tool() == [] From dd1d656452168eb405d183d300dbc5f053510961 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 04:15:19 +0000 Subject: [PATCH 136/229] feat: implement the context mediation (wrap_context, build_hook_arguments) --- goga/hooks/dispatch/delivery.py | 101 ++++++++++++ tests/hooks/dispatch/__init__.py | 1 + tests/hooks/dispatch/test_delivery.py | 217 ++++++++++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 goga/hooks/dispatch/delivery.py create mode 100644 tests/hooks/dispatch/__init__.py create mode 100644 tests/hooks/dispatch/test_delivery.py diff --git a/goga/hooks/dispatch/delivery.py b/goga/hooks/dispatch/delivery.py new file mode 100644 index 00000000..0158eff9 --- /dev/null +++ b/goga/hooks/dispatch/delivery.py @@ -0,0 +1,101 @@ +"""The context mediation of the hooks platform. + +The entities declared in the cell CODEMANIFEST with ``location: delivery.py``: +the delivery view ``wrap_context`` and the fixed-name injection +``build_hook_arguments``. Mediation is transparent for reads and calls — a +hook works with the emitted object as if it held it — and closed for writes: +the delivered context is read-only, and the only state a hook may write is +its own tool context, delivered separately under the declared name ``self``. +Nothing here calls a hook; the call belongs to the emission. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable + +from ..registry import ToolContext + +_KEYWORD_CAPABLE = frozenset( + { + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + } +) +"""The parameter kinds a call may fill by name — the injection surface.""" + + +def wrap_context(target: object) -> object: + """Wrap an emitted domain object for delivery to a hook. + + The proxy resolves every attribute read on ``target`` — plain + attributes, properties, bound methods — so a hook sees the emitted + object itself and nothing else. Writes are closed: attribute assignment + and deletion raise a clean error and ``target`` stays untouched. + + The proxy hides ``target`` completely. The proxy class is created in the + closure of this call — a fresh class per delivery, so no type to + introspect — its instances carry no ``__dict__`` (``__slots__ = ()``), + and a dunder lookup on the proxy follows the language default instead of + reaching ``target``. + + Args: + target: The object the emitting checkpoint hands over. + + Returns: + The delivery view of ``target``. + """ + + class _DeliveryProxy: + """The delivery view of one emitted object — reads pass, writes do not.""" + + __slots__ = () # no instance dict — nowhere to keep or find the target + + def __getattr__(self, name: str) -> object: + if name.startswith("__") and name.endswith("__"): + raise AttributeError(name) # a dunder: the language default, never target + + return getattr(target, name) + + def __setattr__(self, name: str, value: object) -> None: + raise AttributeError("the delivered context is read-only: attribute assignment is blocked") + + def __delattr__(self, name: str) -> None: + raise AttributeError("the delivered context is read-only: attribute deletion is blocked") + + return _DeliveryProxy() + + +def build_hook_arguments( + hook: Callable[..., object], + context: object, + self_context: ToolContext, +) -> dict[str, object]: + """Project a hook signature against the offered injection names. + + The offered-name set is the single source of the opt-in: a declared + ``context`` parameter receives the delivery view of the emitted object, a + declared ``self`` parameter receives the isolated context of the hook's + own tool, and any other declared name receives nothing. Only + keyword-capable parameters participate — a positional-only ``context`` + declares no injection. The declaration order does not matter: values + land by name. The hook is not called here; the call belongs to the + emission. + + Args: + hook: The registered callable. + context: The delivery view of the emitted object. + self_context: The isolated context of the hook's own tool. + + Returns: + The keyword arguments to call ``hook`` with — never a value the hook + did not declare. + """ + offered = {"context": context, "self": self_context} + arguments: dict[str, object] = {} + + for parameter in inspect.signature(hook).parameters.values(): + if parameter.kind in _KEYWORD_CAPABLE and parameter.name in offered: + arguments[parameter.name] = offered[parameter.name] + + return arguments diff --git a/tests/hooks/dispatch/__init__.py b/tests/hooks/dispatch/__init__.py new file mode 100644 index 00000000..e94168a2 --- /dev/null +++ b/tests/hooks/dispatch/__init__.py @@ -0,0 +1 @@ +"""Tests of the context mediation of the dispatch cell — ``goga/hooks/dispatch``.""" diff --git a/tests/hooks/dispatch/test_delivery.py b/tests/hooks/dispatch/test_delivery.py new file mode 100644 index 00000000..cbdf262c --- /dev/null +++ b/tests/hooks/dispatch/test_delivery.py @@ -0,0 +1,217 @@ +"""Contract and logic tests for the entities declared in +``goga/hooks/dispatch/CODEMANIFEST`` with ``location: delivery.py``: + +- ``wrap_context(target)`` — the transparent delivery view of an emitted + domain object: reads and calls pass through, writes are blocked +- ``build_hook_arguments(hook, context, self_context)`` — the fixed-name + injection projected against the signature of the hook + +The delivery zone owns no external boundary, so the logic tests are +mock-free: mediation is exercised over plain target objects and plain +functions. The package facade of the cell is built together with the +emission; here the entities are reached through their declared module. +""" + +from __future__ import annotations + +import importlib +import inspect +import typing + +import pytest +from goga.hooks.dispatch.delivery import build_hook_arguments, wrap_context +from goga.hooks.registry import ToolContext + + +class _Emitted: + """A stand-in emitted object: a plain attribute, a property, a method.""" + + version = "1" + + @property + def name(self) -> str: + return "n" + + def register(self, entry: str) -> tuple[str, str]: + return ("registered", entry) + + +# --- Contract tests --- + + +class TestDeliveryContract: + def test_entities_are_importable_from_the_declared_module(self) -> None: + """Both entities live on the module the CODEMANIFEST declares.""" + module = importlib.import_module("goga.hooks.dispatch.delivery") + + assert module.wrap_context is wrap_context + assert module.build_hook_arguments is build_hook_arguments + + def test_module_defines_no_public_entity_beyond_the_two(self) -> None: + """No extra API, and nothing reaches the cells beside the registry.""" + module = importlib.import_module("goga.hooks.dispatch.delivery") + defined = { + name + for name, value in vars(module).items() + if not name.startswith("_") and getattr(value, "__module__", None) == module.__name__ + } + origin_modules = {str(getattr(value, "__module__", "")) for value in vars(module).values()} + + assert defined == {"build_hook_arguments", "wrap_context"} + assert not any(origin.startswith("goga.hooks.tools") for origin in origin_modules) + assert not any(origin.startswith("goga.hooks.catalog") for origin in origin_modules) + + def test_signatures(self) -> None: + """Both functions carry exactly the declared parameters.""" + expected = { + "wrap_context": ["target"], + "build_hook_arguments": ["hook", "context", "self_context"], + } + module_globals = globals() + hints = {name: typing.get_type_hints(module_globals[name]) for name in expected} + + for name, parameters in expected.items(): + assert list(inspect.signature(module_globals[name]).parameters) == parameters + + assert hints["wrap_context"]["return"] is object + assert hints["build_hook_arguments"]["return"] == dict[str, object] + assert hints["build_hook_arguments"]["self_context"] is ToolContext + + def test_wrap_context_returns_an_object(self) -> None: + """The factory returns a proxy object — never the target itself.""" + target = object() + proxy = wrap_context(target) + + assert isinstance(proxy, object) + assert proxy is not target + + def test_the_proxy_type_carries_no_instance_state(self) -> None: + """The proxy is a slots-only closure class — no instance dict.""" + proxy = wrap_context(object()) + + assert type(proxy).__slots__ == () + assert not hasattr(proxy, "__dict__") + + +# --- Logic tests: the delivery proxy --- + + +class TestWrapContext: + def test_wrap_context_passes_reads_properties_and_calls(self) -> None: + """Reads, properties, and bound-method calls resolve on the target.""" + proxy = wrap_context(_Emitted()) + + assert proxy.version == "1" + assert proxy.name == "n" + assert proxy.register("s") == ("registered", "s") + + def test_wrap_context_blocks_assignment_and_deletion(self) -> None: + """Writes raise a clean error naming the mediation — the target stays.""" + target = type("Target", (), {"x": 1})() + proxy = wrap_context(target) + + with pytest.raises( + AttributeError, + match="the delivered context is read-only: attribute assignment is blocked", + ): + proxy.x = 2 # type: ignore[misc] + + with pytest.raises( + AttributeError, + match="the delivered context is read-only: attribute deletion is blocked", + ): + del proxy.x # type: ignore[attr-defined] + + assert target.x == 1 + assert proxy.x == 1 + + def test_wrap_context_exposes_no_reference_to_target(self) -> None: + """The proxy hides the target — no dict, no class identity, no alias.""" + target = type("Target", (), {"tool_name": "demo"})() + proxy = wrap_context(target) + + # CPython answers vars() of an instance without __dict__ with a + # TypeError; the checked fact is that no proxy dict exists to mine. + with pytest.raises(TypeError): + vars(proxy) # type: ignore[arg-type] + + with pytest.raises(AttributeError): + _ = proxy.__dict__ # type: ignore[attr-defined] + + assert proxy.__class__ is not type(target) + assert hasattr(proxy, "tool_name") is True + assert [name for name in dir(proxy) if not name.startswith("__")] == [] + assert target not in [getattr(proxy, name) for name in dir(proxy)] + + def test_wrap_context_dunder_lookup_never_reaches_target(self) -> None: + """Special-method lookup follows the language default, not the target.""" + proxy = wrap_context(type("Sized", (), {"__len__": lambda _self: 5})()) + + with pytest.raises(TypeError): + len(proxy) # type: ignore[arg-type] + + with pytest.raises(AttributeError): + _ = proxy.__len__ # type: ignore[attr-defined] + + def test_the_proxy_class_is_fresh_per_delivery(self) -> None: + """Every call builds its own class in the closure — no shared type.""" + target = object() + + assert type(wrap_context(target)) is not type(wrap_context(target)) + + +# --- Logic tests: the signature projection --- + + +class TestBuildHookArguments: + def test_build_hook_arguments_fills_declared_names_in_any_order(self) -> None: + """Values land by declared name; unoffered and positional-only get nothing.""" + context_view = object() + own = ToolContext(tool="demo") + + def takes_context(context: object) -> None: + """Declares only the delivered view.""" + + def takes_self_first(self: ToolContext, context: object) -> None: + """Declares both — the tool context first.""" + + def takes_context_first(context: object, self: ToolContext) -> None: + """Declares both — the delivered view first.""" + + def takes_keyword_only(*, context: object) -> None: + """Declares the delivered view as keyword-only.""" + + def takes_other(other: object) -> None: + """Declares an unoffered name.""" + + def takes_positional_only(context: object, /) -> None: + """Declares the delivered view as positional-only — no injection.""" + + def takes_mixed(first: object, /, context: object) -> None: + """Only the name decides: a keyword-capable context still receives.""" + + assert build_hook_arguments(takes_context, context_view, own) == {"context": context_view} + assert build_hook_arguments(takes_self_first, context_view, own) == { + "context": context_view, + "self": own, + } + assert build_hook_arguments(takes_context_first, context_view, own) == { + "context": context_view, + "self": own, + } + assert build_hook_arguments(takes_keyword_only, context_view, own) == {"context": context_view} + assert build_hook_arguments(takes_other, context_view, own) == {} + assert build_hook_arguments(takes_positional_only, context_view, own) == {} + assert build_hook_arguments(takes_mixed, context_view, own) == {"context": context_view} + + def test_build_hook_arguments_never_calls_the_hook(self) -> None: + """The projection is a read of the signature — the call is not here.""" + calls: list[object] = [] + + def hook(context: object, self: ToolContext) -> None: + calls.append(context) + + arguments = build_hook_arguments(hook, object(), ToolContext(tool="t")) + + assert calls == [] + assert set(arguments) == {"context", "self"} From 30a8e19b0560f11c1ce6f83dfbb26fd924378fed Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 04:22:54 +0000 Subject: [PATCH 137/229] feat: implement the hooks event emission (emit_hook_event, dispatch facade) --- goga/hooks/dispatch/__init__.py | 17 ++ goga/hooks/dispatch/emit.py | 102 ++++++++ tests/hooks/dispatch/conftest.py | 45 ++++ tests/hooks/dispatch/test_emit.py | 378 ++++++++++++++++++++++++++++++ 4 files changed, 542 insertions(+) create mode 100644 goga/hooks/dispatch/__init__.py create mode 100644 goga/hooks/dispatch/emit.py create mode 100644 tests/hooks/dispatch/conftest.py create mode 100644 tests/hooks/dispatch/test_emit.py diff --git a/goga/hooks/dispatch/__init__.py b/goga/hooks/dispatch/__init__.py new file mode 100644 index 00000000..4fded35d --- /dev/null +++ b/goga/hooks/dispatch/__init__.py @@ -0,0 +1,17 @@ +"""Event delivery cell — the mediation and the emission of the hooks platform. + +The owner of the transparent delivery view of an emitted domain object, the +fixed-name injection of hook arguments, and the emission of an action to its +subscribed hooks under the action's error class. Importing the package +enumerates nothing and builds no registry — the single build of a run happens +on the first emission. +""" + +from .delivery import build_hook_arguments, wrap_context +from .emit import emit_hook_event + +__all__: list[str] = [ + "build_hook_arguments", + "emit_hook_event", + "wrap_context", +] diff --git a/goga/hooks/dispatch/emit.py b/goga/hooks/dispatch/emit.py new file mode 100644 index 00000000..c01531af --- /dev/null +++ b/goga/hooks/dispatch/emit.py @@ -0,0 +1,102 @@ +"""The emission of the hooks platform. + +The entity declared in the cell CODEMANIFEST with ``location: emit.py``: +``emit_hook_event`` — the emission of an action of a domain checkpoint to its +subscribed hooks under the action's error class. This is the only point where +the registry is assembled: the first emission of a run performs the single +build, and no emission of the same run rebuilds it. Delivery is +fire-and-forget — nothing is returned and nothing is collected after the +event; the single channel a tool has towards the emitting domain is calling +members of the delivered object. Every diagnostic names the tool, the action, +and the reason. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable + +from ..catalog import declared_actions +from ..registry import HookRegistry +from .delivery import build_hook_arguments, wrap_context + + +def emit_hook_event( + registry: HookRegistry, + domain: str, + action: str, + context_for: Callable[[str], object], +) -> None: + """Emit an action of a domain checkpoint to its subscribed hooks. + + Args: + registry: The run registry — assembled on first use, never rebuilt. + domain: The emitting domain — the semantic owner of the action. + action: The action name within its domain. + context_for: Builds the context view of one receiving tool — takes the + tool identity, returns the object that tool's hooks receive. + + Algorithm: + 1. Assemble the registry via ``build_once`` — the first emission of a + run performs the single build + 2. Resolve ``domain`` and ``action`` against ``declared_actions`` — an + unknown address is a clean error of the emitting side + 3. Take the subscriptions of the address in enumeration order; for + each, build the receiving tool's context view via ``context_for`` + — one view per distinct tool of the address, built at the tool's + first subscription and reused by its remaining subscriptions + 4. Wrap the view via ``wrap_context``, project the call arguments via + ``build_hook_arguments`` with the tool's own context from the + registry, and call the hook + 5. Treat a failure per the action's error class: soft — a warning on + stderr naming the tool, the action, and the reason, the hook is + skipped, the sequence continues; hard — a clean error naming the + tool and the reason, the sequence stops at the first failure + + Requirements: + An address without subscriptions emits nothing. A failure of + ``context_for`` is a clean error of the emitting side — never treated + as a hook failure — so it is raised outside the failure intercept + below, which covers the wrapping, the projection, and the call as one + interceptable space and catches ``Exception`` only: a + ``BaseException`` such as ``KeyboardInterrupt`` passes through. + + Raises: + ValueError: The address is not declared, or a hook of a hard action + failed — the message names the hook, the tool, and the reason. + """ + registry.build_once() + + record = next( + (entry for entry in declared_actions() if entry.domain == domain and entry.name == action), + None, + ) + if record is None: + raise ValueError(f"unknown hook action: {domain}.{action}") + + views: dict[str, object] = {} + + for subscription in registry.subscriptions_for(domain, action): + if subscription.tool not in views: + # Outside the intercept below: a crashing view builder is a clean + # error of the emitting side, never a hook failure. + views[subscription.tool] = context_for(subscription.tool) + + try: + view = wrap_context(views[subscription.tool]) + arguments = build_hook_arguments( + subscription.hook, + view, + registry.self_context(subscription.tool), + ) + subscription.hook(**arguments) + except Exception as exc: + if record.error_class == "hard": + raise ValueError( + f"hook {subscription.name} of tool {subscription.tool} failed on {domain}.{action}: {exc}" + ) from exc + + print( + f"Warning: hook {subscription.name} of tool {subscription.tool} failed on {domain}.{action}: {exc}", + file=sys.stderr, + ) diff --git a/tests/hooks/dispatch/conftest.py b/tests/hooks/dispatch/conftest.py new file mode 100644 index 00000000..992da6bd --- /dev/null +++ b/tests/hooks/dispatch/conftest.py @@ -0,0 +1,45 @@ +"""Fixtures of the emission tests — the catalog boundary of a hard action. + +The real catalog declares a single address, ``statuses.register_statuses``, +and it is soft. The hard failure treatment needs a hard-class record, so the +fixture below pins ``declared_actions`` in the ``goga.hooks.dispatch.emit`` +namespace — the single point the emission resolves addresses against. The +statuses record stays: within a pinned test the soft behavior keeps working +beside the hard one. +""" + +from __future__ import annotations + +import pytest +from goga.hooks.catalog import Action + +_HARD_CATALOG: list[Action] = [ + Action(domain="d", name="act", error_class="hard"), + Action(domain="statuses", name="register_statuses", error_class="soft"), +] +"""The pinned catalog — the real one plus the hard-class address ``d.act``.""" + + +@pytest.fixture +def hard_action_catalog(monkeypatch: pytest.MonkeyPatch) -> list[Action]: + """Pin the emission's catalog with one hard-class address ``d.act``. + + While pinned, the emission resolves ``("d", "act")`` as a hard action — + the first failing hook stops the sequence with a clean error — and keeps + resolving the statuses address as soft. + + Args: + monkeypatch: the pytest patcher restoring the real catalog on teardown. + + Returns: + The records the pinned emission resolves addresses against. + """ + from goga.hooks.dispatch import emit + + def pinned() -> list[Action]: + """The pinned catalog — a fresh list, as the real routine returns.""" + return list(_HARD_CATALOG) + + monkeypatch.setattr(emit, "declared_actions", pinned) + + return pinned() diff --git a/tests/hooks/dispatch/test_emit.py b/tests/hooks/dispatch/test_emit.py new file mode 100644 index 00000000..3ac5cc48 --- /dev/null +++ b/tests/hooks/dispatch/test_emit.py @@ -0,0 +1,378 @@ +"""Contract and logic tests for the entity declared in +``goga/hooks/dispatch/CODEMANIFEST`` with ``location: emit.py``: + +- ``emit_hook_event(registry, domain, action, context_for)`` — the emission + of an action to its subscribed hooks under the action's error class + +The environment boundary is pinned by the shared fixtures of +``tests/hooks/conftest.py`` — the enumeration mapping and the fake +``goga_tool_*`` modules — so the registry, the registrars, and the delivery +run for real behind the emission. The hard failure treatment needs a +hard-class catalog record; ``tests/hooks/dispatch/conftest.py`` pins the +catalog of the emission for that. Only the hard-failure registry is a plain +fake: a hard address cannot be registered through the real envelope, because +the real catalog does not declare it. +""" + +from __future__ import annotations + +import importlib +import inspect +import typing +from collections.abc import Callable +from unittest import mock + +import pytest +from goga.hooks.dispatch import emit_hook_event +from goga.hooks.dispatch.delivery import wrap_context +from goga.hooks.registry import HookRegistry, ToolContext +from goga.hooks.tools import Subscription + +_CELL_ALL = ["build_hook_arguments", "emit_hook_event", "wrap_context"] + + +def _noop_hook(context: object) -> None: + """A stand-in registered callable — receiving the view is all it does.""" + + +def _plain_view(tool: str) -> object: + """A stand-in view builder — the view itself carries no meaning here.""" + return object() + + +def _subscribe(name: str, hook: Callable[..., object]) -> Callable[[object], None]: + """Build a facade callback subscribing ``hook`` under ``name`` on statuses.""" + + def register_hooks(hooks: object) -> None: + hooks.subscribe("statuses", "register_statuses", name, hook) # type: ignore[attr-defined] + + return register_hooks + + +class _FakeRegistry: + """A pre-assembled registry stand-in for addresses the real catalog lacks.""" + + def __init__(self, subscriptions: list[Subscription]) -> None: + self._subscriptions = subscriptions + self._contexts: dict[str, ToolContext] = {} + + def build_once(self) -> None: + """Already assembled — the emission must not rebuild it.""" + + def subscriptions_for(self, domain: str, action: str) -> list[Subscription]: + """Exact address match, given order.""" + return [ + subscription + for subscription in self._subscriptions + if subscription.domain == domain and subscription.action == action + ] + + def self_context(self, tool: str) -> ToolContext: + """One context per tool, as the real registry does.""" + if tool not in self._contexts: + self._contexts[tool] = ToolContext(tool=tool) + + return self._contexts[tool] + + +# --- Contract tests --- + + +class TestEmissionContract: + def test_entity_is_importable_from_the_package_facade(self) -> None: + """The emission lives on the cell package and its ``__all__`` is exact.""" + import goga.hooks.dispatch as cell + from goga.hooks.dispatch.delivery import build_hook_arguments + + assert cell.emit_hook_event is emit_hook_event + assert cell.build_hook_arguments is build_hook_arguments + assert cell.wrap_context is wrap_context + assert cell.__all__ == _CELL_ALL + + def test_module_defines_no_public_entity_beyond_the_one(self) -> None: + """No extra API, and the cell imports from catalog and registry only.""" + module = importlib.import_module("goga.hooks.dispatch.emit") + defined = { + name + for name, value in vars(module).items() + if not name.startswith("_") and getattr(value, "__module__", None) == module.__name__ + } + origin_modules = {str(getattr(value, "__module__", "")) for value in vars(module).values()} + platform_origins = { + origin for origin in origin_modules if origin.startswith("goga.hooks.") and origin != module.__name__ + } + + assert defined == {"emit_hook_event"} + assert platform_origins <= { + "goga.hooks.catalog", + "goga.hooks.catalog.catalog", + "goga.hooks.registry", + "goga.hooks.registry.state", + "goga.hooks.dispatch.delivery", + } + + def test_signature_and_return(self) -> None: + """The emission carries exactly the declared parameters and returns None.""" + assert list(inspect.signature(emit_hook_event).parameters) == [ + "registry", + "domain", + "action", + "context_for", + ] + + hints = typing.get_type_hints(emit_hook_event) + + assert hints["return"] is type(None) + assert hints["registry"] is HookRegistry + assert hints["domain"] is str + assert hints["action"] is str + + def test_the_first_emission_performs_the_single_build( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """The emission assembles the registry — no separate build step.""" + boundary = pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + install_tool_package("goga_tool_a", register_hooks=_subscribe("one", _noop_hook)) + registry = HookRegistry() + + assert registry.subscriptions == [] + + emit_hook_event(registry, "statuses", "register_statuses", _plain_view) + + assert boundary.call_count == 1 + assert [s.name for s in registry.subscriptions] == ["one"] + + def test_a_second_emission_never_rebuilds_the_registry( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """Two emissions over one registry enumerate the environment once.""" + boundary = pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + calls: list[object] = [] + + def hook(context: object) -> None: + calls.append(context) + + install_tool_package("goga_tool_a", register_hooks=_subscribe("one", hook)) + registry = HookRegistry() + + emit_hook_event(registry, "statuses", "register_statuses", _plain_view) + emit_hook_event(registry, "statuses", "register_statuses", _plain_view) + + assert boundary.call_count == 1 + assert len(calls) == 2 # the hook fires again; the build does not + + +# --- Logic tests: the delivered event --- + + +class TestEmissionDelivery: + def test_emit_delivers_proxied_context_and_self_to_hook( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """The hook receives the delivery view and its own tool context.""" + pin_package_environment({"goga_tool_demo": ["goga-tool-demo"]}) + captured: list[tuple[object, ToolContext]] = [] + + def hook(self: ToolContext, context: object) -> None: + captured.append((context, self)) + + install_tool_package("goga_tool_demo", register_hooks=_subscribe("published", hook)) + sentinel = type("Sentinel", (), {"tool_name": "demo-tool-name", "marker": 1})() + registry = HookRegistry() + + def context_for(tool: str) -> object: + return sentinel + + emit_hook_event(registry, "statuses", "register_statuses", context_for) + + assert len(captured) == 1 + captured_context, captured_self = captured[0] + + assert captured_context is not sentinel + assert captured_context.tool_name == sentinel.tool_name # reads pass through + + with pytest.raises(AttributeError, match="read-only"): + captured_context.marker = 2 # type: ignore[misc] + + assert sentinel.marker == 1 # the emitted object stays untouched + assert isinstance(captured_self, ToolContext) + assert captured_self.tool == "demo" + assert captured_self is registry.self_context("demo") + + # The proxy class is created in a closure per delivery — an isinstance + # check against any other proxy type can never succeed. + assert not isinstance(captured_context, type(wrap_context(sentinel))) + + def test_emit_builds_one_view_per_distinct_tool( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """One context view per distinct tool, reused by its remaining hooks.""" + pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) + built_for: list[str] = [] + received: dict[str, list[object]] = {} + + def context_for(tool: str) -> object: + built_for.append(tool) + return type("View", (), {"token": object()})() # a fresh token per built view + + def hook_of(label: str) -> Callable[..., None]: + def hook(context: object) -> None: + received.setdefault(label, []).append(context) + + return hook + + def register_a(hooks: object) -> None: + subscribe = hooks.subscribe # type: ignore[attr-defined] + subscribe("statuses", "register_statuses", "one", hook_of("one")) + subscribe("statuses", "register_statuses", "two", hook_of("two")) + + def register_b(hooks: object) -> None: + hooks.subscribe("statuses", "register_statuses", "three", hook_of("three")) # type: ignore[attr-defined] + + install_tool_package("goga_tool_a", register_hooks=register_a) + install_tool_package("goga_tool_b", register_hooks=register_b) + registry = HookRegistry() + + emit_hook_event(registry, "statuses", "register_statuses", context_for) + + assert built_for == ["a", "b"] # one per distinct tool, first-subscription order + assert set(received) == {"one", "two", "three"} + + # Both hooks of "a" read the same underlying view — the proxies differ, + # the delivered object does not. + assert received["one"][0].token is received["two"][0].token # type: ignore[attr-defined] + assert received["one"][0].token is not received["three"][0].token # type: ignore[attr-defined] + + +# --- Logic tests: failures --- + + +class TestEmissionFailures: + def test_emit_unknown_address_raises_clean_error( + self, + pin_package_environment, + ) -> None: + """An address outside the catalog is a clean error of the emitting side.""" + pin_package_environment({}) + registry = HookRegistry() + + with pytest.raises(ValueError, match=r"unknown hook action: statuses\.no_such_action"): + emit_hook_event(registry, "statuses", "no_such_action", _plain_view) + + def test_emit_soft_failure_warns_and_continues( + self, + pin_package_environment, + install_tool_package, + capsys: pytest.CaptureFixture[str], + ) -> None: + """A soft hook is skipped with a warning — the sequence continues.""" + pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) + b_calls: list[object] = [] + + def failing(context: object) -> None: + raise ValueError("bad registration") + + def b_hook(context: object) -> None: + b_calls.append(context) + + install_tool_package("goga_tool_a", register_hooks=_subscribe("x", failing)) + install_tool_package("goga_tool_b", register_hooks=_subscribe("y", b_hook)) + registry = HookRegistry() + + emit_hook_event(registry, "statuses", "register_statuses", _plain_view) + + assert len(b_calls) == 1 + assert ( + "Warning: hook x of tool a failed on statuses.register_statuses: bad registration" + in capsys.readouterr().err + ) + + def test_emit_hard_failure_stops_at_first_failure(self, hard_action_catalog: object) -> None: + """A hard hook failure stops the sequence with a clean error.""" + calls: list[str] = [] + + def first(context: object) -> None: + calls.append("first") + raise RuntimeError("stop") + + def second(context: object) -> None: + calls.append("second") + + registry = _FakeRegistry( + [ + Subscription(tool="t1", domain="d", action="act", name="n1", hook=first), + Subscription(tool="t2", domain="d", action="act", name="n2", hook=second), + ] + ) + + with pytest.raises(ValueError, match=r"hook n1 of tool t1 failed on d\.act: stop"): + emit_hook_event(registry, "d", "act", _plain_view) + + assert calls == ["first"] + + def test_emit_context_for_failure_is_clean_error_not_hook_failure( + self, + pin_package_environment, + install_tool_package, + capsys: pytest.CaptureFixture[str], + ) -> None: + """A crashing view builder is an emitting-side error, never a warning.""" + pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + hook_calls: list[object] = [] + + def hook(context: object) -> None: + hook_calls.append(context) + + install_tool_package("goga_tool_a", register_hooks=_subscribe("x", hook)) + registry = HookRegistry() + + def context_for(tool: str) -> object: + return 1 / 0 # a crash of the emitting side, not of the hook + + with pytest.raises(ZeroDivisionError): + emit_hook_event(registry, "statuses", "register_statuses", context_for) + + assert hook_calls == [] + assert "Warning: hook" not in capsys.readouterr().err + + def test_emit_projection_failure_is_treated_as_hook_failure( + self, + pin_package_environment, + install_tool_package, + capsys: pytest.CaptureFixture[str], + ) -> None: + """An unprojectable signature is a hook failure under the error class.""" + pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + install_tool_package("goga_tool_a", register_hooks=_subscribe("built-in", dict)) + registry = HookRegistry() + + emit_hook_event(registry, "statuses", "register_statuses", _plain_view) + + err = capsys.readouterr().err + + assert "Warning: hook" in err + assert "statuses.register_statuses" in err + + def test_emit_address_without_submissions_emits_nothing( + self, + pin_package_environment, + capsys: pytest.CaptureFixture[str], + ) -> None: + """No subscriptions of the address — no view, no call, no diagnostics.""" + pin_package_environment({}) + registry = HookRegistry() + context_for = mock.Mock() + + result = emit_hook_event(registry, "statuses", "register_statuses", context_for) + + assert result is None + context_for.assert_not_called() + assert capsys.readouterr().err == "" From 25b21a570ce3923e840f4c639c239ceaf31d49b9 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 04:27:10 +0000 Subject: [PATCH 138/229] feat: implement the hooks platform facade (four re-exports via goga/hooks) --- goga/hooks/__init__.py | 20 ++++++++++++++ tests/hooks/test_facade.py | 55 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 goga/hooks/__init__.py create mode 100644 tests/hooks/test_facade.py diff --git a/goga/hooks/__init__.py b/goga/hooks/__init__.py new file mode 100644 index 00000000..5539eb40 --- /dev/null +++ b/goga/hooks/__init__.py @@ -0,0 +1,20 @@ +"""Hooks platform facade — the extension surface of the goga domains. + +The single consumer entry point of the platform for installed tool packages +and for the domains: it re-exports the declared action catalog, the run +registry with its per-tool inspection view, and the emission of an action at +a domain checkpoint — the emission assembles the registry on first use. The +facade declares no type of its own. Importing the package imports no tool +package and enumerates nothing. +""" + +from .catalog import declared_actions +from .dispatch import emit_hook_event +from .registry import HookRegistry, ToolHooks + +__all__: list[str] = [ + "HookRegistry", + "ToolHooks", + "declared_actions", + "emit_hook_event", +] diff --git a/tests/hooks/test_facade.py b/tests/hooks/test_facade.py new file mode 100644 index 00000000..1fd148b0 --- /dev/null +++ b/tests/hooks/test_facade.py @@ -0,0 +1,55 @@ +"""Contract and logic tests for the cell declared in +``goga/hooks/CODEMANIFEST`` — the facade of the hooks platform. + +The facade declares no type of its own: it re-exports the four embeddings +consumers address the platform through — ``declared_actions``, +``HookRegistry``, ``ToolHooks``, and ``emit_hook_event`` — each identical to +the object its subcell package owns. The facade import is cheap: reloading +the package reads no installed-distribution mapping and builds no registry. +""" + +from __future__ import annotations + +import importlib + +import goga.hooks +from goga.hooks import catalog as catalog_source +from goga.hooks import dispatch as dispatch_source +from goga.hooks import registry as registry_source + +_FACADE_ALL = ["HookRegistry", "ToolHooks", "declared_actions", "emit_hook_event"] + + +class TestHooksPlatformFacade: + def test_all_lists_exactly_the_four_reexports(self) -> None: + """The facade declares exactly the four embeddings, alphabetically.""" + assert goga.hooks.__all__ == _FACADE_ALL + + def test_declared_actions_is_the_catalog_object(self) -> None: + """declared_actions is the catalog routine, not a copy of it.""" + assert goga.hooks.declared_actions is catalog_source.declared_actions + + def test_hook_registry_is_the_registry_object(self) -> None: + """HookRegistry is the registry class, not a copy of it.""" + assert goga.hooks.HookRegistry is registry_source.HookRegistry + + def test_tool_hooks_is_the_registry_object(self) -> None: + """ToolHooks is the registry record, not a copy of it.""" + assert goga.hooks.ToolHooks is registry_source.ToolHooks + + def test_emit_hook_event_is_the_dispatch_object(self) -> None: + """emit_hook_event is the emission routine, not a copy of it.""" + assert goga.hooks.emit_hook_event is dispatch_source.emit_hook_event + + def test_every_declared_name_is_importable(self) -> None: + """Each name of ``__all__`` resolves to a real attribute of the facade.""" + for name in goga.hooks.__all__: + assert getattr(goga.hooks, name) is not None + + def test_importing_the_facade_enumerates_no_packages(self, pin_package_environment) -> None: + """The facade import reads no installed-distribution mapping.""" + boundary = pin_package_environment({}) + + importlib.reload(goga.hooks) + + boundary.assert_not_called() From 78964a5e87702f36874f6d6bea3a909d592f6424 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 04:36:34 +0000 Subject: [PATCH 139/229] feat: migrate the statuses domain onto the hooks platform (assemble_status_scale emits the status action) --- goga/history/statuses/__init__.py | 8 +- goga/history/statuses/assembly.py | 115 ++--- goga/history/statuses/registry.py | 8 +- .../commands/history/test_history_command.py | 28 +- tests/history/statuses/test_assembly.py | 425 +++++++++--------- 5 files changed, 298 insertions(+), 286 deletions(-) diff --git a/goga/history/statuses/__init__.py b/goga/history/statuses/__init__.py index 981b4e49..20659703 100644 --- a/goga/history/statuses/__init__.py +++ b/goga/history/statuses/__init__.py @@ -1,9 +1,9 @@ """Status scale cell — the owner of the topic status scale. -The built-in artifact axis, the registration of tool statuses over installed -``goga_tool_*`` packages, and the computation of a topic's maximal present -statuses. Pure scale logic — no filesystem probing of topic directories, no -git access, no CLI, no output rendering. +The built-in artifact axis, the registration of tool statuses through the +status action of the hooks platform, and the computation of a topic's +maximal present statuses. Pure scale logic — no filesystem probing of topic +directories, no git access, no CLI, no output rendering. """ from .assembly import assemble_status_scale diff --git a/goga/history/statuses/assembly.py b/goga/history/statuses/assembly.py index 3982971a..9128590b 100644 --- a/goga/history/statuses/assembly.py +++ b/goga/history/statuses/assembly.py @@ -1,19 +1,19 @@ """The scale assembly routine of the statuses cell. The routine declared in the cell CODEMANIFEST with ``location: assembly.py``: -the full status scale — the built-in axis extended by every installed tool -package. The assembly runs at every command start that needs the scale; the -scale is never cached across runs. A registration problem never aborts the -command — a package import failure is the only fatal case. +the full status scale — the built-in axis extended by every tool subscribed +to the status action of the hooks platform. The cell emits the action and +places the registrations delivered through it; the tool packages are carried +by the platform. The assembly runs at every command start that needs the +scale; the scale is never cached across runs. A broken package import is the +only fatal case — it surfaces through the emission. """ from __future__ import annotations import sys -from importlib import import_module -from importlib.metadata import packages_distributions -from types import ModuleType +from ...hooks import HookRegistry, emit_hook_event from .registry import StatusRegistry from .scale import Stage, StatusScale @@ -29,91 +29,72 @@ Stage(name="done", filepath="completed/plan.md"), ] +_ACTION_DOMAIN = "statuses" +_ACTION_NAME = "register_statuses" + def assemble_status_scale() -> StatusScale: - """Assemble the full status scale — the built-in axis extended by every installed tool package. + """Assemble the full status scale — the built-in axis extended by every subscribed tool. Returns: scale: The assembled scale. Algorithm: 1. Build the built-in axis of nine entries - 2. Enumerate the installed goga_tool_* packages in alphabetical - order of package name - 3. Import each package — a broken import is a clean error naming - the package - 4. A package without the callback of the ``registration`` practice - is skipped silently - 5. Call the callback with a registry scoped to the package - 6. Any exception from the callback — a registration content error - or a crashed callback — skips that registration with a warning - to stderr; the package import failure of step 3 remains the - only fatal case - 7. Resolve anchors and validate placement ranges; an unresolvable - anchor or an invalid range skips the registration with a - warning to stderr - 8. Assemble and return the scale + 2. Create the run registry via ``HookRegistry`` + 3. Emit the status action through the platform: the context view of + one receiving tool is a ``StatusRegistry`` over the axis, + qualified by the tool identity — at most one registry per tool + identity, all hooks of the tool share it + 4. Collect the delivered registries in enumeration order + 5. Resolve the anchors of each surviving entry against the list + assembled by the moment the entry is processed — the built-in + axis plus the entries of the earlier tools and the earlier + entries of the current one; an unresolvable anchor or an invalid + range skips the registration with a warning to stderr + 6. Assemble and return the scale Requirements: The scale assembles from the surviving registrations alone — one - broken registration never cancels the rest. Package enumeration - mirrors goga/connect: ``importlib.metadata.packages_distributions()`` - filtered to top-level module names starting with ``goga_tool_``, - sorted alphabetically by top-level module name. + broken registration never cancels the rest. The emission performs + the single build of the run; a broken package import surfacing + through it is the only fatal case. Constraints: - Do not cache the scale across command runs. Do not let a - registration problem abort the command. + Do not enumerate the installed tool packages and do not import their + facades — the platform carries the tool packages. Do not cache the + scale across command runs. Placement follows the anchors of each surviving entry, resolved against - the list assembled by the moment the entry is processed — the built-in - axis plus the entries of the earlier packages and the earlier entries of - the current one. Entries sharing an anchor form one continuous block in - registration order: an ``after``-anchored entry lands at the end of its - anchor's block, a ``before``-anchored entry right in front of its anchor, - and both anchors given define a range the entry must fit into. + the list assembled by the moment the entry is processed. Entries sharing + an anchor form one continuous block in registration order: an + ``after``-anchored entry lands at the end of its anchor's block, a + ``before``-anchored entry right in front of its anchor, and both anchors + given define a range the entry must fit into. """ + registry = HookRegistry() + registries: dict[str, StatusRegistry] = {} + + def context_for(tool: str) -> StatusRegistry: + """Build the context view of one receiving tool — at most one registry per tool identity.""" + if tool not in registries: + registries[tool] = StatusRegistry(builtin_stages=list(_BUILTIN_AXIS), tool_prefix=tool) + return registries[tool] + + emit_hook_event(registry, _ACTION_DOMAIN, _ACTION_NAME, context_for) + stages = list(_BUILTIN_AXIS) - for package_name in _tool_packages(): - module = _import_tool_package(package_name) - callback = getattr(module, "register_topic_statuses", None) - if not callable(callback): - continue - registry = StatusRegistry( - builtin_stages=list(_BUILTIN_AXIS), - tool_prefix=package_name.removeprefix("goga_tool_"), - ) - try: - callback(registry) - except Exception as exc: - print(f"Warning: skipping status registration in {package_name}: {exc}", file=sys.stderr) - for entry in registry.stages[len(_BUILTIN_AXIS) :]: + for status_registry in registries.values(): + for entry in status_registry.stages[len(_BUILTIN_AXIS) :]: try: index = _placement_index(stages, entry) except ValueError as exc: - print(f"Warning: skipping status registration in {package_name}: {exc}", file=sys.stderr) + print(f"Warning: skipping status registration {entry.name}: {exc}", file=sys.stderr) continue stages.insert(index, entry) return StatusScale(stages=stages) -def _tool_packages() -> list[str]: - """The installed ``goga_tool_*`` top-level names in alphabetical order.""" - return sorted(name for name in packages_distributions() if name.startswith("goga_tool_")) - - -def _import_tool_package(name: str) -> ModuleType: - """Import one tool package — a broken import is a clean error naming the package. - - Raises: - ImportError: The package failed to import. - """ - try: - return import_module(name) - except Exception as exc: - raise ImportError(f"package {name} failed to import: {exc}") from exc - - def _placement_index(stages: list[Stage], entry: Stage) -> int: """Resolve the anchors of one accepted entry to an insertion index. diff --git a/goga/history/statuses/registry.py b/goga/history/statuses/registry.py index 21c79408..8e10fbe0 100644 --- a/goga/history/statuses/registry.py +++ b/goga/history/statuses/registry.py @@ -19,13 +19,15 @@ class StatusRegistry: """The controlled registration surface handed to a tool package. - The only way a tool status enters the scale. Registration is add-only — - a built-in entry is never modified, removed, or re-anchored. + The only way a tool status enters the scale. One registry instance is + the context view delivered to the hook of one subscribed tool; the hook + registers through it and nothing else. Registration is add-only — a + built-in entry is never modified, removed, or re-anchored. Attributes: builtin_stages: The immutable built-in axis the registry extends. tool_prefix: The qualifier applied to every name registered through - this registry — derived from the package name. + this registry — the tool identity of the receiving hook. Requirements: Registration is add-only — a built-in entry is never modified, diff --git a/tests/commands/history/test_history_command.py b/tests/commands/history/test_history_command.py index afaaba04..352a17bd 100644 --- a/tests/commands/history/test_history_command.py +++ b/tests/commands/history/test_history_command.py @@ -28,7 +28,6 @@ from click.testing import CliRunner from goga.commands.history import history from goga.history import naming -from goga.history.statuses import assembly as assembly_module # goga.commands.history.history is shadowed in the package __init__ by the # history click group, so attribute access through the package gives the @@ -47,21 +46,26 @@ def now() -> datetime: def _fake_tool_packages(monkeypatch: pytest.MonkeyPatch) -> None: """Isolate the scale assembly to one fake tool package. - ``goga_tool_mkdocs`` registers ``published`` anchored after ``planned``, - so a topic carrying both ``plan.md`` and ``mkdocs/published.md`` has the - single maximal status ``mkdocs.published``. The enumeration patch keeps - the real tool packages of the environment out of the assembled scale. + ``goga_tool_mkdocs`` subscribes ``published`` on the status action and + registers it anchored after ``planned``, so a topic carrying both + ``plan.md`` and ``mkdocs/published.md`` has the single maximal status + ``mkdocs.published``. The enumeration patch keeps the real tool packages + of the environment out of the assembled scale. """ - def register_topic_statuses(statuses: Any) -> None: - statuses.register(name="published", filepath="mkdocs/published.md", after="planned") + def register_hooks(hooks: Any) -> None: + hooks.subscribe( + "statuses", + "register_statuses", + "published", + lambda context: context.register(name="published", filepath="mkdocs/published.md", after="planned"), + ) module = ModuleType("goga_tool_mkdocs") - module.register_topic_statuses = register_topic_statuses + module.register_hooks = register_hooks monkeypatch.setitem(sys.modules, "goga_tool_mkdocs", module) monkeypatch.setattr( - assembly_module, - "packages_distributions", + "goga.hooks.tools.packages.packages_distributions", lambda: {"goga_tool_mkdocs": ["goga-tool-mkdocs"]}, ) @@ -210,7 +214,7 @@ def test_history_status_filter_new_selects_titled_topics( (year_dir / "feat-b").mkdir() (year_dir / "feat-b" / "prd.md").write_text("prd\n", encoding="utf-8") monkeypatch.chdir(tmp_path) - monkeypatch.setattr(assembly_module, "packages_distributions", lambda: {}) + monkeypatch.setattr("goga.hooks.tools.packages.packages_distributions", lambda: {}) result = CliRunner().invoke(history, ["status", "2026", "-s", "new"]) @@ -227,7 +231,7 @@ def test_history_status_filter_new_skips_defined_topics( (year_dir / "feat-b" / "title.txt").write_text("Title\n", encoding="utf-8") (year_dir / "feat-b" / "prd.md").write_text("prd\n", encoding="utf-8") monkeypatch.chdir(tmp_path) - monkeypatch.setattr(assembly_module, "packages_distributions", lambda: {}) + monkeypatch.setattr("goga.hooks.tools.packages.packages_distributions", lambda: {}) result = CliRunner().invoke(history, ["status", "2026", "-s", "new"]) diff --git a/tests/history/statuses/test_assembly.py b/tests/history/statuses/test_assembly.py index 18d59ff5..cdba7f9b 100644 --- a/tests/history/statuses/test_assembly.py +++ b/tests/history/statuses/test_assembly.py @@ -2,27 +2,33 @@ ``goga/history/statuses/CODEMANIFEST`` with ``location: assembly.py``: - ``assemble_status_scale() -> scale`` — the built-in axis extended by every - installed tool package - -The package enumeration and imports are mocked at the point of import; fake -packages are injected through ``sys.modules``. Warnings are checked with -``capsys``. + tool subscribed to the status action + +The cell emits the action through the hooks platform; the tests fake the +emission by monkeypatching ``emit_hook_event`` in the assembly namespace — a +fake that captures the emission arguments and drives ``context_for`` the way +the real emission would: one view per distinct tool, hooks called in +enumeration order. The package enumeration belongs to the platform and is +pinned at its own boundary when asserted. The platform-side failure handling +(a crashed hook, a broken facade import inside the build) is covered by +``tests/hooks/``; the fatal import case is asserted here by letting the fake +re-raise it. Warnings are checked with ``capsys``. """ from __future__ import annotations import inspect -import sys from collections.abc import Callable -from types import ModuleType from typing import Any +from unittest import mock import pytest from goga.history import statuses as cell from goga.history.statuses import Stage, StatusScale, assemble_status_scale from goga.history.statuses import assembly as assembly_module +from goga.hooks import HookRegistry -Registration = Callable[[Any], None] +Hook = Callable[[Any], None] _BUILTIN_NAMES = [ "empty", @@ -36,38 +42,61 @@ "done", ] +_ENUMERATION_TARGET = "goga.hooks.tools.packages.packages_distributions" -def _install_package(monkeypatch: pytest.MonkeyPatch, name: str, attribute: Any) -> ModuleType: - """Inject a fake ``goga_tool_*`` package into ``sys.modules``. - ``attribute`` is the value the module carries as - ``register_topic_statuses`` — a callable callback, a non-callable value, - or ``None`` for a package without the attribute. - """ - module = ModuleType(name) - if attribute is not None: - module.register_topic_statuses = attribute - monkeypatch.setitem(sys.modules, name, module) - return module +def _hook(*registrations: dict[str, Any]) -> Hook: + """A hook that registers the given entries in order through the delivered context.""" + def register(context: Any) -> None: + for registration in registrations: + context.register(**registration) -def _registering(*registrations: dict[str, Any]) -> Registration: - """A callback that registers the given entries in order.""" + return register - def register_topic_statuses(statuses: Any) -> None: - for registration in registrations: - statuses.register(**registration) - return register_topic_statuses +def _fake_emission( + monkeypatch: pytest.MonkeyPatch, + tools: list[tuple[str, Hook]], +) -> dict[str, Any]: + """Replace the emission with a fake driving ``tools`` the way the real one would. + ``tools`` pairs a tool identity with its hook, in the order the emission + delivers them — the enumeration order of the platform. The fake captures + the registry, the address, and ``context_for``, builds one view per + distinct tool at the tool's first subscription, and calls each hook with + its tool's view. -def _packages(monkeypatch: pytest.MonkeyPatch, *names: str) -> None: - """Patch the package enumeration to exactly ``names``.""" - monkeypatch.setattr( - assembly_module, - "packages_distributions", - lambda: {name: [f"dist-{name}"] for name in names}, - ) + Args: + monkeypatch: the pytest patcher restoring the emission on teardown. + tools: the ``(tool, hook)`` pairs the fake drives; the list is read + at call time, so a test may append between two assemblies. + + Returns: + The captured emission arguments — ``registry``, ``domain``, + ``action``, ``context_for``. + """ + captured: dict[str, Any] = {} + + def emit_hook_event( + registry: HookRegistry, + domain: str, + action: str, + context_for: Callable[[str], Any], + ) -> None: + captured["registry"] = registry + captured["domain"] = domain + captured["action"] = action + captured["context_for"] = context_for + + views: dict[str, Any] = {} + for tool, hook in tools: + if tool not in views: + views[tool] = context_for(tool) + hook(views[tool]) + + monkeypatch.setattr(assembly_module, "emit_hook_event", emit_hook_event) + return captured def _names(scale: StatusScale) -> list[str]: @@ -89,7 +118,7 @@ def test_routine_takes_no_arguments(self) -> None: def test_routine_returns_a_status_scale(self, monkeypatch: pytest.MonkeyPatch) -> None: """``-> scale: StatusScale`` — the return carries a ``stages`` attribute.""" - _packages(monkeypatch) + _fake_emission(monkeypatch, []) scale = assemble_status_scale() @@ -99,49 +128,96 @@ def test_routine_returns_a_status_scale(self, monkeypatch: pytest.MonkeyPatch) - def test_routine_does_not_cache_across_runs(self, monkeypatch: pytest.MonkeyPatch) -> None: """Every call assembles a fresh scale — no caching between runs.""" - _packages(monkeypatch) - first = assemble_status_scale() - _install_package( - monkeypatch, - "goga_tool_a", - _registering({"name": "x", "filepath": "a/x.md", "after": "planned"}), - ) - _packages(monkeypatch, "goga_tool_a") + tools: list[tuple[str, Hook]] = [] + _fake_emission(monkeypatch, tools) + first = assemble_status_scale() + tools.append(("a", _hook({"name": "x", "filepath": "a/x.md", "after": "planned"}))) second = assemble_status_scale() assert _names(first) == _BUILTIN_NAMES assert _names(second) != _names(first) assert second.stages is not first.stages + def test_module_owns_no_package_enumeration(self) -> None: + """The platform carries the tool packages — the cell owns neither name nor helper.""" + for name in ("packages_distributions", "import_module", "_tool_packages", "_import_tool_package"): + assert not hasattr(assembly_module, name) + # --- Logic tests --- +class TestAssembleEmission: + def test_assemble_emits_the_status_action_with_per_tool_registries( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The cell emits the declared address; the view qualifies entries by the tool identity.""" + hook = _hook({"name": "pub", "filepath": "p.md", "after": "planned"}) + + captured = _fake_emission(monkeypatch, [("alpha", hook)]) + scale = assemble_status_scale() + + assert captured["domain"] == "statuses" + assert captured["action"] == "register_statuses" + assert isinstance(captured["registry"], HookRegistry) + names = _names(scale) + assert "alpha.pub" in names + assert names.index("alpha.pub") == names.index("planned") + 1 + + def test_assemble_no_package_enumeration_in_the_cell(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The platform owns the enumeration — the assembly never reads the environment.""" + _fake_emission(monkeypatch, []) + + with mock.patch(_ENUMERATION_TARGET) as enumeration: + assemble_status_scale() + + enumeration.assert_not_called() + + def test_assemble_registry_without_registrations_leaves_pure_axis( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A subscribed tool whose hook registers nothing leaves the pure built-in axis.""" + _fake_emission(monkeypatch, [("quiet", lambda _context: None)]) + + scale = assemble_status_scale() + + assert _names(scale) == _BUILTIN_NAMES + + def test_assemble_two_tools_same_anchor_form_registration_order_block( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Two tools delivered to the same anchor stack in delivery order — one block.""" + _fake_emission( + monkeypatch, + [ + ("a", _hook({"name": "pub", "filepath": "a/p.md", "after": "planned"})), + ("b", _hook({"name": "pub", "filepath": "b/p.md", "after": "planned"})), + ], + ) + + scale = assemble_status_scale() + + names = _names(scale) + planned = names.index("planned") + assert names[planned + 1 : planned + 3] == ["a.pub", "b.pub"] + assert names[planned + 3] == "done" + + class TestAssembleBuiltinAxis: def test_assemble_status_scale_builds_nine_entry_axis(self, monkeypatch: pytest.MonkeyPatch) -> None: """The built-in axis counts nine entries — ``new``/``title.txt`` second, no regress to eight.""" - _packages(monkeypatch) + _fake_emission(monkeypatch, []) scale = assemble_status_scale() - assert _names(scale)[:9] == [ - "empty", - "new", - "defined", - "discovered", - "backlog", - "designed", - "specified", - "planned", - "done", - ] + assert _names(scale)[:9] == _BUILTIN_NAMES assert scale.stages[1].filepath == "title.txt" assert len(scale.stages) == 9 def test_assemble_builtin_axis_order(self, monkeypatch: pytest.MonkeyPatch) -> None: - """No tool packages — the pure built-in axis in the contract order.""" - _packages(monkeypatch) + """No subscribed tools — the pure built-in axis in the contract order.""" + _fake_emission(monkeypatch, []) scale = assemble_status_scale() @@ -158,29 +234,17 @@ def test_assemble_builtin_axis_order(self, monkeypatch: pytest.MonkeyPatch) -> N "completed/plan.md", ] - def test_assemble_non_callable_callback_skipped( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] - ) -> None: - """A package whose callback attribute is not callable is skipped silently.""" - _install_package(monkeypatch, "goga_tool_bad", 42) - _packages(monkeypatch, "goga_tool_bad") - - scale = assemble_status_scale() - - assert _names(scale) == _BUILTIN_NAMES - assert capsys.readouterr().err == "" - class TestAssemblePlacement: def test_assemble_places_anchored_statuses(self, monkeypatch: pytest.MonkeyPatch) -> None: """``after`` lands right after its anchor; ``before`` right before its anchor.""" - _install_package( - monkeypatch, "goga_tool_a", _registering({"name": "x", "filepath": "a/x.md", "after": "planned"}) - ) - _install_package( - monkeypatch, "goga_tool_b", _registering({"name": "y", "filepath": "b/y.md", "before": "done"}) + _fake_emission( + monkeypatch, + [ + ("a", _hook({"name": "x", "filepath": "a/x.md", "after": "planned"})), + ("b", _hook({"name": "y", "filepath": "b/y.md", "before": "done"})), + ], ) - _packages(monkeypatch, "goga_tool_a", "goga_tool_b") scale = assemble_status_scale() @@ -190,12 +254,10 @@ def test_assemble_places_anchored_statuses(self, monkeypatch: pytest.MonkeyPatch def test_assemble_both_anchors_range(self, monkeypatch: pytest.MonkeyPatch) -> None: """Both anchors define a range — the entry lands inside it.""" - _install_package( + _fake_emission( monkeypatch, - "goga_tool_a", - _registering({"name": "x", "filepath": "a/x.md", "after": "defined", "before": "backlog"}), + [("a", _hook({"name": "x", "filepath": "a/x.md", "after": "defined", "before": "backlog"}))], ) - _packages(monkeypatch, "goga_tool_a") scale = assemble_status_scale() @@ -206,15 +268,18 @@ def test_assembly_anchors_around_new_axis( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """Anchors around ``empty``/``new``/``defined`` stay resolvable on the nine-entry axis.""" - _install_package( + _fake_emission( monkeypatch, - "goga_tool_x", - _registering( - {"name": "ranged", "filepath": "x/ranged.md", "after": "empty", "before": "defined"}, - {"name": "afternew", "filepath": "x/afternew.md", "after": "new"}, - ), + [ + ( + "x", + _hook( + {"name": "ranged", "filepath": "x/ranged.md", "after": "empty", "before": "defined"}, + {"name": "afternew", "filepath": "x/afternew.md", "after": "new"}, + ), + ) + ], ) - _packages(monkeypatch, "goga_tool_x") scale = assemble_status_scale() @@ -226,94 +291,99 @@ def test_assembly_anchors_around_new_axis( def test_assemble_invalid_anchor_range_skips_with_warning( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - """An inverted range is invalid — the entry is skipped with a warning.""" - _install_package( + """An inverted range is invalid — the entry is skipped with a warning naming the entry.""" + _fake_emission( monkeypatch, - "goga_tool_a", - _registering({"name": "x", "filepath": "a/x.md", "after": "backlog", "before": "defined"}), + [("a", _hook({"name": "x", "filepath": "a/x.md", "after": "backlog", "before": "defined"}))], ) - _packages(monkeypatch, "goga_tool_a") scale = assemble_status_scale() assert "a.x" not in _names(scale) stderr = capsys.readouterr().err - assert "Warning" in stderr - assert "goga_tool_a" in stderr + assert "Warning: skipping status registration a.x" in stderr + assert "anchor range" in stderr - def test_assemble_unresolvable_anchor_skips( + def test_assemble_unresolvable_anchor_warns_and_skips_entry( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - """An anchor naming no entry of the scale skips the registration.""" - _install_package( + """An anchor naming no entry of the scale skips only its own entry — the rest survives.""" + _fake_emission( monkeypatch, - "goga_tool_a", - _registering({"name": "x", "filepath": "a/x.md", "after": "nonexistent.status"}), + [ + ( + "a", + _hook( + {"name": "good", "filepath": "g.md", "after": "planned"}, + {"name": "bad", "filepath": "b.md", "after": "nonexistent"}, + ), + ) + ], ) - _packages(monkeypatch, "goga_tool_a") scale = assemble_status_scale() - assert "a.x" not in _names(scale) - stderr = capsys.readouterr().err - assert "Warning" in stderr - assert "goga_tool_a" in stderr - assert _names(scale) == _BUILTIN_NAMES + names = _names(scale) + assert "a.good" in names + assert "a.bad" not in names + assert "Warning: skipping status registration a.bad" in capsys.readouterr().err def test_assemble_unresolvable_before_anchor_skips( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """A ``before`` anchor naming no entry of the scale skips the registration.""" - _install_package( + _fake_emission( monkeypatch, - "goga_tool_a", - _registering({"name": "x", "filepath": "a/x.md", "before": "nonexistent.status"}), + [("a", _hook({"name": "x", "filepath": "a/x.md", "before": "nonexistent.status"}))], ) - _packages(monkeypatch, "goga_tool_a") scale = assemble_status_scale() assert "a.x" not in _names(scale) stderr = capsys.readouterr().err - assert "Warning" in stderr - assert "goga_tool_a" in stderr + assert "Warning: skipping status registration a.x" in stderr + assert "unknown before anchor" in stderr assert _names(scale) == _BUILTIN_NAMES - def test_assemble_same_anchor_block_keeps_registration_order(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Two packages anchoring after the same entry form a block in package order. + def test_assemble_same_anchor_block_follows_delivery_order( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The block follows the delivery order of the emission — the cell does not sort tools. - The design-review q1 regression: a bare ``insert(pos(A) + 1)`` would - reverse the block — the alphabetical package order must win. The - enumeration map is handed to the packages in reverse order so the - assertion depends on the alphabetical sort, not on dict insertion - order. + The design-review q1 regression, re-based: sorting the packages is + the platform's contract now, so the tools are delivered in reverse + alphabetical order here and the assembly must still place them in + delivery order — the order the registries were handed over in. """ - _install_package( - monkeypatch, "goga_tool_a", _registering({"name": "x", "filepath": "a/x.md", "after": "planned"}) - ) - _install_package( - monkeypatch, "goga_tool_b", _registering({"name": "y", "filepath": "b/y.md", "after": "planned"}) + _fake_emission( + monkeypatch, + [ + ("b", _hook({"name": "y", "filepath": "b/y.md", "after": "planned"})), + ("a", _hook({"name": "x", "filepath": "a/x.md", "after": "planned"})), + ], ) - _packages(monkeypatch, "goga_tool_b", "goga_tool_a") scale = assemble_status_scale() names = _names(scale) planned = names.index("planned") - assert names[planned + 1 : planned + 3] == ["a.x", "b.y"] + assert names[planned + 1 : planned + 3] == ["b.y", "a.x"] assert names[planned + 3] == "done" - def test_assemble_two_entries_of_one_package_same_anchor(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Two entries of one package sharing an anchor also stack in order.""" - _install_package( + def test_assemble_two_entries_of_one_tool_same_anchor(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Two entries of one tool sharing an anchor also stack in order.""" + _fake_emission( monkeypatch, - "goga_tool_a", - _registering( - {"name": "x", "filepath": "a/x.md", "after": "planned"}, - {"name": "z", "filepath": "a/z.md", "after": "planned"}, - ), + [ + ( + "a", + _hook( + {"name": "x", "filepath": "a/x.md", "after": "planned"}, + {"name": "z", "filepath": "a/z.md", "after": "planned"}, + ), + ) + ], ) - _packages(monkeypatch, "goga_tool_a") scale = assemble_status_scale() @@ -321,26 +391,26 @@ def test_assemble_two_entries_of_one_package_same_anchor(self, monkeypatch: pyte planned = names.index("planned") assert names[planned + 1 : planned + 3] == ["a.x", "a.z"] - def test_assemble_tool_prefix_strips_package_qualifier(self, monkeypatch: pytest.MonkeyPatch) -> None: - """P1 — the prefix is the top-level name without the ``goga_tool_`` part.""" - _install_package( + def test_assemble_prefix_is_the_delivered_tool_identity(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The qualifier is the tool identity the emission delivers — hyphen form included.""" + _fake_emission( monkeypatch, - "goga_tool_hello_world", - _registering({"name": "x", "filepath": "hw/x.md", "after": "planned"}), + [("hello-world", _hook({"name": "x", "filepath": "hw/x.md", "after": "planned"}))], ) - _packages(monkeypatch, "goga_tool_hello_world") scale = assemble_status_scale() - assert "hello_world.x" in _names(scale) + assert "hello-world.x" in _names(scale) def test_assemble_anchor_to_earlier_tool_entry(self, monkeypatch: pytest.MonkeyPatch) -> None: - """An entry may anchor to a tool entry accepted from an earlier package.""" - _install_package( - monkeypatch, "goga_tool_a", _registering({"name": "x", "filepath": "a/x.md", "after": "planned"}) + """An entry may anchor to a tool entry accepted from an earlier tool.""" + _fake_emission( + monkeypatch, + [ + ("a", _hook({"name": "x", "filepath": "a/x.md", "after": "planned"})), + ("b", _hook({"name": "y", "filepath": "b/y.md", "after": "a.x"})), + ], ) - _install_package(monkeypatch, "goga_tool_b", _registering({"name": "y", "filepath": "b/y.md", "after": "a.x"})) - _packages(monkeypatch, "goga_tool_a", "goga_tool_b") scale = assemble_status_scale() @@ -349,65 +419,20 @@ def test_assemble_anchor_to_earlier_tool_entry(self, monkeypatch: pytest.MonkeyP class TestAssembleFailures: - def test_assemble_broken_import_is_fatal(self, monkeypatch: pytest.MonkeyPatch) -> None: - """A package import failure is the only fatal case — a clean error.""" - _packages(monkeypatch, "goga_tool_bad") + def test_assemble_broken_import_is_fatal_through_the_emission( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A broken package import is the only fatal case — it propagates through the emission.""" - def _raise(name: str) -> ModuleType: - raise ModuleNotFoundError(f"No module named {name!r}") + def emit_hook_event( + registry: HookRegistry, + domain: str, + action: str, + context_for: Callable[[str], Any], + ) -> None: + raise ImportError("package goga_tool_bad failed to import: boom") - monkeypatch.setattr(assembly_module, "import_module", _raise) + monkeypatch.setattr(assembly_module, "emit_hook_event", emit_hook_event) with pytest.raises(ImportError, match="goga_tool_bad"): assemble_status_scale() - - def test_assemble_bad_registration_warns_and_continues( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] - ) -> None: - """A second, anchor-less registration skips with a warning; the rest survives.""" - _install_package( - monkeypatch, - "goga_tool_a", - _registering( - {"name": "good", "filepath": "a/good.md", "after": "planned"}, - {"name": "bad", "filepath": "a/bad.md"}, - ), - ) - _install_package( - monkeypatch, "goga_tool_b", _registering({"name": "y", "filepath": "b/y.md", "before": "done"}) - ) - _packages(monkeypatch, "goga_tool_a", "goga_tool_b") - - scale = assemble_status_scale() - - names = _names(scale) - assert "a.good" in names - assert "a.bad" not in names - assert "b.y" in names - stderr = capsys.readouterr().err - assert "Warning: skipping status registration in goga_tool_a" in stderr - - def test_assemble_crashed_callback_warns_and_continues( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] - ) -> None: - """A callback that crashes after its first entry keeps that entry.""" - _install_package(monkeypatch, "goga_tool_a", _crashing_callback) - _install_package( - monkeypatch, "goga_tool_b", _registering({"name": "y", "filepath": "b/y.md", "before": "done"}) - ) - _packages(monkeypatch, "goga_tool_a", "goga_tool_b") - - scale = assemble_status_scale() - - names = _names(scale) - assert "a.first" in names - assert "b.y" in names - stderr = capsys.readouterr().err - assert "Warning: skipping status registration in goga_tool_a" in stderr - assert "boom" in stderr - - -def _crashing_callback(statuses: Any) -> None: - """Register one entry, then crash like a broken third-party callback.""" - statuses.register("first", "a/first.md", after="planned") - raise TypeError("boom") From f5983a31913ddce14b8e4cead2d2a51da7cdab08 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 04:47:22 +0000 Subject: [PATCH 140/229] feat: implement the goga hooks command (registry, --tool slice, tree renderer) --- goga/commands/hooks/__init__.py | 6 + goga/commands/hooks/hooks.py | 72 +++++++++ goga/commands/hooks/render.py | 40 +++++ tests/commands/hooks/__init__.py | 1 + tests/commands/hooks/test_hooks.py | 241 ++++++++++++++++++++++++++++ tests/commands/hooks/test_render.py | 185 +++++++++++++++++++++ 6 files changed, 545 insertions(+) create mode 100644 goga/commands/hooks/__init__.py create mode 100644 goga/commands/hooks/hooks.py create mode 100644 goga/commands/hooks/render.py create mode 100644 tests/commands/hooks/__init__.py create mode 100644 tests/commands/hooks/test_hooks.py create mode 100644 tests/commands/hooks/test_render.py diff --git a/goga/commands/hooks/__init__.py b/goga/commands/hooks/__init__.py new file mode 100644 index 00000000..640aaaa9 --- /dev/null +++ b/goga/commands/hooks/__init__.py @@ -0,0 +1,6 @@ +"""Hooks command cell — the CLI surface of the hooks inspection.""" + +from .hooks import hooks +from .render import render_hooks_tree + +__all__: list[str] = ["hooks", "render_hooks_tree"] diff --git a/goga/commands/hooks/hooks.py b/goga/commands/hooks/hooks.py new file mode 100644 index 00000000..7ecf8fe7 --- /dev/null +++ b/goga/commands/hooks/hooks.py @@ -0,0 +1,72 @@ +"""The ``goga hooks`` command — the inspection of the registered hooks. + +The entity declared in the cell CODEMANIFEST with ``location: hooks.py``: the +``hooks`` click command. It is a thin inspection wrapper — it creates the run +registry, assembles it once, applies the ``--tool`` slice, and hands the view +to the renderer. No registry computation, no delivery, and no action emission +live here: the command reads the registry and states the fact of registration, +never the application of a hook in some command. A broken package import +surfaces as a clean CLI error — stderr, exit 1, no traceback. +""" + +from __future__ import annotations + +import click + +from ...hooks import HookRegistry, ToolHooks +from .render import render_hooks_tree + + +def _slice_view(view: list[ToolHooks], tools: tuple[str, ...]) -> list[ToolHooks]: + """Narrow the per-tool view to the requested tools. + + Args: + view: The per-tool entries of the registry, alphabetical by tool. + tools: The requested tool names — the identities without the package + prefix; empty means every tool. + + Returns: + The entries of the requested tools in view order, followed by one + empty entry per requested name without registrations, in request + order. A repeated request yields one entry; an unknown name is not an + error. + """ + requested = list(dict.fromkeys(tools)) + known = {entry.tool for entry in view} + selected = [entry for entry in view if entry.tool in known & set(requested)] + missing = [ToolHooks(tool=name, subscriptions=[], rejections=[]) for name in requested if name not in known] + return selected + missing + + +@click.command() +@click.option("--tool", "-t", "tools", multiple=True, help="Narrow the tree to the named tools (repeatable).") +@click.pass_context +def hooks(ctx: click.Context, tools: tuple[str, ...] = ()) -> None: + """Inspect the hooks registered by the installed tool packages. + + The registry assembles once and prints as a tree: tool, then domain, then + action, with every refused registration and its reason. A tool with no + subscriptions and no refusals prints its line alone; an empty registry + prints nothing and exits 0. -t/--tool narrows the tree to the named tools + — the tool identity, without the package prefix, as the tool line shows + it; the option is repeatable, and a requested name without registrations + keeps an empty entry, not an error. A broken package import fails the + command with a clean error naming the package. + \f + The ``exit_code`` contract follows the ``goga/commands/history`` + precedent: the callback is annotated ``-> None``, returns nothing, and + exits through ``ctx.exit(0)`` — errors propagate as + ``click.ClickException`` (stderr, exit 1, no traceback). The command + emits no action; only the hook checkpoints of the domains do. + """ + try: + registry = HookRegistry() + registry.build_once() + except ImportError as exc: + raise click.ClickException(str(exc)) from exc + + view = registry.by_tool() + if tools: + view = _slice_view(view, tools) + render_hooks_tree(view) + ctx.exit(0) diff --git a/goga/commands/hooks/render.py b/goga/commands/hooks/render.py new file mode 100644 index 00000000..f7a46ce6 --- /dev/null +++ b/goga/commands/hooks/render.py @@ -0,0 +1,40 @@ +"""Console rendering for the ``goga hooks`` command. + +The entity declared in the cell CODEMANIFEST with ``location: render.py``: +the tree renderer of the per-tool registry view. It is pure output — the view +prints as given, never mutated and never re-sorted; the caller owns the +collection and the slice. Only the domain lines of one tool are ordered, +alphabetically, as the documented tree fixes it. +""" + +from __future__ import annotations + +import click + +from ...hooks import ToolHooks + + +def render_hooks_tree(view: list[ToolHooks]) -> None: + """Render the registry view as the tool tree. + + One tool line per entry — the tools are the top level, there is no root + line. Under a tool, one domain line per distinct domain of its + subscriptions, ordered alphabetically; under a domain, one action line per + subscription of that domain — the action name, two spaces, the bare hook + name. Every refused registration of the tool prints after the domains with + its reason, the attempted name in double quotes. A tool without + subscriptions and without refusals prints its line alone; an empty view + prints nothing. + + Args: + view: The per-tool entries — already sliced by the caller. + """ + for entry in view: + click.echo(entry.tool) + for domain in sorted({subscription.domain for subscription in entry.subscriptions}): + click.echo(f" {domain}") + for subscription in entry.subscriptions: + if subscription.domain == domain: + click.echo(f" {subscription.action} {subscription.name}") + for rejection in entry.rejections: + click.echo(f' rejected {rejection.domain}/{rejection.action} "{rejection.name}": {rejection.reason}') diff --git a/tests/commands/hooks/__init__.py b/tests/commands/hooks/__init__.py new file mode 100644 index 00000000..86aeb96a --- /dev/null +++ b/tests/commands/hooks/__init__.py @@ -0,0 +1 @@ +"""Tests of the hooks command cell — ``goga/commands/hooks``.""" diff --git a/tests/commands/hooks/test_hooks.py b/tests/commands/hooks/test_hooks.py new file mode 100644 index 00000000..a1407caf --- /dev/null +++ b/tests/commands/hooks/test_hooks.py @@ -0,0 +1,241 @@ +"""Contract and logic tests for the entity declared in +``goga/commands/hooks/CODEMANIFEST`` with ``location: hooks.py`` — +``hooks(tools: tuple[str, ...])``. + +The command is a thin inspection wrapper: it creates the run registry, +assembles it once, applies the ``--tool`` slice, and hands the view to the +renderer. The registry is faked at its import point in the command module, so +the tests drive the CLI surface alone; the command is invoked directly — the +root-group registration belongs to the task that follows this cell. +""" + +from __future__ import annotations + +import inspect +import sys +import typing +from typing import Any + +import click +import goga.commands.hooks as facade +import pytest +from click.testing import CliRunner +from goga.commands.hooks import hooks +from goga.hooks import ToolHooks +from goga.hooks.tools import RejectedRegistration, Subscription + +# goga.commands.hooks.hooks is shadowed in the package __init__ by the click +# command, so attribute access through the package gives the command. Resolve +# the real module via sys.modules (precedent: test_history_command.py). +_hooks_module = sys.modules["goga.commands.hooks.hooks"] + +_CELL_ALL = ["hooks", "render_hooks_tree"] + + +class _FakeRegistry: + """Stand-in for ``HookRegistry`` answering a fixed per-tool view.""" + + def __init__(self, view: list[ToolHooks]) -> None: + self._view = view + self.build_calls = 0 + + def build_once(self) -> None: + self.build_calls += 1 + + def by_tool(self) -> list[ToolHooks]: + return self._view + + +class _BrokenRegistry: + """Stand-in for ``HookRegistry`` whose single build fails fatally.""" + + def build_once(self) -> None: + raise ImportError("package goga_tool_bad failed to import: boom") + + def by_tool(self) -> list[ToolHooks]: + raise AssertionError("a broken build never reaches the view") + + +def _install_fake_registry(monkeypatch: pytest.MonkeyPatch, fake: Any) -> Any: + """Pin the registry factory the command reads; return the fake for asserts.""" + monkeypatch.setattr(_hooks_module, "HookRegistry", lambda: fake) + return fake + + +def _subscription(tool: str = "mkdocs", name: str = "published") -> Subscription: + """One statuses-action subscription — the shape the seed catalog carries.""" + return Subscription( + tool=tool, + domain="statuses", + action="register_statuses", + name=name, + hook=lambda: None, + ) + + +def _dup_rejection(tool: str = "mkdocs") -> RejectedRegistration: + """One refused envelope with the documented repeated-name reason.""" + return RejectedRegistration( + tool=tool, + domain="statuses", + action="register_statuses", + name="dup", + reason="repeated name on the same address", + ) + + +# --- Contract tests --- + + +class TestHooksCommandContract: + def test_hooks_is_exported_by_the_cell_facade(self) -> None: + """``hooks`` is importable from the package and listed in its ``__all__``.""" + assert facade.hooks is hooks + assert list(facade.__all__) == _CELL_ALL + + def test_hooks_is_a_click_command_not_a_group(self) -> None: + """The entity is a terminal ``click.Command`` — no subcommands.""" + assert isinstance(hooks, click.Command) + assert not isinstance(hooks, click.Group) + + def test_hooks_carries_one_repeatable_tools_option(self) -> None: + """``--tool``/``-t`` is the single option, repeatable, never ``None``.""" + options = [param for param in hooks.params if isinstance(param, click.Option)] + assert len(options) == 1 + option = options[0] + assert option.name == "tools" + assert option.multiple is True + assert "--tool" in option.opts + assert "-t" in option.opts + assert option.secondary_opts == [] + + def test_hooks_callback_declares_the_empty_tuple_default(self) -> None: + """No ``-t`` passes the empty tuple — never ``None`` (the click practice).""" + signature = inspect.signature(inspect.unwrap(hooks.callback)) + parameter = signature.parameters["tools"] + assert parameter.default == () + assert parameter.annotation in (tuple[str, ...], "tuple[str, ...]") + + def test_hooks_callback_is_annotated_none(self) -> None: + """The callback returns nothing — the exit code is click's, not a return value.""" + hints = typing.get_type_hints(inspect.unwrap(hooks.callback)) + assert hints["tools"] == tuple[str, ...] + assert hints["return"] is type(None) + + def test_hooks_help_text_carries_no_api_sections(self) -> None: + """``--help`` is user-facing help: no Args/Returns/Raises blocks.""" + result = CliRunner().invoke(hooks, ["--help"]) + + assert result.exit_code == 0 + assert "Usage:" in result.output + assert "--tool" in result.output + for forbidden in ("Args:", "Returns:", "Raises:"): + assert forbidden not in result.output + + def test_hooks_help_summary_is_one_concise_line(self) -> None: + """The first docstring line is the command-listing summary.""" + summary = (hooks.help or "").splitlines()[0] + + assert summary.endswith(".") + assert len(summary) < 80 + + +# --- Logic tests --- + + +class TestHooksCommandTree: + def test_hooks_command_renders_the_tree(self, monkeypatch: pytest.MonkeyPatch) -> None: + """One tool line, domain lines, action lines, then the refusal with its reason.""" + view = [ + ToolHooks( + tool="mkdocs", + subscriptions=[_subscription()], + rejections=[_dup_rejection()], + ) + ] + _install_fake_registry(monkeypatch, _FakeRegistry(view)) + + result = CliRunner().invoke(hooks, []) + + assert result.exit_code == 0 + expected = ( + "mkdocs\n" + " statuses\n" + " register_statuses published\n" + ' rejected statuses/register_statuses "dup": repeated name on the same address\n' + ) + assert result.output == expected + + def test_hooks_assembles_the_registry_once(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The single build of the run — one ``build_once`` call, never two.""" + fake = _FakeRegistry([ToolHooks(tool="mkdocs", subscriptions=[_subscription()], rejections=[])]) + _install_fake_registry(monkeypatch, fake) + + result = CliRunner().invoke(hooks, []) + + assert result.exit_code == 0 + assert fake.build_calls == 1 + + def test_hooks_slice_keeps_empty_entry_for_unknown_tool(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A requested name without registrations keeps its tool line alone.""" + view = [ToolHooks(tool="mkdocs", subscriptions=[_subscription()], rejections=[])] + _install_fake_registry(monkeypatch, _FakeRegistry(view)) + + result = CliRunner().invoke(hooks, ["-t", "mkdocs", "-t", "ghost"]) + + assert result.exit_code == 0 + assert result.output.startswith("mkdocs\n") + assert result.output.endswith("ghost\n") + assert "register_statuses" in result.output + + def test_hooks_slice_deduplicates_a_repeated_request(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Asking for one tool twice yields one tool line, not two.""" + view = [ToolHooks(tool="a", subscriptions=[_subscription(tool="a")], rejections=[])] + _install_fake_registry(monkeypatch, _FakeRegistry(view)) + + result = CliRunner().invoke(hooks, ["-t", "a", "-t", "a"]) + + assert result.exit_code == 0 + assert result.output.splitlines().count("a") == 1 + + def test_hooks_slice_keeps_the_view_order_before_the_missing_names(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Known tools print in view order; unknown names follow in request order.""" + view = [ + ToolHooks(tool="b", subscriptions=[_subscription(tool="b")], rejections=[]), + ToolHooks(tool="a", subscriptions=[_subscription(tool="a")], rejections=[]), + ] + _install_fake_registry(monkeypatch, _FakeRegistry(view)) + + result = CliRunner().invoke(hooks, ["-t", "zulu", "-t", "a", "-t", "b"]) + + assert result.exit_code == 0 + assert result.output.splitlines()[0] == "b" + assert result.output.splitlines()[-1] == "zulu" + + +class TestHooksCommandErrors: + def test_hooks_command_broken_import_is_clean_cli_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A broken package import fails with stderr, exit 1, and no traceback.""" + _install_fake_registry(monkeypatch, _BrokenRegistry()) + + result = CliRunner().invoke(hooks, []) + + assert result.exit_code == 1 + assert "goga_tool_bad" in result.stderr + assert "Traceback" not in result.output + assert "Traceback" not in result.stderr + + +class TestHooksCommandEdges: + def test_hooks_empty_registry_prints_nothing_and_exits_zero(self, monkeypatch: pytest.MonkeyPatch) -> None: + """An empty view renders not a single line and is not an error.""" + _install_fake_registry(monkeypatch, _FakeRegistry([])) + + result = CliRunner().invoke(hooks, []) + + assert result.exit_code == 0 + assert result.output == "" + + def test_hooks_never_emits_an_action(self) -> None: + """Inspection reads the registry only — the module carries no emission.""" + assert not hasattr(_hooks_module, "emit_hook_event") diff --git a/tests/commands/hooks/test_render.py b/tests/commands/hooks/test_render.py new file mode 100644 index 00000000..6f43cdb8 --- /dev/null +++ b/tests/commands/hooks/test_render.py @@ -0,0 +1,185 @@ +"""Contract and logic tests for the entity declared in +``goga/commands/hooks/CODEMANIFEST`` with ``location: render.py`` — +``render_hooks_tree(view: list[ToolHooks])``. + +The renderer is pure output: the view prints as given — the tool entries keep +their order, the subscriptions and rejections of an entry are never touched, +and only the domain lines are ordered (alphabetically, per the documented +tree). Output is captured with ``capsys``. +""" + +from __future__ import annotations + +import inspect +import typing +from collections.abc import Callable + +import pytest +from goga.commands.hooks import render_hooks_tree +from goga.hooks import ToolHooks +from goga.hooks.tools import RejectedRegistration, Subscription + + +def _hook() -> Callable[..., object]: + """A placeholder callable — the renderer never calls a hook.""" + return lambda: None + + +def _subscription( + tool: str = "mkdocs", + domain: str = "statuses", + action: str = "register_statuses", + name: str = "published", +) -> Subscription: + """One accepted subscription bound to an address.""" + return Subscription(tool=tool, domain=domain, action=action, name=name, hook=_hook()) + + +def _rejection( + tool: str = "mkdocs", + domain: str = "statuses", + action: str = "register_statuses", + name: str = "dup", + reason: str = "repeated name on the same address", +) -> RejectedRegistration: + """One refused registration envelope with its reason.""" + return RejectedRegistration(tool=tool, domain=domain, action=action, name=name, reason=reason) + + +# --- Contract tests --- + + +class TestRenderContract: + def test_render_hooks_tree_is_exported_by_the_cell_facade(self) -> None: + """``render_hooks_tree`` is importable from the cell package.""" + import goga.commands.hooks as facade + + assert facade.render_hooks_tree is render_hooks_tree + + def test_render_hooks_tree_signature(self) -> None: + """``render_hooks_tree(view: list[ToolHooks]) -> None``.""" + signature = inspect.signature(render_hooks_tree) + assert list(signature.parameters) == ["view"] + assert signature.parameters["view"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + hints = typing.get_type_hints(render_hooks_tree) + assert hints == {"view": list[ToolHooks], "return": type(None)} + + +# --- Logic tests --- + + +class TestRenderTreeFormat: + def test_tool_with_subscriptions_only(self, capsys: pytest.CaptureFixture[str]) -> None: + """A tool line, a domain line per domain, one action line per subscription.""" + view = [ + ToolHooks( + tool="mkdocs", + subscriptions=[ + _subscription(name="published"), + _subscription(name="sync"), + ], + rejections=[], + ) + ] + + render_hooks_tree(view) + + assert capsys.readouterr().out == ( + "mkdocs\n statuses\n register_statuses published\n register_statuses sync\n" + ) + + def test_domain_lines_are_alphabetical_per_tool(self, capsys: pytest.CaptureFixture[str]) -> None: + """The distinct domains of one tool print sorted, whatever the entry order.""" + view = [ + ToolHooks( + tool="t", + subscriptions=[ + _subscription(domain="zeta", action="a", name="n1"), + _subscription(domain="alpha", action="a", name="n2"), + _subscription(domain="zeta", action="b", name="n3"), + ], + rejections=[], + ) + ] + + render_hooks_tree(view) + + assert capsys.readouterr().out == ("t\n alpha\n a n2\n zeta\n a n1\n b n3\n") + + def test_tool_with_a_rejection_only(self, capsys: pytest.CaptureFixture[str]) -> None: + """A refusal prints under its tool with the name in double quotes.""" + view = [ToolHooks(tool="scriba", subscriptions=[], rejections=[_rejection(tool="scriba")])] + + render_hooks_tree(view) + + assert capsys.readouterr().out == ( + 'scriba\n rejected statuses/register_statuses "dup": repeated name on the same address\n' + ) + + def test_tool_without_subscriptions_and_refusals_prints_its_line_alone( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """A sliced empty entry stays in the tree — one bare tool line.""" + view = [ToolHooks(tool="ghost", subscriptions=[], rejections=[])] + + render_hooks_tree(view) + + assert capsys.readouterr().out == "ghost\n" + + def test_empty_view_prints_nothing(self, capsys: pytest.CaptureFixture[str]) -> None: + """An empty registry renders not a single line.""" + render_hooks_tree([]) + assert capsys.readouterr().out == "" + + def test_rejections_print_after_the_domains_of_their_tool(self, capsys: pytest.CaptureFixture[str]) -> None: + """Subscriptions print first, the refusals of the tool after them.""" + view = [ + ToolHooks( + tool="t", + subscriptions=[_subscription()], + rejections=[_rejection()], + ) + ] + + render_hooks_tree(view) + + assert capsys.readouterr().out == ( + "t\n" + " statuses\n" + " register_statuses published\n" + ' rejected statuses/register_statuses "dup": repeated name on the same address\n' + ) + + +class TestRenderReadOnly: + def test_render_hooks_tree_does_not_mutate_or_resort_view(self, capsys: pytest.CaptureFixture[str]) -> None: + """The tool order and the entry contents survive the call unchanged.""" + first = ToolHooks(tool="z", subscriptions=[_subscription(tool="z", name="n1")], rejections=[]) + second = ToolHooks(tool="a", subscriptions=[], rejections=[_rejection(tool="a", name="x")]) + view = [first, second] + snapshot = [ + (entry.tool, [s.name for s in entry.subscriptions], [r.name for r in entry.rejections]) for entry in view + ] + + render_hooks_tree(view) + capsys.readouterr() + + assert [entry.tool for entry in view] == ["z", "a"] + assert [ + (entry.tool, [s.name for s in entry.subscriptions], [r.name for r in entry.rejections]) for entry in view + ] == snapshot + assert view[0] is first + assert view[1] is second + + def test_render_hooks_tree_prints_tools_in_the_given_order(self, capsys: pytest.CaptureFixture[str]) -> None: + """The renderer never re-sorts the tool entries — the caller's order holds.""" + view = [ + ToolHooks(tool="zeta", subscriptions=[_subscription(tool="zeta")], rejections=[]), + ToolHooks(tool="alpha", subscriptions=[_subscription(tool="alpha")], rejections=[]), + ] + + render_hooks_tree(view) + + assert capsys.readouterr().out == ( + "zeta\n statuses\n register_statuses published\nalpha\n statuses\n register_statuses published\n" + ) From dd9f7acdb68ee78369f3b73114d35b540fbe2381 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 04:54:10 +0000 Subject: [PATCH 141/229] feat: register the hooks command in the commands facade and root CLI group --- goga/cli.py | 2 ++ goga/commands/__init__.py | 2 ++ tests/commands/hooks/test_hooks.py | 9 +++--- tests/commands/hooks/test_render.py | 7 ++++- tests/commands/test_commands_facade.py | 19 ++++++++++++- tests/test_cli.py | 38 +++++++++++++++++++++++++- 6 files changed, 70 insertions(+), 7 deletions(-) diff --git a/goga/cli.py b/goga/cli.py index a2895f08..5668026b 100644 --- a/goga/cli.py +++ b/goga/cli.py @@ -10,6 +10,7 @@ connect, contract, history, + hooks, init, install, lint, @@ -77,4 +78,5 @@ def app() -> None: app.add_command(tool) app.add_command(upgrade) app.add_command(history) +app.add_command(hooks) app.add_command(topics) diff --git a/goga/commands/__init__.py b/goga/commands/__init__.py index 5f976762..992220b2 100644 --- a/goga/commands/__init__.py +++ b/goga/commands/__init__.py @@ -3,6 +3,7 @@ from .connect import connect from .contract import contract from .history import history +from .hooks import hooks from .init import init from .install import install, uninstall from .lint import lint @@ -19,6 +20,7 @@ "connect", "contract", "history", + "hooks", "init", "install", "lint", diff --git a/tests/commands/hooks/test_hooks.py b/tests/commands/hooks/test_hooks.py index a1407caf..fc42131f 100644 --- a/tests/commands/hooks/test_hooks.py +++ b/tests/commands/hooks/test_hooks.py @@ -17,16 +17,17 @@ from typing import Any import click -import goga.commands.hooks as facade import pytest from click.testing import CliRunner from goga.commands.hooks import hooks from goga.hooks import ToolHooks from goga.hooks.tools import RejectedRegistration, Subscription -# goga.commands.hooks.hooks is shadowed in the package __init__ by the click -# command, so attribute access through the package gives the command. Resolve -# the real module via sys.modules (precedent: test_history_command.py). +# The goga.commands facade re-exports the click command under the same name as +# the cell package (``from .hooks import hooks``), so attribute access through +# goga.commands gives the command for both the package and its module. Resolve +# the real objects via sys.modules (precedent: test_history_command.py). +facade = sys.modules["goga.commands.hooks"] _hooks_module = sys.modules["goga.commands.hooks.hooks"] _CELL_ALL = ["hooks", "render_hooks_tree"] diff --git a/tests/commands/hooks/test_render.py b/tests/commands/hooks/test_render.py index 6f43cdb8..ce78ab60 100644 --- a/tests/commands/hooks/test_render.py +++ b/tests/commands/hooks/test_render.py @@ -11,6 +11,7 @@ from __future__ import annotations import inspect +import sys import typing from collections.abc import Callable @@ -52,7 +53,11 @@ def _rejection( class TestRenderContract: def test_render_hooks_tree_is_exported_by_the_cell_facade(self) -> None: """``render_hooks_tree`` is importable from the cell package.""" - import goga.commands.hooks as facade + # The goga.commands facade re-exports the click command as + # goga.commands.hooks, shadowing the cell package on attribute access — + # resolve the real package via sys.modules (precedent: + # test_history_command.py). + facade = sys.modules["goga.commands.hooks"] assert facade.render_hooks_tree is render_hooks_tree diff --git a/tests/commands/test_commands_facade.py b/tests/commands/test_commands_facade.py index be4bb689..bf4c5278 100644 --- a/tests/commands/test_commands_facade.py +++ b/tests/commands/test_commands_facade.py @@ -1,11 +1,13 @@ from __future__ import annotations import click -from goga import commands +from goga import app, commands +from goga.commands import hooks as hooks_reexport from goga.commands import install as install_reexport from goga.commands import pipeline as pipeline_reexport from goga.commands import topics as topics_reexport from goga.commands import uninstall as uninstall_reexport +from goga.commands.hooks import hooks as hooks_source from goga.commands.install import install as install_source from goga.commands.install import uninstall as uninstall_source from goga.commands.pipeline import pipeline as pipeline_source @@ -68,3 +70,18 @@ def test_topics_listed_in_all(self) -> None: def test_topics_is_a_click_group_via_facade(self) -> None: """The re-exported topics is a click Group (a command group).""" assert isinstance(topics_reexport, click.Group) + + +class TestHooksFacade: + def test_commands_facade_reexports_hooks_and_root_group_registers_it(self) -> None: + """hooks is re-exported by the facade and registered on the root app group. + + The full registration chain of the hooks inspection command: the facade + re-export carries the source object, ``__all__`` declares it, the + re-export is a plain ``click.Command`` (not a group), and the root + ``app`` carries it under its command name. + """ + assert hooks_reexport is hooks_source + assert "hooks" in commands.__all__ + assert isinstance(hooks_reexport, click.Command) + assert any(cmd.name == "hooks" for cmd in app.commands.values()) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4b1c2406..0578dc4d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -108,6 +108,11 @@ def test_topics_command_registered(self) -> None: assert any(command.name == "topics" for command in app.commands.values()) assert "topics" in app.commands + def test_hooks_command_registered(self) -> None: + """The 'hooks' command is registered on the app group (command.name).""" + assert any(command.name == "hooks" for command in app.commands.values()) + assert "hooks" in app.commands + class TestHelpOutput: def test_help_exit_code_zero(self) -> None: @@ -159,6 +164,12 @@ def test_help_contains_topics(self) -> None: result = runner.invoke(app, ["--help"]) assert "topics" in result.output + def test_help_contains_hooks(self) -> None: + """The --help output lists the 'hooks' command (design checkpoint).""" + runner = CliRunner() + result = runner.invoke(app, ["--help"]) + assert "hooks" in result.output + class TestBuildHelpOutput: def test_build_help_exit_code_zero(self) -> None: @@ -328,10 +339,35 @@ def test_cli_registers_history_group() -> None: assert subcommand in history_help.output assert "history" in commands.__all__ - assert len(commands.__all__) == 15 + assert len(commands.__all__) == 16 assert hasattr(commands, "history") +def test_cli_registers_hooks_command_and_invokes_it_through_the_root_group( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The hooks command is wired into the root app end-to-end. + + Registration chain plus one real dispatch: with the package enumeration + pinned to an empty environment the registry comes up empty, the command + prints nothing, and exits 0 — the deferred app-level invocation of the + Task 9 suite (the command was registered on nothing back there). + """ + monkeypatch.setattr( + "goga.hooks.tools.packages.packages_distributions", + lambda: {}, + ) + runner = CliRunner() + + hooks_help = runner.invoke(app, ["hooks", "--help"]) + assert hooks_help.exit_code == 0 + assert "--tool" in hooks_help.output + + result = runner.invoke(app, ["hooks"]) + assert result.exit_code == 0 + assert result.output == "" + + def test_facades_export_topics() -> None: """Every facade of the feature exports its contract names. From 1c94c0f1d69abb176d0502d992c2a5ebde1dec19 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 05:04:01 +0000 Subject: [PATCH 142/229] feat: migrate the domain-hooks integration tests and add the non-regression guards --- tests/commands/topics/test_topics_command.py | 7 +- tests/integration/test_topic_workflows.py | 76 +++++++++++++++++--- tests/test_cli.py | 20 ++++++ 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/tests/commands/topics/test_topics_command.py b/tests/commands/topics/test_topics_command.py index ed0c1a46..71ab313e 100644 --- a/tests/commands/topics/test_topics_command.py +++ b/tests/commands/topics/test_topics_command.py @@ -277,7 +277,12 @@ def test_topics_status_info_flag_reaches_renderer(self, monkeypatch: pytest.Monk title="Payment retry", ), ] - monkeypatch.setattr(shutil, "get_terminal_size", lambda: os.terminal_size((100, 24))) + # The lambda tolerates any caller signature: pytest's own terminal + # writer probes the width with ``fallback=`` while the patch is live, + # and a zero-arg patch aborts the run as an INTERNALERROR. + monkeypatch.setattr( + shutil, "get_terminal_size", lambda *_args, **_kwargs: os.terminal_size((100, 24)) + ) with mock.patch.object(_topics_module, "collect_topic_board", return_value=records): result = CliRunner().invoke(topics, ["status", "--info"]) assert result.exit_code == 0 diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index 897c0ac1..286bc133 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -49,6 +49,7 @@ import threading from pathlib import Path from types import ModuleType +from typing import Any from unittest import mock import click @@ -58,7 +59,6 @@ from goga.commands.history import history from goga.commands.topics import topics from goga.history import assemble_status_scale, current_year -from goga.history.statuses import assembly as statuses_assembly from goga.topics import create_topic, publish_topic, switch_topic from goga.topics import switching as topics_switching @@ -281,27 +281,36 @@ def test_board_empty_year_prints_nothing_and_exits_zero( class TestHistoryStatusToolFilter: """``goga history status -s <tool>.<name>`` against the real assembly.""" - def test_qualified_tool_status_validates_and_filters(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def test_qualified_tool_status_validates_and_filters( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest + ) -> None: """A registered tool status validates by its qualified name and keeps exactly the topics carrying it.""" scale = assemble_status_scale() qualified = next((stage.name for stage in scale.stages if "." in stage.name), None) if qualified is None: # No installed tool package registers statuses — register a fake - # one. The command surface, the domain, and the assembly below - # stay the real ones; only the package enumeration is pinned. + # one on the hooks platform. The command surface, the domain, + # and the assembly below stay the real ones; only the package + # enumeration is pinned. package = ModuleType("goga_tool_fake") - def register_topic_statuses(statuses: object) -> None: - statuses.register("published", "fake/published.md", after="planned") + def register_hooks(hooks: Any) -> None: + hooks.subscribe( + "statuses", + "register_statuses", + "published", + lambda context: context.register("published", "fake/published.md", after="planned"), + ) - package.register_topic_statuses = register_topic_statuses + package.register_hooks = register_hooks monkeypatch.setitem(sys.modules, "goga_tool_fake", package) - monkeypatch.setattr( - statuses_assembly, - "packages_distributions", - lambda: {"goga_tool_fake": ["goga-tool-fake"]}, + enumeration = mock.patch( + "goga.hooks.tools.packages.packages_distributions", + return_value={"goga_tool_fake": ["goga-tool-fake"]}, ) + enumeration.start() + request.addfinalizer(enumeration.stop) qualified = "fake.published" artifact = "fake/published.md" else: @@ -324,6 +333,51 @@ def register_topic_statuses(statuses: object) -> None: assert "other-topic [defined]" in unfiltered.output +class TestUnmigratedStatusCallback: + """A tool package still on the removed ``register_topic_statuses`` callback.""" + + def test_old_status_callback_is_silently_ignored( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """A package without ``register_hooks`` loses its statuses quietly. + + The old ``register_topic_statuses`` callback no longer exists and is + never called (ADR): the migrated package's statuses assemble into the + scale, the unmigrated one contributes nothing, and stderr carries no + error about it — the quiet loss the migration note of ``docs/tools.md`` + announces. + """ + + def register_hooks(hooks: Any) -> None: + hooks.subscribe( + "statuses", + "register_statuses", + "published", + lambda context: context.register("published", "published.md", after="planned"), + ) + + new_package = ModuleType("goga_tool_newtool") + new_package.register_hooks = register_hooks + old_package = ModuleType("goga_tool_oldtool") + old_package.register_topic_statuses = lambda _statuses: None + + monkeypatch.setitem(sys.modules, "goga_tool_newtool", new_package) + monkeypatch.setitem(sys.modules, "goga_tool_oldtool", old_package) + + with mock.patch( + "goga.hooks.tools.packages.packages_distributions", + return_value={ + "goga_tool_newtool": ["goga-tool-newtool"], + "goga_tool_oldtool": ["goga-tool-oldtool"], + }, + ): + names = [stage.name for stage in assemble_status_scale().stages] + + assert "newtool.published" in names + assert "oldtool.published" not in names + assert "oldtool" not in capsys.readouterr().err + + class TestPipelineTopicProcedureRegression: """The removed ``-b/--branch`` and the ``-t/--topic`` that replaced it.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 0578dc4d..e2a21fd0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -368,6 +368,26 @@ def test_cli_registers_hooks_command_and_invokes_it_through_the_root_group( assert result.output == "" +def test_commands_without_hooks_never_enumerate_packages() -> None: + """Commands that use no hooks never build the registry. + + ``--version``, ``lint --help``, and ``tool --help`` answer without ever + reading the installed-distributions mapping: the enumeration boundary of + the hooks platform stays untouched until a hook-consuming command + actually assembles the registry. The mock counts, so the assertion + covers all three invocations at once. + """ + runner = CliRunner() + + with mock.patch("goga.hooks.tools.packages.packages_distributions") as enumeration: + for args in (["--version"], ["lint", "--help"], ["tool", "--help"]): + result = runner.invoke(app, args) + + assert result.exit_code == 0, args + + enumeration.assert_not_called() + + def test_facades_export_topics() -> None: """Every facade of the feature exports its contract names. From cad5ac0a8330802dfda4a8b8c711e69bae6da44f Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 05:27:27 +0000 Subject: [PATCH 143/229] fix: address code review findings --- README.md | 14 ++- docs/cli/hooks.md | 2 +- docs/tools.md | 2 +- goga/CODEMANIFEST | 2 + goga/commands/.usages/cli-commands.md | 7 +- tests/history/statuses/test_assembly.py | 115 +++++++++++++++++++++- tests/hooks/conftest.py | 15 +-- tests/hooks/dispatch/conftest.py | 13 +-- tests/hooks/registry/test_state.py | 30 +++++- tests/integration/test_topic_workflows.py | 54 +++++----- tests/test_cli.py | 44 +++++++++ 11 files changed, 234 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 67b47c86..a39791de 100644 --- a/README.md +++ b/README.md @@ -397,7 +397,7 @@ Minimal layout, illustrated by a tool named `acme` that ships four subcommands ``` goga_tool_acme/ -├── __init__.py # main(argv: list[str]) — CLI entry point +├── __init__.py # main(argv: list[str]) — CLI entry; optional install()/register_hooks() ├── skills/ │ ├── acme-explore/ │ │ └── SKILL.md # goga-tool-acme-explore @@ -420,14 +420,18 @@ A valid tool **must**: A tool **may** additionally expose an `install(user: str | None = None)` callable in its facade package: `goga install` calls it after a successful pip, passing the initiating user (`SUDO_USER` when goga itself runs under sudo, else the current OS user) only when the parameter is declared keyword-capable. A missing or non-callable `install` is skipped quietly. -A tool **may** also expose a `register_topic_statuses(statuses)` callable to extend the topic status scale with its own artifacts. goga imports every installed `goga_tool_*` package at each command start that computes statuses and calls the callable with a registry scoped to the package: +A tool **may** also expose a `register_hooks(hooks)` callable to extend goga domains with its own hooks — today, the topic status scale. goga calls it when a command first reaches a hook checkpoint that needs statuses, or when you inspect the registry with `goga hooks`; commands that use no hooks never call it: ```python -def register_topic_statuses(statuses): - statuses.register("published", "mkdocs/published.md", after="planned") +def register_hooks(hooks): + hooks.subscribe("statuses", "register_statuses", "published", register_published) + + +def register_published(context): + context.register("published", "mkdocs/published.md", after="planned") ``` -The name is shown qualified as `<tool>.<name>` (here `mkdocs.published`), the filepath is relative to the topic directory (nested paths allowed), and `before=`/`after=` anchor the entry to an existing scale entry (at least one anchor is required; both define a range). Built-in entries are immutable. A bad registration — an unknown anchor, an invalid range, or a crashed callback — is skipped with a warning on stderr and never aborts the command; only a package that fails to import is fatal. +The hook receives the delivered status registry through `context` — read and call freely, attribute assignment is blocked. The name is shown qualified as `<tool>.<name>` (here `mkdocs.published`; the tool identity is the package name with the `goga_tool_` prefix dropped and underscores turned into hyphens, so `goga_tool_hello_world` registers `hello-world.*`), the filepath is relative to the topic directory (nested paths allowed), and `before=`/`after=` anchor the entry to an existing scale entry (at least one anchor is required; both define a range). Built-in entries are immutable. A bad registration — an unknown anchor, an invalid range, or a crashed hook — is skipped with a warning on stderr and never aborts the command; only a package that fails to import is fatal. Run `goga hooks` to inspect what is registered. The removed `register_topic_statuses(statuses)` callback is no longer called — a package still carrying it loses its statuses silently after the update. After publication, install into any project: diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md index 04e41e2b..9311a9a6 100644 --- a/docs/cli/hooks.md +++ b/docs/cli/hooks.md @@ -10,7 +10,7 @@ goga hooks [--tool NAME]... ## Description -`goga hooks` assembles the run registry once — it imports every installed `goga_tool_*` package and runs its `register_hooks` callback — and prints the registrations as a tree: tool, then domain, then action. It states the **fact of registration**, never whether a hook ran in a particular command. See [Tools — Domain extensions](../tools.md#domain-extensions--register_hooks) for the registration contract. +`goga hooks` assembles the run registry once — it imports every installed `goga_tool_*` package and runs its `register_hooks` callback — and prints the registrations as a tree: tool, then domain, then action. It states the **fact of registration**, never whether a hook ran in a particular command. See [Tools — Domain extensions](../tools.md#domain-extensions) for the registration contract. ## The tree diff --git a/docs/tools.md b/docs/tools.md index 4b26025d..1708a985 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -158,7 +158,7 @@ Each action in the catalog fixes how a failing hook is treated. The topic-status At registration: a wrong address, an empty name, or a repeated name on the same address is refused with a stderr warning naming the tool, the action, and the reason — the registration is skipped, the rest apply. A crashing callback is a warning; the registrations made before the crash survive. A broken package import is the only fatal case: a clean error naming the package. -> **Migration note.** The old `register_topic_statuses(statuses)` callback is gone. After a goga update, a package still carrying it loses its statuses **without any diagnostic** — they silently disappear from the scale. Moving to `register_hooks` is the package author's responsibility. +> **Migration note.** The old `register_topic_statuses(statuses)` callback is gone. After a goga update, a package still carrying it loses its statuses **without any diagnostic** — they silently disappear from the scale. Moving to `register_hooks` is the package author's responsibility. Qualified names of packages with underscores in their name change too: the qualifier is the canonical hyphen identity, so `goga_tool_hello_world` now registers `hello-world.published` where it used to register `hello_world.published` — existing `goga history status -s <tool>.<name>` filters must use the hyphen form. ## Optional injections diff --git a/goga/CODEMANIFEST b/goga/CODEMANIFEST index cd561a4a..4d3b8dd3 100644 --- a/goga/CODEMANIFEST +++ b/goga/CODEMANIFEST @@ -14,6 +14,7 @@ Imports: - install - uninstall - history + - hooks - topics Usages: - cli-commands @@ -83,6 +84,7 @@ app(): - `install` - `uninstall` - `history` + - `hooks` - `topics` --- diff --git a/goga/commands/.usages/cli-commands.md b/goga/commands/.usages/cli-commands.md index fffad61c..b3b599e4 100644 --- a/goga/commands/.usages/cli-commands.md +++ b/goga/commands/.usages/cli-commands.md @@ -1,6 +1,6 @@ # CLI Commands — goga/commands facade -The `goga.commands` package is a facade that re-exports 15 CLI commands. Each command is a `click.Command` registered in a click group. Each subcell is an independent Python package (`goga/commands/<name>/`) with implementation in `<name>.py` and re-export through `__init__.py`. +The `goga.commands` package is a facade that re-exports 16 CLI commands. Each command is a `click.Command` registered in a click group. Each subcell is an independent Python package (`goga/commands/<name>/`) with implementation in `<name>.py` and re-export through `__init__.py`. ## Import @@ -22,6 +22,7 @@ from goga.commands import ( install, uninstall, history, + hooks, topics, ) ``` @@ -43,6 +44,7 @@ from goga.commands.upgrade import upgrade from goga.commands.install import install from goga.commands.install import uninstall from goga.commands.history import history +from goga.commands.hooks import hooks from goga.commands.topics import topics ``` @@ -66,6 +68,7 @@ from goga.commands import ( install, uninstall, history, + hooks, topics, ) @@ -89,6 +92,7 @@ app.add_command(upgrade) app.add_command(install) app.add_command(uninstall) app.add_command(history) +app.add_command(hooks) app.add_command(topics) ``` @@ -123,4 +127,5 @@ def test_example(): | `install` | `goga/commands/install/` | Install a goga_tool_* package | | `uninstall` | `goga/commands/install/` | Remove a goga_tool_* package | | `history` | `goga/commands/history/` | Work with the .goga/history/ tree | +| `hooks` | `goga/commands/hooks/` | Inspect registered tool hooks | | `topics` | `goga/commands/topics/` | Topic board, creation, and switching | diff --git a/tests/history/statuses/test_assembly.py b/tests/history/statuses/test_assembly.py index cdba7f9b..cb915d16 100644 --- a/tests/history/statuses/test_assembly.py +++ b/tests/history/statuses/test_assembly.py @@ -9,16 +9,21 @@ fake that captures the emission arguments and drives ``context_for`` the way the real emission would: one view per distinct tool, hooks called in enumeration order. The package enumeration belongs to the platform and is -pinned at its own boundary when asserted. The platform-side failure handling -(a crashed hook, a broken facade import inside the build) is covered by -``tests/hooks/``; the fatal import case is asserted here by letting the fake -re-raise it. Warnings are checked with ``capsys``. +pinned at its own boundary when asserted. The failure tests below the +assembly drive the real platform — the enumeration pinned, the fake facades +mounted in ``sys.modules``, the emission for real — so the surviving- +registration contract of a crashed hook is guarded at this level too; the +platform-internal failure handling is covered by ``tests/hooks/``, and the +fatal import case is asserted here by letting the fake re-raise it. Warnings +are checked with ``capsys``. """ from __future__ import annotations import inspect +import sys from collections.abc import Callable +from types import ModuleType from typing import Any from unittest import mock @@ -103,6 +108,39 @@ def _names(scale: StatusScale) -> list[str]: return [stage.name for stage in scale.stages] +def _real_platform(monkeypatch: pytest.MonkeyPatch, packages: list[tuple[str, Hook]]) -> None: + """Mount fake ``goga_tool_*`` facades and pin the enumeration to them. + + The platform below the assembly — the enumeration boundary, the facade + import, the registrar, and the emission — runs for real; only the + installed-distributions mapping is pinned and the facades are fakes, so + the failure semantics of a hook are exercised end to end. + + Args: + monkeypatch: the pytest patcher restoring the boundary on teardown. + packages: The ``(module_name, register_hooks)`` pairs to mount, in + enumeration order. + """ + mapping: dict[str, list[str]] = {} + + for module_name, register_hooks in packages: + package = ModuleType(module_name) + package.register_hooks = register_hooks + monkeypatch.setitem(sys.modules, module_name, package) + mapping[module_name] = [module_name.replace("_", "-")] + + monkeypatch.setattr(_ENUMERATION_TARGET, lambda: mapping) + + +def _subscribe(name: str, hook: Hook) -> Hook: + """Build a facade callback subscribing one hook under ``name``.""" + + def register_hooks(hooks: Any) -> None: + hooks.subscribe("statuses", "register_statuses", name, hook) + + return register_hooks + + # --- Contract tests --- @@ -419,6 +457,75 @@ def test_assemble_anchor_to_earlier_tool_entry(self, monkeypatch: pytest.MonkeyP class TestAssembleFailures: + def test_assemble_crashed_hook_warns_and_keeps_earlier_registrations( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """A hook that crashes after its first entry keeps that entry. + + The registration made before the crash lives in the tool's delivered + registry, so it still assembles into the scale, and the next tool + still contributes. The soft action turns the crash into a stderr + warning naming the hook, the tool, and the action. + """ + + def crashing(context: Any) -> None: + context.register("first", "a/first.md", after="planned") + raise TypeError("boom") + + _real_platform( + monkeypatch, + [ + ("goga_tool_a", _subscribe("crashed", crashing)), + ( + "goga_tool_b", + _subscribe("y", _hook({"name": "y", "filepath": "b/y.md", "before": "done"})), + ), + ], + ) + + scale = assemble_status_scale() + + names = _names(scale) + assert "a.first" in names + assert "b.y" in names + stderr = capsys.readouterr().err + assert "Warning: hook crashed of tool a failed on statuses.register_statuses: boom" in stderr + + def test_assemble_rejected_registration_warns_and_continues( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """A structural violation inside one hook skips that hook's registration only. + + The anchor-less entry raises inside the delivered registry, the hook + fails softly, and the warning names the entry — the earlier entry of + the same hook and the entries of the other tools survive. + """ + mixed = _hook( + {"name": "good", "filepath": "a/good.md", "after": "planned"}, + {"name": "bad", "filepath": "a/bad.md"}, + ) + + _real_platform( + monkeypatch, + [ + ("goga_tool_a", _subscribe("mixed", mixed)), + ( + "goga_tool_b", + _subscribe("y", _hook({"name": "y", "filepath": "b/y.md", "before": "done"})), + ), + ], + ) + + scale = assemble_status_scale() + + names = _names(scale) + assert "a.good" in names + assert "a.bad" not in names + assert "b.y" in names + stderr = capsys.readouterr().err + assert "Warning: hook mixed of tool a failed on statuses.register_statuses" in stderr + assert "at least one anchor is required" in stderr + def test_assemble_broken_import_is_fatal_through_the_emission( self, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/hooks/conftest.py b/tests/hooks/conftest.py index c6a12917..406e0fb4 100644 --- a/tests/hooks/conftest.py +++ b/tests/hooks/conftest.py @@ -53,14 +53,13 @@ def _pin(mapping: dict[str, list[str]]) -> mock.MagicMock: @pytest.fixture def install_tool_package( monkeypatch: pytest.MonkeyPatch, -) -> Callable[..., ModuleType]: +) -> Callable[[str, Callable[[Any], None] | None], ModuleType]: """Factory: install one fake ``goga_tool_*`` package into ``sys.modules``. - ``register_hooks`` becomes the facade callback of the package; every - further keyword argument is set on the module verbatim (``main``, - ``register_topic_statuses``, ...). Each call installs one package and - each installation is undone on teardown — one restored ``sys.modules`` - entry per fake package. + ``register_hooks`` becomes the facade callback of the package; omitting it + leaves the facade without a callback — the quiet-skip condition. Each call + installs one package and each installation is undone on teardown — one + restored ``sys.modules`` entry per fake package. Args: monkeypatch: the pytest patcher restoring ``sys.modules`` on teardown. @@ -72,16 +71,12 @@ def install_tool_package( def _install( module_name: str, register_hooks: Callable[[Any], None] | None = None, - **attributes: Any, ) -> ModuleType: module = ModuleType(module_name) if register_hooks is not None: module.register_hooks = register_hooks - for name, value in attributes.items(): - setattr(module, name, value) - monkeypatch.setitem(sys.modules, module_name, module) return module diff --git a/tests/hooks/dispatch/conftest.py b/tests/hooks/dispatch/conftest.py index 992da6bd..98c8a50f 100644 --- a/tests/hooks/dispatch/conftest.py +++ b/tests/hooks/dispatch/conftest.py @@ -21,7 +21,7 @@ @pytest.fixture -def hard_action_catalog(monkeypatch: pytest.MonkeyPatch) -> list[Action]: +def hard_action_catalog(monkeypatch: pytest.MonkeyPatch) -> None: """Pin the emission's catalog with one hard-class address ``d.act``. While pinned, the emission resolves ``("d", "act")`` as a hard action — @@ -30,16 +30,7 @@ def hard_action_catalog(monkeypatch: pytest.MonkeyPatch) -> list[Action]: Args: monkeypatch: the pytest patcher restoring the real catalog on teardown. - - Returns: - The records the pinned emission resolves addresses against. """ from goga.hooks.dispatch import emit - def pinned() -> list[Action]: - """The pinned catalog — a fresh list, as the real routine returns.""" - return list(_HARD_CATALOG) - - monkeypatch.setattr(emit, "declared_actions", pinned) - - return pinned() + monkeypatch.setattr(emit, "declared_actions", lambda: list(_HARD_CATALOG)) diff --git a/tests/hooks/registry/test_state.py b/tests/hooks/registry/test_state.py index 0eb6cb4f..8d1a531f 100644 --- a/tests/hooks/registry/test_state.py +++ b/tests/hooks/registry/test_state.py @@ -10,8 +10,9 @@ The environment boundary is pinned by the shared fixtures of ``tests/hooks/conftest.py`` — the enumeration mapping and the fake ``goga_tool_*`` modules. The registry, its registrars, and the package access -run for real; only the fatal broken-import case is mocked at the -``call_register_hooks`` seam. +run for real; the fatal broken-import case is mocked at the +``call_register_hooks`` seam in one test and driven through a real broken +package on disk in another. """ from __future__ import annotations @@ -21,6 +22,7 @@ import inspect import typing from collections.abc import Callable +from pathlib import Path import pytest from goga.hooks.registry import HookRegistry, ToolContext, ToolHooks, state @@ -319,6 +321,30 @@ def broken(package: object, registrar: object) -> bool: with pytest.raises(ImportError, match="goga_tool_a"): registry.build_once() + def test_build_once_broken_import_is_fatal_through_the_real_import( + self, + pin_package_environment, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The real import boundary and the real build agree on the fatal case. + + A facade importing a missing dependency fails through the platform's + own wrapper, and the build re-raises it: the producer + (``call_register_hooks``) and the consumer (``build_once``) run + together here, so the fatal case does not rest on a hand-crafted + message at the ``call_register_hooks`` seam. + """ + package_dir = tmp_path / "goga_tool_broken" + package_dir.mkdir() + (package_dir / "__init__.py").write_text("import goga_missing_dependency\n") + monkeypatch.syspath_prepend(tmp_path) + pin_package_environment({"goga_tool_broken": ["goga-tool-broken"]}) + registry = HookRegistry() + + with pytest.raises(ImportError, match=r"package goga_tool_broken failed to import"): + registry.build_once() + def test_build_once_callback_importerror_is_warning_not_fatal( self, pin_package_environment, diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index 286bc133..bc10df98 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -282,42 +282,35 @@ class TestHistoryStatusToolFilter: """``goga history status -s <tool>.<name>`` against the real assembly.""" def test_qualified_tool_status_validates_and_filters( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A registered tool status validates by its qualified name and - keeps exactly the topics carrying it.""" - scale = assemble_status_scale() - qualified = next((stage.name for stage in scale.stages if "." in stage.name), None) - if qualified is None: - # No installed tool package registers statuses — register a fake - # one on the hooks platform. The command surface, the domain, - # and the assembly below stay the real ones; only the package - # enumeration is pinned. - package = ModuleType("goga_tool_fake") - - def register_hooks(hooks: Any) -> None: - hooks.subscribe( - "statuses", - "register_statuses", - "published", - lambda context: context.register("published", "fake/published.md", after="planned"), - ) + keeps exactly the topics carrying it. - package.register_hooks = register_hooks - monkeypatch.setitem(sys.modules, "goga_tool_fake", package) - enumeration = mock.patch( - "goga.hooks.tools.packages.packages_distributions", - return_value={"goga_tool_fake": ["goga-tool-fake"]}, + The enumeration is pinned before the first assembly and counts its + calls: the fake package is the only one read whatever the machine has + installed, and every CLI run assembles the registry exactly once. + """ + package = ModuleType("goga_tool_fake") + + def register_hooks(hooks: Any) -> None: + hooks.subscribe( + "statuses", + "register_statuses", + "published", + lambda context: context.register("published", "fake/published.md", after="planned"), ) - enumeration.start() - request.addfinalizer(enumeration.stop) - qualified = "fake.published" - artifact = "fake/published.md" - else: - artifact = next(stage.filepath for stage in scale.stages if stage.name == qualified) + + package.register_hooks = register_hooks + monkeypatch.setitem(sys.modules, "goga_tool_fake", package) + enumeration = mock.MagicMock(return_value={"goga_tool_fake": ["goga-tool-fake"]}) + monkeypatch.setattr("goga.hooks.tools.packages.packages_distributions", enumeration) + qualified = "fake.published" + artifact = "fake/published.md" # True registration: the qualified name assembles into the scale. assert qualified in [stage.name for stage in assemble_status_scale().stages] + assert enumeration.call_count == 1 (tmp_path / ".goga/history/2026/demo-topic").mkdir(parents=True) _write(tmp_path, f".goga/history/2026/demo-topic/{artifact}") @@ -328,6 +321,9 @@ def register_hooks(hooks: Any) -> None: unfiltered = CliRunner().invoke(history, ["status", "2026"]) assert filtered.exit_code == 0 + assert unfiltered.exit_code == 0 + # One build per run — the direct assembly plus one per CLI run. + assert enumeration.call_count == 3 assert filtered.output == f"demo-topic [{qualified}]\n" assert "other-topic" not in filtered.output assert "other-topic [defined]" in unfiltered.output diff --git a/tests/test_cli.py b/tests/test_cli.py index e2a21fd0..ee2a19a0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,6 +6,8 @@ import sys from importlib.metadata import PackageNotFoundError from pathlib import Path +from types import ModuleType +from typing import Any from unittest import mock import click @@ -368,6 +370,48 @@ def test_cli_registers_hooks_command_and_invokes_it_through_the_root_group( assert result.output == "" +def test_hooks_command_renders_a_real_registry_end_to_end( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The real chain — enumeration, identity, registration, view, render. + + A package named with underscores proves the composition end to end: the + tree shows the canonical hyphen identity ``my-tool``, the slice by that + form keeps the entry, and the underscore spelling is an unknown name that + keeps an empty entry — not an error. + """ + package = ModuleType("goga_tool_my_tool") + + def register_hooks(hooks: Any) -> None: + hooks.subscribe( + "statuses", + "register_statuses", + "published", + lambda context: context.register("published", "my/published.md", after="planned"), + ) + + package.register_hooks = register_hooks + monkeypatch.setitem(sys.modules, "goga_tool_my_tool", package) + monkeypatch.setattr( + "goga.hooks.tools.packages.packages_distributions", + lambda: {"goga_tool_my_tool": ["goga-tool-my-tool"]}, + ) + runner = CliRunner() + + tree = runner.invoke(app, ["hooks"]) + + assert tree.exit_code == 0 + assert tree.output == "my-tool\n statuses\n register_statuses published\n" + + sliced = runner.invoke(app, ["hooks", "-t", "my-tool"]) + assert sliced.exit_code == 0 + assert sliced.output == tree.output + + underscored = runner.invoke(app, ["hooks", "-t", "my_tool"]) + assert underscored.exit_code == 0 + assert underscored.output == "my_tool\n" + + def test_commands_without_hooks_never_enumerate_packages() -> None: """Commands that use no hooks never build the registry. From 64203b6f2192dc9734c3365b1bc89d927339c0e0 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 05:57:53 +0000 Subject: [PATCH 144/229] test: cover the one-registry-per-tool guarantee of the status assembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acceptance pass finding: the context_for reuse branch of assemble_status_scale — the manifest guarantee that a tool identity receives one StatusRegistry shared by all its hooks — was the single uncovered branch of the changed code. Adds the contract test driving the view builder directly: repeated calls for one tool share the registry, different tools never do. Statuses assembly now sits at 100% line and branch coverage. --- tests/history/statuses/test_assembly.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/history/statuses/test_assembly.py b/tests/history/statuses/test_assembly.py index cb915d16..22fd2032 100644 --- a/tests/history/statuses/test_assembly.py +++ b/tests/history/statuses/test_assembly.py @@ -222,6 +222,18 @@ def test_assemble_registry_without_registrations_leaves_pure_axis( assert _names(scale) == _BUILTIN_NAMES + def test_assemble_context_for_hands_one_registry_per_tool_identity( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The view builder reuses one registry per tool identity and never shares it across tools.""" + captured = _fake_emission(monkeypatch, []) + assemble_status_scale() + + context_for = captured["context_for"] + + assert context_for("alpha") is context_for("alpha") + assert context_for("alpha") is not context_for("beta") + def test_assemble_two_tools_same_anchor_form_registration_order_block( self, monkeypatch: pytest.MonkeyPatch ) -> None: From 64f96ec359180d3a947a9a8d52fd12917d17089b Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 21:36:54 +0300 Subject: [PATCH 145/229] fix: permissions for workspace in docker --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1d1c526c..57f4fac5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG AFM_VERSION=0.5.51 +ARG AFM_VERSION=0.5.60 ARG RALPHEX_VERSION=1.6 ARG PYTHON_VERSION=3.12 ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 @@ -57,10 +57,10 @@ ENV GOGA_DOCKER=1 ENV RALPHEX_DOCKER=1 ENV AFM_IN_DOCKER=1 -WORKDIR /workspace - USER goga +WORKDIR /workspace + RUN goga connect claude codex opencode qwen cursor ENTRYPOINT ["goga"] From bbe55d85c87ef1d5925d967d39a3109172d00e50 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 21:13:18 +0000 Subject: [PATCH 146/229] feat: define the memory contracts across the workflow and compiler cells Contract layer for project-memory support (specification only, no implementation yet): - workflow cell: WorkflowMemory and WorkflowReflect models, the reflect / memory stage instructions on WorkflowStage, the memory field of WorkflowDocument, and the memory-aware parse_workflow contract (25 new structural-error messages) - compiler cell: FlowMemory model, the reflect / memory_use stage slots after script_timeout, the memory-block emission rules (the block is emitted iff at least one stage participates; the goga-side method selector never reaches the output), and authoring rejection of reflect / memory_use in stage bodies - pipeline facade: apply_skip_stages rebuilds carry the memory configuration verbatim; the memory usage import is wired - usages: new memory (workflow) and memory-emission (compiler) practices; parse-workflow, compile-flow, serialize-flow, and the afm cook (Memory mechanism) updated for the memory keys --- .goga/usages/cooks/afm.md | 41 +++ goga/pipeline/CODEMANIFEST | 10 +- .../pipeline/compiler/.usages/compile-flow.md | 14 +- .../compiler/.usages/memory-emission.md | 74 +++++ .../compiler/.usages/serialize-flow.md | 19 +- goga/pipeline/compiler/CODEMANIFEST | 223 ++++++++++++-- goga/pipeline/workflow/.usages/memory.md | 96 ++++++ .../workflow/.usages/parse-workflow.md | 11 +- goga/pipeline/workflow/CODEMANIFEST | 276 +++++++++++++++--- 9 files changed, 693 insertions(+), 71 deletions(-) create mode 100644 goga/pipeline/compiler/.usages/memory-emission.md create mode 100644 goga/pipeline/workflow/.usages/memory.md diff --git a/.goga/usages/cooks/afm.md b/.goga/usages/cooks/afm.md index cf914988..1ce528ac 100644 --- a/.goga/usages/cooks/afm.md +++ b/.goga/usages/cooks/afm.md @@ -114,9 +114,50 @@ afm honors the following additional per-stage keys inside each stage of a flow-f | `script_after` | str | Shell script run after the stage's agent invocation. | | `script_timeout` | str | Timeout for the stage's script action (Go duration), applied via afm's script-timeout defaults. Authored by the goga pipeline compiler from a `timeout` stage directive; the value passes verbatim — a malformed duration surfaces at runtime. | | `buttons` | map[str]str | Per-stage note buttons — a map of "button name → prompt text". Accepted by `afm validate` (single- and multi-line values); in the current binary the key is not yet interpreted (forward-compat) — it is neither rejected nor processed. Compiled by the goga workflow layer from a `notes` instruction (`workflow.stages.<name>.notes`). | +| `reflect` | map | Per-stage memory reflection — a map with a required `file` key (str) and an optional `mode` key (`r`/`w`/`rw`). afm treats `file` as a path INSIDE the flow's `memory.path`. Compiled by the goga workflow layer from a `reflect` instruction (`workflow.stages.<name>.reflect`, reflect method); goga materializes `mode: rw` when the authoring entry omits it. | +| `memory_use` | *bool | Per-stage participation in the flow's memory. An unset key INHERITS the global `memory.memory_use` (afm computes `UseFor(stage) = stage.memory_use ?? memory.memory_use`), so the goga pipeline compiler emits an explicit `memory_use: false` on every unmarked stage whenever the global `memory` block is emitted. Compiled from a workflow `memory: true` instruction (alignment method). | These keys are optional per stage; stages that do not carry them behave as before (backward compatible). goga authors `auto_approve` from its `approve: auto` workflow directive, translates its authoring `before_script`/`script`/`after_script` stage-body keys into `script_before`/`script`/`script_after`, compiles its authoring `timeout` stage directive into `script_timeout` (verbatim), and authors `auto_run: false` from a `trigger: manual` stage directive or a workflow `manual: true` instruction (never `true`; the key's absence is the norm). goga compiles its workflow `notes` instruction (map str→str) into the per-stage `buttons` field; the interpretation of the buttons belongs to afm (a separate repository) — goga only serializes the field. +## Memory mechanism (flow-file, afm v0.5.60+) + +afm supports a persistent-memory mechanism authored through the flow-file: a global +`memory` block plus two per-stage keys (`reflect`, `memory_use`). + +### Global `memory` block + +A top-level flow-file key placed after `description` and before `stages`, with the key +order `path`, `mode`, `memory_use`, `max_rules`, `commit`: + +| Key | Type | Purpose | +|-----|------|---------| +| `path` | str | Memory directory (e.g. `.goga/memory`); a stage's `reflect.file` resolves inside it | +| `mode` | str (`r`/`w`/`rw`) | Project-memory access mode | +| `memory_use` | bool | Global participation default inherited by stages that carry no `memory_use` of their own | +| `max_rules` | int (>= 1) | Maximum number of memory rules | +| `commit` | bool | Whether memory changes are committed | + +### Semantics (recovered from the afm binary) + +- `UseFor(stage) = stage.memory_use ?? memory.memory_use` — a stage key that is not set + inherits the global value. +- `CanReadProject` / `CanWriteProject` check EXACT equality of `mode` against + `r`/`w`/`rw`: an empty `mode` means neither read nor write (NOT `rw`). +- `reflect.file` is treated as a path INSIDE `memory.path`. +- The afm-side default of `max_rules` is not locatable in the binary. +- afm does NOT reject unknown keys — a typo in a key name passes silently, which is why + goga performs the full authoring validation on its own side. + +### goga authoring stance + +goga authors memory in the workflow-file (the `memory:` block plus the `reflect`/`memory` +stage instructions) and compiles it into the flow-file; the runtime interpretation belongs +to afm. The global block is emitted if and only if at least one stage participates in +memory; defaults are materialized (`max_rules: 25`, `commit: false`, and `mode: rw` for +the alignment method); `path` is the fixed prefix `.goga/memory` plus an optional authored +suffix; the goga-side `method` key (reflect | alignment) is never written to the +flow-file. + ## Integration pattern — running afm in a container A host-side launcher runs afm **inside a container image** that ships the `afm` binary. diff --git a/goga/pipeline/CODEMANIFEST b/goga/pipeline/CODEMANIFEST index 6f5d64e4..8738de55 100644 --- a/goga/pipeline/CODEMANIFEST +++ b/goga/pipeline/CODEMANIFEST @@ -25,6 +25,7 @@ Imports: - WorkflowStage Usages: - parse-workflow + - memory From: goga/pipeline/workflow - Types: - resolve_project_name @@ -612,7 +613,9 @@ Annotations: | 4. Return a NEW `WorkflowDocument`: prompt = the prompt of `workflow` when `workflow` is not None else None; stages = the new map; extend = a copy of the extend map of `workflow` when `workflow` is not None else the - default empty map. The input `workflow` and its maps are NOT mutated + default empty map; memory = the memory field of `workflow` when + `workflow` is not None else None — carried verbatim. The input + `workflow` and its maps are NOT mutated Requirements: - Empty `skip_stages` is a no-op — return the input unchanged @@ -625,6 +628,9 @@ Annotations: | None, extend empty); skip applies to a workflow-less pipeline - Construct `WorkflowStage` with skip=True and all other fields at their defaults — notes stays None (the model field default) + - The memory configuration of the input workflow survives the rebuild + verbatim — a rebuild that drops it would silently disable memory + participation for skip-driven runs - Stage-name validation is NOT performed here — the compiler's strict check raises a structural error on a name absent from the pipeline body; this routine stays declarative @@ -636,6 +642,8 @@ Annotations: | - Do not write, read, or generate any workflow-file — the merge operates purely on in-memory Python objects - Do not mutate the input `workflow` object or its maps + - Do not interpret, validate, or rebuild the memory configuration — it is + carried as an opaque value (per `memory`) "pipeline_cli(argv: list[str]) -> exit_code: int": location: cli.py diff --git a/goga/pipeline/compiler/.usages/compile-flow.md b/goga/pipeline/compiler/.usages/compile-flow.md index 373dd9e3..d5c4f38e 100644 --- a/goga/pipeline/compiler/.usages/compile-flow.md +++ b/goga/pipeline/compiler/.usages/compile-flow.md @@ -9,7 +9,8 @@ and detects the body format, applies per-stage workflow overrides + loop-expansi Output FlowStage fields, canonical order: interactive, auto_approve, auto_run, command, prompt, description, buttons, agents, supervisor, -supervisor_prompt, skills, script_before, script, script_after, script_timeout, <unknown A-Z>. +supervisor_prompt, skills, script_before, script, script_after, script_timeout, reflect, +memory_use, <unknown A-Z>. Authoring key → output key (the authoring key is consumed, not passed through): - communication → interactive (value true/false) @@ -20,13 +21,22 @@ Authoring key → output key (the authoring key is consumed, not passed through) - timeout → script_timeout (verbatim str; requires script; omitempty) - notes (workflow.stages.<name>) → buttons (map verbatim; slot right after description; omitempty) +- reflect (workflow.stages.<name>.reflect, reflect method) → reflect + (map {file, mode}; slot right after script_timeout; omitempty) +- memory (workflow.stages.<name>.memory, alignment method) → memory_use + (bool; True on a participating stage, explicit false on every + non-participating one when the block is emitted; omitempty) - roles → agents (via translate_role; default ["auto"] when absent/empty). In a body carrying `script`, NO agents key is emitted at all (afm rejects the combination) — neither the default nor a translated roles value — while the roles elements are still validated (a non-str element → structural error, same as without script) An authoring auto_run key is rejected ("auto_run key is forbidden in stage body; -use trigger: manual") — auto_run is a runtime key, authored as trigger. +use trigger: manual") — auto_run is a runtime key, authored as trigger. An +authoring reflect or memory_use key is likewise rejected ("reflect key is +forbidden in stage body; use reflect in workflow.stages" / "memory_use key is +forbidden in stage body; use memory in workflow.stages") — the memory stage +keys are compiled exclusively from the workflow instructions. ## trigger (stage-body: on_success | manual) & manual (workflow: bool) diff --git a/goga/pipeline/compiler/.usages/memory-emission.md b/goga/pipeline/compiler/.usages/memory-emission.md new file mode 100644 index 00000000..daa78a36 --- /dev/null +++ b/goga/pipeline/compiler/.usages/memory-emission.md @@ -0,0 +1,74 @@ +# memory-emission — компиляция памяти в afm flow-файл + +Документ описывает, как компилятор обрабатывает память workflow: когда +эмитится глобальный блок `memory`, какие ключи получают стадии, какие +умолчания материализуются. Адресат — потребители компилятора и авторы +workflow-файлов, сверяющие ожидаемый вывод. + +## Условие эмиссии + +Глобальный блок `memory` эмитится **тогда и только тогда, когда хотя бы одна +стадия участвует в памяти**. Участие: инструкция `reflect` при reflect-методе; +`memory: true` при alignment-методе. Блок `memory:` в workflow — конфигурация, +а не выключатель. + +| # | Блок `memory:` | Инструкции на стадиях | Блок в выводе? | +|---|----------------|------------------------|----------------| +| 1 | нет | нет | нет | +| 2 | нет | есть `reflect` | да | +| 3 | есть (только конфигурация) | нет | нет — тихий no-op | +| 4 | есть, alignment | есть `memory: true` | да | +| 5 | есть, alignment | нет (в т.ч. все `false`) | нет — тихий no-op | +| 6 | есть, reflect | есть `reflect` | да | + +Если блока нет — на стадиях не пишется **ничего**, включая отклоняющий ключ. + +## Состав блока (по методу) + +Блок стоит между `description` и `stages`; порядок ключей `path, mode, +memory_use, max_rules, commit`: + +| Ключ | reflect | alignment | +|------|---------|-----------| +| `path` | склеенный корень памяти | склеенный корень памяти | +| `mode` | — (отсутствует) | материализованное значение | +| `memory_use` | — (отсутствует) | `true` | +| `max_rules` | из конфигурации | из конфигурации | +| `commit` | из конфигурации | из конфигурации | + +`path` = `.goga/memory` (без суффикса) или `.goga/memory/<суффикс>`. + +Когда блок `memory:` в workflow не авторирован (случай 2 — есть только +инструкции `reflect`), значения берутся из материализованных умолчаний: +`path` — голый корень `.goga/memory`, `max_rules: 25`, `commit: false`. +Единственный источник умолчаний — полевые умолчания модели `WorkflowMemory`. + +## Ключи стадий + +Каноническая позиция — после `script_timeout` (хвост известных ключей): + +- reflect-метод: стадия с инструкцией `reflect` получает ключ `reflect` — + `file` дословно, `mode` материализован (`rw`, если не авторирован) +- alignment-метод (при эмитированном блоке): помеченная стадия — + `memory_use: true`; **каждая** непомеченная — явный `memory_use: false` +- loop-копии несут те же ключи, что и оригинал; skipped-стадии не достигают + применения + +Селектор метода goga в вывод не попадает никогда. + +## Инварианты + +- workflow без участия памяти компилируется байт-в-байт как без памяти — + ни блока, ни стадийных ключей +- `PipelineDocument` — точное зеркало исходного pipeline-файла: блок и + стадийные ключи памяти только output-side +- сигнатуры `compile_flow`/`serialize_flow` не меняются + +## Anti-patterns + +- Не авторить `reflect`/`memory_use` в теле стадии — структурная ошибка; + единственный источник — инструкции workflow +- Не рассчитывать, что незаданный стадийный ключ безопасен: наследование + глобального умолчания — причина явного `memory_use: false` на непомеченных +- Не проверять авторский словарь на стороне компилятора — его отвергает + парсер workflow до компиляции diff --git a/goga/pipeline/compiler/.usages/serialize-flow.md b/goga/pipeline/compiler/.usages/serialize-flow.md index 5900b063..e91045f5 100644 --- a/goga/pipeline/compiler/.usages/serialize-flow.md +++ b/goga/pipeline/compiler/.usages/serialize-flow.md @@ -8,7 +8,7 @@ flow-style agents, block-style skills/depends_on/top-level prompt). interactive, auto_approve, auto_run, command, prompt, description, buttons, agents, supervisor, supervisor_prompt, skills, script_before, script, script_after, -script_timeout, <unknown A-Z>. +script_timeout, reflect, memory_use, <unknown A-Z>. The serializer does NOT reorder — canonical order is fixed at `FlowStage` assembly (`compile_flow`); `serialize_flow` iterates `fields` as-is. @@ -24,12 +24,21 @@ assembly (`compile_flow`); `serialize_flow` iterates `fields` as-is. block-literal scalars when multi-line - script_timeout: plain scalar when single-line; block-literal scalar when multi-line (the script-family pattern) +- reflect: a block-style mapping of plain scalars (file, mode) +- memory_use: a plain bool scalar - empty list values (e.g. explicit empty depends_on) written explicitly ## Key presence Stages without auto_approve / auto_run / buttons / script_before / script / -script_after / script_timeout serialize without those keys — each appears only -when its source directive is present. `auto_run` appears only for a -manual-effective stage, always as `auto_run: false`. `buttons` appears only when -the workflow supplied a non-empty notes instruction for the stage. +script_after / script_timeout / reflect / memory_use serialize without those +keys — each appears only when its source directive is present. `auto_run` +appears only for a manual-effective stage, always as `auto_run: false`. +`buttons` appears only when the workflow supplied a non-empty notes instruction +for the stage. + +A flow document without memory participation serializes without the top-level +`memory` block and without any memory stage key — byte-identical output for +memory-free workflows. When present, the block sits between `description` and +`stages` with the key order path, mode, memory_use, max_rules, commit (a None +field omitted entirely). diff --git a/goga/pipeline/compiler/CODEMANIFEST b/goga/pipeline/compiler/CODEMANIFEST index f63bbed1..e220f8ac 100644 --- a/goga/pipeline/compiler/CODEMANIFEST +++ b/goga/pipeline/compiler/CODEMANIFEST @@ -3,13 +3,17 @@ Imports: - WorkflowDocument - WorkflowStage - WorkflowExtendStage + - WorkflowMemory + - WorkflowReflect Usages: - parse-workflow + - memory From: goga/pipeline/workflow Usages: convention: .goga/usages/conventions.md beautiful_yaml: .goga/usages/cooks/beautiful_yaml.md + afm: .goga/usages/cooks/afm.md Annotations: | The `convention` practice is used for: @@ -22,7 +26,8 @@ Annotations: | This cell is a pure transformer: it reads a pipeline-file written in goga DSL (phases-list or stages-map) and writes an equivalent afm flow-file (flat YAML with top-level prompt (when present), top-level root_dir (when - supplied by the caller), name, description, and a stages list). It performs + supplied by the caller), name, description, memory (when memory + participates), and a stages list). It performs no I/O beyond the two paths it receives and the optional workflow-file instruction source, performs no network or subprocess calls, and performs no environment-variable reads — the caller supplies the root_dir value @@ -52,7 +57,7 @@ Annotations: | segment before is the header (name, description, optional roles), the segment after is the body (either a YAML list for phases or a YAML dict for stages). The output flow-file is NOT segmented — it is a single flat - YAML document with optional prompt, name, description, and stages. + YAML document with optional prompt, name, description, memory, and stages. `BodyFormat` detection is structural: a list body yields PHASES (auto-generates depends_on by position), a dict body yields STAGES (passes @@ -77,8 +82,9 @@ Annotations: | Canonical `FlowStage` fields key order: interactive, auto_approve, auto_run, command, prompt, description, buttons, agents, supervisor, supervisor_prompt, - skills, script_before, script, script_after, script_timeout, then - alphabetically-sorted unknown keys. command is populated from the agent + skills, script_before, script, script_after, script_timeout, reflect, + memory_use, then alphabetically-sorted unknown keys. command is populated + from the agent field of `WorkflowStage` (composed as /home/goga/bin/AGENT-as-claude.sh); description is populated from the prompt field of `WorkflowStage`. Both are independent channels — pipeline-file prompt and workflow-file prompt @@ -294,6 +300,22 @@ Annotations: | the translated value verbatim; PipelineDocument bodies stay untouched (output-side only). + Workflow memory emission: when the supplied workflow carries memory + participation, the cell assembles the top-level memory block of the + flow-file and the per-stage memory keys. The block is emitted if and only + if at least one stage participates in memory — the memory configuration + alone never turns the block on. A stage participates by carrying a reflect + instruction under the reflect method, or a true memory instruction under + the alignment method. The emitted path composes the fixed memory root with + the authored suffix. The goga-side method selector never reaches the + output. Apply `afm` for the external contract of the emitted memory keys. + Apply `memory` for the authoring vocabulary of the consumed workflow + instructions. The stage-body keys reflect and memory_use are + authoring-forbidden — the memory stage keys are compiled exclusively from + the workflow instructions; an authored occurrence in any stage body is a + structural error. Output-side only — `PipelineDocument` stays the faithful + mirror of the source pipeline-file. + --- "BodyFormat()": @@ -581,8 +603,8 @@ Annotations: | `fields`: extra step fields in canonical key order (interactive, auto_approve, auto_run, command, prompt, description, buttons, agents, supervisor, supervisor_prompt, skills, script_before, - script, script_after, script_timeout, then alphabetically-sorted - unknown keys). + script, script_after, script_timeout, reflect, memory_use, + then alphabetically-sorted unknown keys). buttons (map of str→str) is present only when the workflow supplied a non-empty notes instruction for the stage — the map passes through verbatim. @@ -591,7 +613,16 @@ Annotations: | is never assembled. script_before/script/script_after (str) are present only when the corresponding stage directive was authored; script_timeout (str) is present only when the - corresponding directive was authored. Insertion order of + corresponding directive was authored. reflect (map of file + + mode) is present only when the block is emitted and the + stage's reflect instruction is effective — the authored file + verbatim, the materialized mode; uniform across every + loop-expanded copy. memory_use (bool) is present only when + the block is emitted under the alignment method — True on a + participating stage, explicit False on every non-participating + one. Both occupy the canonical slots after script_timeout. A + stage of a memory-free workflow carries neither key. + Insertion order of this dict IS the output order — the serializer iterates it as-is. command is populated from the agent field of `WorkflowStage` (composed as the in-container @@ -640,7 +671,8 @@ Annotations: | Extra fields in canonical key order: interactive, auto_approve, auto_run, command, prompt, description, buttons, agents, supervisor, supervisor_prompt, skills, script_before, script, script_after, - script_timeout, then alphabetically-sorted unknown keys. buttons + script_timeout, reflect, memory_use, then alphabetically-sorted unknown + keys. buttons (map of str→str) is present only when the workflow supplied a non-empty notes instruction for the stage. auto_approve (bool) is present only when the effective approve directive drives the @@ -648,18 +680,72 @@ Annotations: | is present only when the stage's effective trigger is manual — the value is always False, auto_run: true is never assembled; script_before/script/ script_after/script_timeout (str) are present only when the corresponding - stage directive was authored. Insertion order IS the output order — the + stage directive was authored. reflect (map of file + mode) is present + only when the block is emitted and the stage's reflect instruction is + effective — the authored file verbatim, the materialized mode; uniform + across every loop-expanded copy. memory_use (bool) is present only when + the block is emitted under the alignment method — True on a + participating stage, explicit False on every non-participating one. + Both occupy the canonical slots after script_timeout. A stage of a + memory-free workflow carries neither key. Insertion order IS the output order — the serializer iterates as-is. command/description from WorkflowStage; agents default-injected to ["auto"], absent entirely in a body carrying script; supervisor/supervisor_prompt only when authored. -"FlowDocument(prompt: str | None = None, root_dir: str | None = None, name: str, description: str, stages: list[FlowStage])": +"FlowMemory(path: str, mode: str | None = None, memory_use: bool | None = None, max_rules: int, commit: bool)": + location: flow_memory.py + annotations: | + Emitted top-level memory block of the flow-file — the compiled form of + the workflow-memory configuration. Built by `compile_flow` when memory + participates; consumed by `serialize_flow`. + + `path`: the composed memory root — the fixed root joined with the + authored suffix (the bare root when no suffix) + `mode`: the project-memory access mode — present only for the alignment + method; None for the reflect method + `memory_use`: the global participation default — True only for the + alignment method; None for the reflect method + `max_rules`: the maximum number of memory rules (always >= 1) + `commit`: whether memory changes are committed + + Build the data model with the standard library dataclasses module + (NOT pydantic, per `convention`). Use @dataclass(kw_only=True). + + Requirements: + - Use @dataclass(kw_only=True) (per `convention`) + - Field order is fixed: path, mode, memory_use, max_rules, commit — the + emission order of the block keys + - reflect method: mode None, memory_use None; alignment method: mode the + materialized value, memory_use True + - A None field is omitted from the output entirely + + Constraints: + - Do not decide here whether the block is emitted — `compile_flow` + decides on participation + - Do not compose `path` here — the caller composes the root and suffix + - Do not interpret the block — the runtime behavior belongs to afm (per + `afm`) + properties: + "path -> str": | + The composed memory root — the fixed root joined with the authored + suffix. + "mode -> str | None": | + The project-memory access mode; present only for the alignment method. + "memory_use -> bool | None": | + The global participation default; True only for the alignment method. + "max_rules -> int": | + The maximum number of memory rules; always >= 1. + "commit -> bool": | + Whether memory changes are committed. + +"FlowDocument(prompt: str | None = None, root_dir: str | None = None, name: str, description: str, memory: FlowMemory | None = None, stages: list[FlowStage])": location: flow_document.py annotations: | - Output afm flow-file — a single flat YAML document with up to five + Output afm flow-file — a single flat YAML document with up to six top-level keys (prompt (when present), root_dir (when supplied), - name, description, stages). No segmentation, no header sub-object: + name, description, memory (when memory participates), stages). No + segmentation, no header sub-object: the format is flat, and this type mirrors that flatness. `prompt`: top-level prompt value emitted as the FIRST top-level key of @@ -676,6 +762,10 @@ Annotations: | None when the caller did not supply one (omitted from output — no top-level root_dir key in the flow-file). The compiler itself performs no environment-variable reads. + `memory`: the compiled memory block, or None when memory does not + participate. Emitted between description and stages when not + None; omitted entirely when None — byte-identical output for + memory-free workflows. `name`: top-level name value (carried 1:1 from `PipelineHeader` name) `description`: top-level description value (carried 1:1 from `PipelineHeader` description) @@ -696,8 +786,9 @@ Annotations: | key when no workflow supplies one - `root_dir` defaults to None — the flow-file omits the top-level root_dir key when the caller did not supply one - - Field order is fixed: prompt, root_dir, name, description, stages — - matches the canonical emission order in `serialize_flow` + - memory defaults to None; field order is fixed: prompt, root_dir, name, + description, memory, stages — matches the canonical emission order + in `serialize_flow` properties: "prompt -> str | None": | @@ -711,6 +802,8 @@ Annotations: | Top-level flow name. "description -> str": | Top-level flow description. + "memory -> FlowMemory | None": | + The compiled memory block, or None when memory does not participate. "stages -> list[FlowStage]": | Ordered list of flow stages. @@ -821,15 +914,22 @@ Annotations: | top-level key (after prompt when present, before name), as a plain scalar. When `doc` root_dir is None — omit the key entirely (no top-level root_dir key in the output) + - when `doc` memory is not None — emit the memory block after + description and before stages, key order path, mode, memory_use, + max_rules, commit; every present value a plain scalar; a None + field omitted entirely (no key in the output). When `doc` memory + is None — omit the block entirely - name, then description, then stages (per step 2) 2. For each `FlowStage` in `doc`, build a representation in canonical key order: id, name, then the stage fields as-is (already in canonical order — interactive, auto_approve, auto_run, command, prompt, description, buttons, agents, supervisor, supervisor_prompt, - skills, script_before, script, script_after, script_timeout, then - alphabetically-sorted unknown keys), then depends_on when it is not - None. The serializer does NOT reorder — canonical order is fixed at - `FlowStage` assembly + skills, script_before, script, script_after, script_timeout, reflect, + memory_use, then alphabetically-sorted unknown keys), then depends_on + when it is not None. The serializer does NOT reorder — canonical + order is fixed at `FlowStage` assembly. Stage memory keys: reflect — + a block-style mapping of plain scalars (file, mode); memory_use — a + plain bool scalar 3. Serialize the representation to YAML per `beautiful_yaml`, with the flow-style applied to agents and block-style applied to skills, depends_on, and the top-level prompt; auto_approve and auto_run as @@ -849,9 +949,11 @@ Annotations: | Requirements: - Output is canonical afm flow-file YAML: optional prompt (first, when present), optional root_dir (second, when supplied), then - fixed top-level key order (name, description, stages), canonical + fixed top-level key order (name, description, memory (when present), + stages), canonical per-stage key order (including auto_approve, auto_run, command, - description, buttons, and script_before/script/script_after/script_timeout), + description, buttons, script_before/script/script_after/script_timeout, + reflect, and memory_use), flow-style for agents, block-style for skills and depends_on, auto_approve and auto_run as plain bool scalars, single trailing newline - When `doc` prompt is None — the output omits the prompt key @@ -873,9 +975,13 @@ Annotations: | - buttons values: single-line text as a plain scalar (quoted as needed), multi-line text as a block-literal scalar; a stage without a buttons key serializes without it (byte-identical output for notes-free pipelines) + - A flow document without memory serializes without the block and without + any memory stage key — byte-identical output for memory-free workflows + - The block occupies the position between description and stages; the stage + keys occupy their canonical slots Constraints: - - Do not reorder keys — the fields order is the caller's responsibility + - Do not reorder keys — canonical order is fixed at assembly - Do not validate `doc` — assume it is well-formed (`compile_flow` constructed it) - Do not write to disk — return the string; the caller writes it @@ -1176,6 +1282,22 @@ Annotations: | copy — no explicit rewrite needed for PHASES. 4.8. Replace the body's step list with the new (embedded + expanded + rewritten) sequence. + 4.9. Compute memory participation from the workflow (when `workflow` + is not None). The effective memory configuration is the + `WorkflowMemory` value of the workflow document when it carries + one, else a default-constructed `WorkflowMemory` (its field + defaults ARE the materialized authoring defaults: path None, + max_rules 25, commit False, mode None — the default method is + "reflect"). The method is the materialized method of the + effective configuration; under the reflect method — a stage + participates when its `WorkflowReflect` instruction is not None; + under the alignment method — a stage participates when its memory + instruction is True. Participation is computed over the stages + present in the working body after skip removal and loop expansion + (embedded extend-stages included; a stage removed by skip — + workflow skip OR the CLI GOGA_SKIP_STAGES channel — never + counts, so a run whose every participating stage was skipped + emits no block) 5. Build FlowStages from the (possibly reconstructed) body: - PHASES: for each `PhaseStep`, build a `FlowStage` with depends_on derived from list position (first step has none; each subsequent @@ -1198,6 +1320,12 @@ Annotations: | structural error "buttons key is forbidden in stage body; use notes in workflow.stages" (same pass as the agents/interactive-forbidden checks) + - Stage-body validation (in the existing stage-body checking pass): + a reflect or memory_use key in a stage body (pipeline-file stage OR + embedded extend-stage body) raises a structural error "reflect key + is forbidden in stage body; use reflect in workflow.stages" / + "memory_use key is forbidden in stage body; use memory in + workflow.stages" - In both branches, BEFORE canonical ordering, inject default stage fields when the source step body has no usable roles value (missing key, explicit null, or empty list): set agents to @@ -1276,11 +1404,21 @@ Annotations: | description; uniform across every loop-expanded copy. A stage without an effective notes assembles NO buttons key. Output-side only — the source bodies and PipelineDocument stay untouched + - Stage memory keys assembly (uniform across every loop-expanded copy; + a skipped stage never reaches here): when the block is emitted and + the method is reflect — a stage carrying a `WorkflowReflect` + instruction assembles the reflect field (the authored file verbatim, + the materialized mode) into the canonical slot after script_timeout; + when the block is emitted and the method is alignment — every stage + assembles memory_use into the canonical slot after reflect: True on + a participating stage, explicit False on every non-participating one; + when the block is not emitted — no stage carries any memory key - In both branches, assemble the fields of each `FlowStage` in the EXTENDED canonical key order (interactive, auto_approve, auto_run, command, prompt, description, buttons, agents, supervisor, supervisor_prompt, skills, script_before, script, script_after, - script_timeout, then alphabetically-sorted unknown keys), copying + script_timeout, reflect, memory_use, then alphabetically-sorted + unknown keys), copying from the (defaults-injected) step body. auto_approve (bool) is present only when the effective approve directive drives the roles effect ("auto"/"dialog") + planner-in-roles @@ -1300,6 +1438,19 @@ Annotations: | `project_name` is not None, else the header description unchanged (OUTPUT-only — PipelineDocument.description stays the faithful mirror, like root_dir) + - memory = the built memory block (only when participation exists), + sourcing every value from the effective memory configuration of + step 4.9 (the `WorkflowMemory` value of the workflow document when + it carries one, else a default-constructed `WorkflowMemory` — its + field defaults ARE the materialized authoring defaults): path = the + fixed root ".goga/memory" joined with the authored suffix (the bare + root when the suffix is None); reflect method — mode None, + memory_use None; alignment method — mode the materialized value, + memory_use True; max_rules and commit carried from the effective + configuration (25 / False when no block was authored); place the + block between description and stages of the `FlowDocument`. When + participation does not exist — memory is None (no block, no stage + keys) - name from the header; stages from the assembled FlowStages 7. Build `PipelineDocument` from (header, format, ORIGINAL body) — the parsed representation carried alongside the FlowDocument for the @@ -1511,6 +1662,21 @@ Annotations: | existing structural error "unknown stage name in workflow.stages: <name>" - buttons is output-side only — `PipelineDocument` and the source bodies are never affected + - The block is emitted if and only if at least one stage participates — a + memory configuration without participation is a silent no-op (no block, + no stage keys, not even an opting-out stage key) + - All six emission cases hold: no block + no instructions → no block; no + block + reflect instructions → block; block without instructions → no + block; alignment + a true memory instruction → block; alignment without + a true memory instruction → no block; reflect + a reflect instruction → + block + - The emitted path is the fixed memory root joined with the authored suffix + - Participation is counted over the working body, not over the + workflow.stages map — a skipped stage's instructions do not count + - Every loop-expanded copy carries the same memory keys as its original + - The method selector never appears in the output + - A workflow without memory participation compiles byte-identically to the + current output Constraints: - Do not read AFM_DIR or any environment variable — `flow_path` is @@ -1595,8 +1761,8 @@ Annotations: | - Canonical key order is fixed at `FlowStage` assembly — the full order (interactive, auto_approve, auto_run, command, prompt, description, buttons, agents, supervisor, supervisor_prompt, skills, - script_before, script, script_after, script_timeout, then - alphabetically-sorted unknown keys) + script_before, script, script_after, script_timeout, reflect, + memory_use, then alphabetically-sorted unknown keys) - Do not leak auto_approve or script_* into `PipelineDocument` — they are output-side `FlowStage` fields only - Do not emit auto_run: true — the afm contract treats the key's absence @@ -1619,6 +1785,15 @@ Annotations: | fields directly from the effective-notes value; the body channel is closed by the authoring-buttons prohibition (an injected key would trip the step-5 check) + - Do not re-validate the authoring vocabulary — the workflow parser already + rejected non-conforming authoring (per `memory`) + - Do not interpret the memory keys — the runtime behavior belongs to afm + (per `afm`) + - Do not emit memory stage keys when the block is absent + - Do not leak the memory block or the stage keys into `PipelineDocument` — + output-side only + - Do not apply memory instructions to a skipped stage — skip removal runs + before the assembly "translate_role(role: str) -> name: str": location: compile_flow.py diff --git a/goga/pipeline/workflow/.usages/memory.md b/goga/pipeline/workflow/.usages/memory.md new file mode 100644 index 00000000..fd946a21 --- /dev/null +++ b/goga/pipeline/workflow/.usages/memory.md @@ -0,0 +1,96 @@ +# memory — авторинг памяти в workflow-файле + +`memory` включает участие workflow в памяти проекта: один top-level блок +конфигурации и две per-stage инструкции участия. Документ адресован авторам +workflow-файлов: всё описанное проверяется структурно при парсинге — опечатки, +несоответствия типов и значений отвергаются с читаемой ошибкой. + +## Top-level блок `memory:` + +| Ключ | Тип | Умолчание | Примечание | +|------|-----|-----------|------------| +| `method` | `reflect` \| `alignment` | `reflect` | селектор словаря инструкций; селектор — сторона goga, в скомпилированный вывод не попадает | +| `path` | str | без суффикса | суффикс внутри фиксированного корня памяти проекта | +| `max_rules` | int >= 1 | `25` | материализуется — опустить нельзя молча | +| `commit` | bool | `false` | материализуется | +| `mode` | `r` \| `w` \| `rw` | `rw` (материализуется) | только при `method: alignment`; при `method: reflect` — структурная ошибка | + +Неизвестный ключ — структурная ошибка. Workflow из одного блока `memory:` +валиден (не считается пустым). + +## Инструкции блока `stages` + +| Инструкция | Допустимый метод | Значение | +|------------|------------------|----------| +| `reflect: {file, mode?}` | `reflect` | `file` обязателен — файл рефлексии стадии (форма пути внутри корня памяти, без ведущего `/`, не абсолютный, без `..`); `mode` опционален (`r`/`w`/`rw`), умолчание `rw` материализуется | +| `memory: <bool>` | `alignment` | `true` — стадия участвует; `false` эквивалентен отсутствию ключа | + +Несоответствие метода и инструкции — структурная ошибка: `reflect` допустим +только при reflect-методе, `memory` — только при alignment-методе. Метод +по умолчанию — `reflect`, поэтому инструкция `memory` без блока `memory:` +с явным `method: alignment` — ошибка. + +В extend-записи обе инструкции запрещены: участие новой стадии авторится в +блоке `stages` по её имени. + +## Минимальные примеры + +Reflect-метод (умолчание) — стадии рефлексии в общий файл памяти: + +```yaml +memory: + max_rules: 40 +stages: + brainstorm: + reflect: + file: shared.md + review: + reflect: + file: shared.md + mode: r +``` + +Alignment-метод — избирательное участие стадий: + +```yaml +memory: + method: alignment + path: goga-development + mode: rw +stages: + brainstorm: + memory: true + build: + memory: true +``` + +Блок без инструкций — валидная конфигурация (тихий no-op при компиляции). + +## Структурные ошибки (полный перечень) + +| Авторинг | Ошибка | +|---|---| +| `memory:` не-отображение | non-mapping memory block in workflow | +| неизвестный ключ `memory:` | unknown key in workflow.memory: KEY; valid keys: method, path, max_rules, commit, mode | +| `method` вне {reflect, alignment} | структурная ошибка со списком допустимых | +| `max_rules` не int / < 1 | структурная ошибка | +| `commit`/`memory` не bool | структурная ошибка | +| `mode` вне {r, w, rw} | структурная ошибка со списком допустимых | +| `mode` при `method: reflect` | mode is forbidden in workflow.memory with method: reflect | +| `path`/`reflect.file` плохой формы | структурная ошибка (пустая строка, ведущий `/`, абсолютный путь, `..`) | +| `reflect` не-отображение | non-mapping reflect in workflow.stages.NAME | +| неизвестный ключ `reflect` | unknown key in workflow.stages.NAME.reflect: KEY; valid keys: file, mode | +| `reflect` без `file` | структурная ошибка | +| `reflect` при alignment | reflect is forbidden in workflow.stages.NAME with method: alignment | +| `memory` при reflect | memory is forbidden in workflow.stages.NAME with method: reflect | +| `reflect`/`memory` в extend-записи | reflect/memory is forbidden in workflow.extend.NAME | + +## Anti-patterns + +- Не авторить `reflect`/`memory` в теле стадии или в теле extend-записи — + единственная точка авторинга инструкций — блок `stages` workflow-файла + (ключи в телах стадий отвергаются при компиляции). +- Не рассчитывать на умолчания afm: материализация умолчаний (`mode`, + `max_rules`, `commit`) — обязательное поведение парсера, а не стилистика. +- Не указывать `mode` в блоке `memory:` при методе по умолчанию — это + структурная ошибка, а не молчаливое игнорирование. diff --git a/goga/pipeline/workflow/.usages/parse-workflow.md b/goga/pipeline/workflow/.usages/parse-workflow.md index 605be4b9..d4141f02 100644 --- a/goga/pipeline/workflow/.usages/parse-workflow.md +++ b/goga/pipeline/workflow/.usages/parse-workflow.md @@ -8,7 +8,7 @@ all of that is the compiler's job. ## Accepted structure -Top-level keys: `prompt`, `stages`, `extend`. +Top-level keys: `prompt`, `stages`, `extend`, `memory`. ## Per-stage override keys (workflow.stages.<name>) @@ -22,6 +22,8 @@ Top-level keys: `prompt`, `stages`, `extend`. | approve | str ("auto"/"plan"/"dialog") | auto-approval directive; one of the three accepted; declarative | | manual | bool | manual-launch instruction; strictly bool; stages block only; declarative | | notes | map[str]str | note buttons — map "note name → prompt text"; compiled into the stage's `buttons` field; stages block only; declarative | +| reflect | map {file, mode?} | memory-reflection instruction — reflect method only; compiled into the stage's `reflect` field; stages block only; declarative | +| memory | bool | memory-participation instruction — alignment method only; `true` participates, `false` equals absence; compiled into the stage's `memory_use` field; stages block only; declarative | ## The notes field @@ -67,6 +69,11 @@ body via `trigger`, not via workflow instructions. in workflow.extend.<name>") — note buttons of a new stage are authored in the stages block by the new stage's name. +`reflect` and `memory` are forbidden in an extend-entry (structural errors +"reflect is forbidden in workflow.extend.<name>" / "memory is forbidden in +workflow.extend.<name>") — memory participation of a new stage is authored in +the stages block by the new stage's name. + ## The manual field `manual` is an optional per-stage instruction (stages block only) controlling the @@ -94,7 +101,7 @@ there is nothing to cancel. - in the stages block it is an unknown key → structural error "unknown key in workflow.stages.<name>: trigger; valid keys: agent, prompt, loop, skills, - skip, approve, manual, notes" + skip, approve, manual, notes, reflect, memory" - in an extend-entry body it is legal and passes through verbatim — the compiler validates its value (`on_success` | `manual`) at compilation time diff --git a/goga/pipeline/workflow/CODEMANIFEST b/goga/pipeline/workflow/CODEMANIFEST index 90d93853..4517221f 100644 --- a/goga/pipeline/workflow/CODEMANIFEST +++ b/goga/pipeline/workflow/CODEMANIFEST @@ -11,7 +11,8 @@ Annotations: | This cell is a pure parser of project-level workflow-files. It reads a workflow-file, validates its structure (known top-level keys prompt / - stages / extend, field types, loop counts, extend-entry positioning, and + stages / extend / memory, field types, loop counts, extend-entry + positioning, and the inline agent/loop fields of an extend-entry), and returns a `WorkflowDocument` carrying declarative instructions for the compiler. It performs no I/O beyond the path it receives, performs no network or subprocess calls, and has no Imports — it depends only on @@ -64,16 +65,26 @@ Annotations: | launch mode of a new stage is authored in its body via trigger, not via workflow instructions. + memory is a declarative workflow-memory configuration — an optional top-level + block plus two per-stage participation instructions. This cell validates the + block and the instructions structurally: key sets, types, value domains, path + shapes, and the correspondence between the block method and the stage + instruction. Materialized defaults live in the document model. This cell + performs NO memory logic — it composes no paths, decides no block emission, + and resolves no stage participation; the consumer consumes the extracted + values. The import graph stays one-directional. + --- -"WorkflowStage(agent: str | None = None, prompt: str | None = None, loop: int | None = None, skills: list[str] | None = None, skip: bool = False, approve: str | None = None, manual: bool | None = None, notes: dict[str, str] | None = None)": +"WorkflowStage(agent: str | None = None, prompt: str | None = None, loop: int | None = None, skills: list[str] | None = None, skip: bool = False, approve: str | None = None, manual: bool | None = None, notes: dict[str, str] | None = None, reflect: WorkflowReflect | None = None, memory: bool | None = None)": location: workflow_stage.py annotations: | Data model of a single per-stage override instruction in a workflow-file — which agent, which prompt, how many loop iterations, which skills to merge, whether to SKIP (delete) the stage, an optional auto-approval directive, - an optional manual-launch instruction, and an optional note-buttons - instruction. Constructed by `parse_workflow` + an optional manual-launch instruction, an optional note-buttons + instruction, and optional memory-participation instructions. Constructed + by `parse_workflow` from one entry of the workflow-file stages map; carried verbatim inside `WorkflowDocument`. @@ -114,19 +125,28 @@ Annotations: | normalizes it to None, so the model carries either None or a non-empty map. This cell does NOT act on `notes` — it is declarative; the compiler emits the buttons. + `reflect`: optional memory-reflection instruction. Declarative — extracted + here, consumed by the compiler to emit the stage's reflect field. + None when not specified. + `memory`: optional memory-participation instruction. Declarative — + extracted here, consumed by the compiler to emit the stage's + memory participation. None when not specified; an explicit false + equals absence (normalized to None by `parse_workflow` — no + separate semantics for false). Build the data model with the standard library dataclasses module (NOT pydantic, per `convention`). Use @dataclass(kw_only=True). Requirements: - Use @dataclass(kw_only=True) (per `convention`) - - Fields agent/prompt/loop/skills/approve/manual/notes default to None; - `skip` defaults to False (NOT None); `manual` defaults to None (NOT - False) — an absent key and an explicit manual: false are DIFFERENT - instructions and must stay distinguishable to the compiler + - Fields agent/prompt/loop/skills/approve/manual/notes/reflect/memory + default to None; `skip` defaults to False (NOT None); `manual` defaults + to None (NOT False) — an absent key and an explicit manual: false are + DIFFERENT instructions and must stay distinguishable to the compiler - Field order is fixed: agent, prompt, loop, skills, skip, approve, - manual, notes — matches the canonical order of the per-stage keys in - the workflow-file + manual, notes, reflect, memory — matches the canonical order of the + per-stage keys in the workflow-file + - An explicit memory: false is normalized to None at parse time - `approve` accepts ONLY "auto"/"plan"/"dialog"; `manual` accepts ONLY True/False — any other value is rejected by `parse_workflow` as a structural error before this dataclass is built @@ -142,6 +162,8 @@ Annotations: | the workflow - Do not act on `notes` here — it is declarative; the compiler emits the buttons when applying the workflow + - Do not act on `reflect` or `memory` here — they are declarative; the + compiler performs the emission when applying the workflow properties: "agent -> str | None": | Agent name consumed by the compiler to compose the wrapper path, or None. @@ -167,6 +189,48 @@ Annotations: | Optional note-buttons instruction (map of note name → prompt text). Declarative — extracted here, consumed by the compiler to emit the stage's buttons field. None when not specified (an empty map equals absence). + "reflect -> WorkflowReflect | None": | + Optional memory-reflection instruction (file + access mode). Declarative + — extracted here, consumed by the compiler to emit the stage's reflect + field. None when not specified. + "memory -> bool | None": | + Optional memory-participation instruction. Declarative — extracted + here, consumed by the compiler to emit the stage's memory participation. + None when not specified; an explicit false equals absence. + +"WorkflowReflect(file: str, mode: str = \"rw\")": + location: workflow_reflect.py + annotations: | + Data model of a per-stage memory-reflection instruction — which memory + file the stage reflects into and with which access mode. Constructed by + `parse_workflow`; carried inside `WorkflowStage`. + + `file`: the reflection file — a path shape inside the memory root; + carried verbatim + `mode`: the access mode — one of "r", "w", "rw"; materialized to "rw" + when the authoring entry omits it + + Build the data model with the standard library dataclasses module + (NOT pydantic, per `convention`). Use @dataclass(kw_only=True). + + Requirements: + - Use @dataclass(kw_only=True) (per `convention`) + - `file` is required (no default); `mode` defaults to "rw" — a + materialized value, not an omission + - Field order is fixed: file, mode + + Constraints: + - Do not validate the path shape or the mode domain here — + `parse_workflow` enforces them during parsing + - Do not resolve `file` against any memory root here — the consumer + composes paths + properties: + "file -> str": | + The reflection file — a path shape inside the memory root; carried + verbatim. + "mode -> str": | + The access mode — one of "r", "w", "rw"; materialized to "rw" when the + authoring entry omits it. "WorkflowExtendStage(before: list[str] | None = None, after: list[str] | None = None, agent: str | None = None, loop: int | None = None, approve: str | None = None, body: dict[str, Any])": location: workflow_extend_stage.py @@ -229,12 +293,63 @@ Annotations: | Verbatim stage body excluding before, after, agent, loop, approve, and depends_on. Open-ended. -"WorkflowDocument(prompt: str | None = None, stages: dict[str, WorkflowStage] | None = None, extend: dict[str, WorkflowExtendStage] | None = None)": +"WorkflowMemory(method: str = \"reflect\", path: str | None = None, max_rules: int = 25, commit: bool = False, mode: str | None = None)": + location: workflow_memory.py + annotations: | + Data model of the workflow-memory configuration block — the authoring + form of the flow-level memory settings. Constructed by `parse_workflow` + with materialized defaults; carried inside `WorkflowDocument`. + + `method`: the authoring method — "reflect" or "alignment". A goga-side + selector of the instruction vocabulary; never part of any + output. + `path`: authored suffix inside the fixed memory root; None means no + suffix. + `max_rules`: the maximum number of memory rules; always >= 1. + `commit`: whether memory changes are committed. + `mode`: the project-memory access mode — one of "r", "w", "rw"; exists + only for the "alignment" method (None for "reflect"). + + Build the data model with the standard library dataclasses module + (NOT pydantic, per `convention`). Use @dataclass(kw_only=True). + + Requirements: + - Use @dataclass(kw_only=True) (per `convention`) + - Defaults are materialized values, not omissions: method "reflect", + max_rules 25, commit False; mode "rw" for the "alignment" method, None + for "reflect" + - path carries the authored suffix only — the fixed root prefix is not + part of this model + - Field order is fixed: method, path, max_rules, commit, mode + + Constraints: + - Do not validate keys, types, or value domains here — `parse_workflow` + enforces them during parsing + - Do not compose the memory root prefix here — the consumer composes the + final path + - Do not act on `method` here — it is declarative; the consumer selects + the emission form + properties: + "method -> str": | + The authoring method — "reflect" or "alignment". A goga-side selector + of the instruction vocabulary; never part of any output. + "path -> str | None": | + Authored suffix inside the fixed memory root; None means no suffix. + "max_rules -> int": | + The maximum number of memory rules; always >= 1. + "commit -> bool": | + Whether memory changes are committed. + "mode -> str | None": | + The project-memory access mode — one of "r", "w", "rw"; exists only for + the "alignment" method, None for "reflect". + +"WorkflowDocument(prompt: str | None = None, stages: dict[str, WorkflowStage] | None = None, extend: dict[str, WorkflowExtendStage] | None = None, memory: WorkflowMemory | None = None)": location: workflow_document.py annotations: | Aggregated workflow-file document — the parsed representation of a - workflow-file as a single value, combining an optional top-level prompt - and a map of per-stage override instructions. Built by `parse_workflow` + workflow-file as a single value, combining an optional top-level prompt, + a map of per-stage override instructions, and an optional workflow-memory + configuration. Built by `parse_workflow` and consumed by the compiler via its workflow parameter. `prompt`: top-level prompt text that the compiler emits as the first @@ -256,6 +371,9 @@ Annotations: | positioned via before/after. Stages in `extend` that reference unknown names are silently ignored with a warning by the compiler. An empty map (default) means the workflow provides no new stages. + `memory`: workflow-memory configuration extracted from the optional + top-level memory block, or None when the workflow-file carries + no block. A workflow consisting of the block alone is valid. Build the data model with the standard library dataclasses module (NOT pydantic, per `convention`). Use @dataclass(kw_only=True). @@ -269,9 +387,15 @@ Annotations: | - `extend` defaults to an empty dict via field(default_factory=dict) in the implementation; the signature default None is a DSL representation, the actual default factory is applied at construction - - A workflow-file with none of a top-level `prompt`, any stage entries, or - any extend entries is rejected by `parse_workflow` with a structural - error before this dataclass is built — at least one must be present + - memory defaults to None; field order is fixed: prompt, stages, extend, + memory + - The empty-workflow rule counts the block: a workflow is empty only when + prompt is None AND stages is empty AND extend is empty AND memory is + None + - A workflow-file with none of a top-level `prompt`, any stage entries, + any extend entries, or the memory block is rejected by `parse_workflow` + with a structural error before this dataclass is built — at least one + must be present Constraints: - Do not validate stage-name keys against any pipeline — the compiler @@ -292,6 +416,9 @@ Annotations: | "extend -> dict[str, WorkflowExtendStage]": | Map of new-stage extend-instructions keyed by stage name. Empty map when the workflow-file has no extend section. + "memory -> WorkflowMemory | None": | + Workflow-memory configuration extracted from the top-level memory + block, or None when the workflow-file carries no block. "parse_workflow(workflow_path: Path) -> workflow: WorkflowDocument": location: parse_workflow.py @@ -322,16 +449,44 @@ Annotations: | error "non-mapping stages block in workflow" - extend: if present, must be a dict; otherwise raise a structural error "non-mapping extend block in workflow" + - memory: if present, must be a dict; otherwise raise a structural + error "non-mapping memory block in workflow" 5. For every other top-level key — raise a structural error - "unknown key in workflow: KEY; valid keys: prompt, stages, extend" + "unknown key in workflow: KEY; valid keys: prompt, stages, extend, + memory" + 6.0. For the memory block (when present), validate each key and value: + - an unknown key raises "unknown key in workflow.memory: KEY; valid + keys: method, path, max_rules, commit, mode" + - method must be a str and one of "reflect"/"alignment"; otherwise + raise "non-str value in workflow.memory.method" (non-str) or + "method must be one of: reflect, alignment in workflow.memory" + (str outside the set) + - path must be a str of a valid path shape (non-empty, no leading "/", + not absolute, no ".."); otherwise raise "non-str value in + workflow.memory.path" (non-str) or "invalid path in + workflow.memory.path: VALUE" (bad shape) + - max_rules must be an int >= 1; otherwise raise "non-int value in + workflow.memory.max_rules" (non-int) or "max_rules must be >= 1 + in workflow.memory" (int < 1) + - commit must be a bool; otherwise raise "non-bool value in + workflow.memory.commit" + - mode must be a str and one of "r"/"w"/"rw"; otherwise raise + "non-str value in workflow.memory.mode" (non-str) or "mode must + be one of: r, w, rw in workflow.memory" (str outside the set). + An authored mode together with the "reflect" method raises "mode + is forbidden in workflow.memory with method: reflect" + - build `WorkflowMemory` with materialized defaults (method "reflect", + max_rules 25, commit False; mode "rw" under "alignment", None under + "reflect") 6.1. For each entry of stages (when present), identified by stage name and stage value: 6.1.1. If the stage value is not a dict — raise a structural error "non-mapping stage NAME in workflow.stages" 6.1.2. Validate the key set of the stage value against agent, prompt, - loop, skills, skip, approve, manual, notes: an unknown key raises - "unknown key in workflow.stages.NAME: KEY; valid keys: agent, prompt, - loop, skills, skip, approve, manual, notes" + loop, skills, skip, approve, manual, notes, reflect, memory: an + unknown key raises "unknown key in workflow.stages.NAME: KEY; valid + keys: agent, prompt, loop, skills, skip, approve, manual, notes, + reflect, memory" 6.1.3. agent (when present) must be a str; otherwise raise "non-str value in workflow.stages.NAME.agent" 6.1.4. prompt (when present) must be a str; otherwise raise @@ -354,9 +509,27 @@ Annotations: | workflow.stages.NAME" (non-dict) or "non-str value in workflow.stages.NAME.notes.KEY" (non-str value). An empty map equals absence — build with notes=None - 6.1.11. Build a `WorkflowStage` from the validated values (agent, prompt, - loop, skills, skip, approve, manual, notes); an absent manual key - yields None (NOT False); an empty notes map yields None + 6.1.11. reflect (when present) must be a dict with a key set within + {file, mode}: a non-dict value raises "non-mapping reflect in + workflow.stages.NAME"; an unknown key raises "unknown key in + workflow.stages.NAME.reflect: KEY; valid keys: file, mode". file + is required, a str of a valid path shape — a missing file raises + "file is required in workflow.stages.NAME.reflect", a non-str + value raises "non-str value in workflow.stages.NAME.reflect.file", + a bad shape raises "invalid path in + workflow.stages.NAME.reflect.file: VALUE". mode (when present) + is a str and one of "r"/"w"/"rw" — otherwise raise "non-str + value in workflow.stages.NAME.reflect.mode" (non-str) or "mode + must be one of: r, w, rw in workflow.stages.NAME.reflect" (str + outside the set). Build `WorkflowReflect` with the authored + file and mode materialized to "rw" when absent + 6.1.12. memory (when present) must be a bool; otherwise raise + "non-bool value in workflow.stages.NAME.memory". An explicit false + equals absence — build with memory=None + 6.1.13. Build a `WorkflowStage` from the validated values (agent, prompt, + loop, skills, skip, approve, manual, notes, reflect, memory); an + absent manual key yields None (NOT False); an empty notes map yields + None 6.2. For each entry of extend (when present), identified by stage name and entry value: 6.2.1. If the entry value is not a dict — raise a structural error @@ -369,48 +542,62 @@ Annotations: | error "manual is forbidden in workflow.extend.NAME" 6.2.5. If the entry value contains a notes key — raise a structural error "notes is forbidden in workflow.extend.NAME" - 6.2.6. before (when present) must be a list[str]; otherwise raise + 6.2.6. If the entry value contains a reflect key — raise a structural + error "reflect is forbidden in workflow.extend.NAME" + 6.2.7. If the entry value contains a memory key — raise a structural + error "memory is forbidden in workflow.extend.NAME" + 6.2.8. before (when present) must be a list[str]; otherwise raise "non-list-of-str before in workflow.extend.NAME" - 6.2.7. after (when present) must be a list[str]; otherwise raise + 6.2.9. after (when present) must be a list[str]; otherwise raise "non-list-of-str after in workflow.extend.NAME" - 6.2.8. agent (when present) must be a str; otherwise raise + 6.2.10. agent (when present) must be a str; otherwise raise "non-str value in workflow.extend.NAME.agent" - 6.2.9. loop (when present) must be an int and >= 1; otherwise raise + 6.2.11. loop (when present) must be an int and >= 1; otherwise raise "non-int value in workflow.extend.NAME.loop" (non-int) or "loop must be >= 1 in workflow.extend.NAME" (int < 1) - 6.2.10. approve (when present) must be a str and one of "auto"/"plan"/"dialog"; + 6.2.12. approve (when present) must be a str and one of "auto"/"plan"/"dialog"; otherwise raise "non-str value in workflow.extend.NAME.approve" (non-str) or "approve must be one of: auto, plan, dialog in workflow.extend.NAME" (str outside the set) - 6.2.11. If neither before nor after is present — raise a structural error + 6.2.13. If neither before nor after is present — raise a structural error "extend entry NAME requires at least one of before/after" - 6.2.12. Other keys of the entry value are NOT validated (open-ended: + 6.2.14. Other keys of the entry value are NOT validated (open-ended: title, prompt, skills, roles, communication, trigger — a full stage-body field — and any other stage field) and pass through verbatim - 6.2.13. Build a `WorkflowExtendStage` from the validated before/after, + 6.2.15. Build a `WorkflowExtendStage` from the validated before/after, agent/loop/approve, and the REMAINING entry value (excluding before, after, agent, loop, approve, and depends_on) as body — agent/loop/ approve are extracted into the model, not carried in body, so they never reach the flow-file as stray stage fields + 6.3. Validate the correspondence between the materialized method and the + per-stage instructions (the method is "reflect" when no block is + authored): + - a reflect instruction under "alignment" raises "reflect is forbidden + in workflow.stages.NAME with method: alignment" + - a memory instruction under "reflect" raises "memory is forbidden in + workflow.stages.NAME with method: reflect" 7. If prompt is None AND stages is empty (no entries) AND extend is empty - (no entries) — raise a structural error "empty workflow — provide at - least prompt, one stage, or one extend entry" - 8. Return `WorkflowDocument` from the parsed prompt and stages + (no entries) AND memory is None — raise a structural error "empty + workflow — provide at least prompt, one stage, one extend entry, or + the memory block" + 8. Return `WorkflowDocument` from the parsed prompt, stages, extend, + and memory Apply `convention` for code style, exception message formatting, and docstring style. Requirements: - Top-level unknown keys are a structural error — only prompt, stages, - and extend are accepted + extend, and memory are accepted - extend-entry depends_on is a structural error; before/after (when present) must be list[str]; at least one of before/after is required - extend-entry names are NOT validated against any pipeline — unknown before/after names pass through; the compiler decides whether to apply or ignore (silently with a warning) - Per-stage unknown keys are a structural error — only agent, prompt, - loop, skills, skip, approve, manual, notes are accepted + loop, skills, skip, approve, manual, notes, reflect, memory are + accepted - skip (when present) must be a bool; a non-bool value is a structural error - skip is forbidden in an extend-entry — a structural error (skip is @@ -449,12 +636,21 @@ Annotations: | every name it references exists in each target pipeline; a name absent from a target pipeline is a structural error (not a silent warning+skip) — split or prune such workflows - - A workflow-file with neither prompt nor any stage entries is rejected + - A workflow-file with none of a prompt, any stage entries, any extend + entries, or the memory block is rejected — at least one must be present - agent value is NOT validated against a known agent set; absence of the wrapper file is surfaced by afm at invocation time - prompt contents (top-level and per-stage) are NOT validated — passed through verbatim to the consumer + - The memory block and both instructions are validated structurally: key + sets, types, value domains, path shapes, and the method ↔ instruction + correspondence + - Defaults are materialized in the model, not omitted + - An explicit memory: false equals absence — the model carries None + - A workflow consisting of the memory block alone is valid + - reflect and memory are forbidden in an extend entry — participation of a + new stage is authored in the stages block by its name Constraints: - Do not resolve agent to a wrapper path here — the compiler performs @@ -481,6 +677,12 @@ Annotations: | propagates unchanged (consistent with the compiler behavior) - Do not accept YAML files whose root is not a mapping — that is a structural error + - Do not compose the memory root prefix here — the consumer composes the + final path + - Do not decide block emission or stage participation here — the consumer + decides both + - Do not validate reflect/memory keys inside stage bodies here — stage bodies + are the consumer's input, checked at compilation --- From 2e01f743667537d9d19248371163ff0127ae5564 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 21:18:56 +0000 Subject: [PATCH 147/229] feat: add the WorkflowReflect and WorkflowMemory data models to the workflow cell --- goga/pipeline/workflow/__init__.py | 8 ++- goga/pipeline/workflow/workflow_memory.py | 69 +++++++++++++++++++ goga/pipeline/workflow/workflow_reflect.py | 50 ++++++++++++++ .../workflow/test_workflow_memory_contract.py | 66 ++++++++++++++++++ .../workflow/test_workflow_memory_logic.py | 51 ++++++++++++++ .../test_workflow_reflect_contract.py | 49 +++++++++++++ .../workflow/test_workflow_reflect_logic.py | 44 ++++++++++++ 7 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 goga/pipeline/workflow/workflow_memory.py create mode 100644 goga/pipeline/workflow/workflow_reflect.py create mode 100644 tests/pipeline/workflow/test_workflow_memory_contract.py create mode 100644 tests/pipeline/workflow/test_workflow_memory_logic.py create mode 100644 tests/pipeline/workflow/test_workflow_reflect_contract.py create mode 100644 tests/pipeline/workflow/test_workflow_reflect_logic.py diff --git a/goga/pipeline/workflow/__init__.py b/goga/pipeline/workflow/__init__.py index 908a1924..3841c829 100644 --- a/goga/pipeline/workflow/__init__.py +++ b/goga/pipeline/workflow/__init__.py @@ -9,17 +9,23 @@ entry. With the parser landed, the contract names ``parse_workflow``, ``WorkflowDocument``, and ``WorkflowStage`` are re-exported here, alongside the ``WorkflowSyntaxError`` structural-error type (mirroring the compiler cell's -``StructuralError`` re-export). +``StructuralError`` re-export). The memory surface adds the two authoring data +models — ``WorkflowMemory`` (the top-level memory block) and ``WorkflowReflect`` +(the per-stage reflection instruction) — bringing the facade to seven names. """ from .parse_workflow import WorkflowSyntaxError, parse_workflow from .workflow_document import WorkflowDocument from .workflow_extend_stage import WorkflowExtendStage +from .workflow_memory import WorkflowMemory +from .workflow_reflect import WorkflowReflect from .workflow_stage import WorkflowStage __all__: list[str] = [ "WorkflowDocument", "WorkflowExtendStage", + "WorkflowMemory", + "WorkflowReflect", "WorkflowStage", "WorkflowSyntaxError", "parse_workflow", diff --git a/goga/pipeline/workflow/workflow_memory.py b/goga/pipeline/workflow/workflow_memory.py new file mode 100644 index 00000000..e553ba81 --- /dev/null +++ b/goga/pipeline/workflow/workflow_memory.py @@ -0,0 +1,69 @@ +"""The ``WorkflowMemory`` dataclass — the workflow-memory configuration block. + +A workflow-file may carry a top-level ``memory`` block — the authoring form of +the flow-level memory settings. ``WorkflowMemory`` is the parsed representation +of that block. It is constructed by ``parse_workflow`` with materialized +defaults and carried verbatim inside ``WorkflowDocument``. + +The model is intentionally declarative — it holds the configuration, never +its resolution. ``method`` is the authoring method — ``"reflect"`` or +``"alignment"``: a goga-side selector of the instruction vocabulary, never +part of any output (the consumer selects the emission form). ``path`` is the +authored suffix inside the fixed memory root (``None`` means no suffix) — the +fixed root prefix is NOT part of this model; the consumer composes the final +path. ``max_rules`` is the maximum number of memory rules (always ``>= 1``). +``commit`` is whether memory changes are committed. ``mode`` is the +project-memory access mode — one of ``"r"``, ``"w"``, ``"rw"``; it exists only +for the ``"alignment"`` method (``None`` for ``"reflect"``). No validation +lives here either: ``parse_workflow`` enforces every invariant (the block key +set, field types, value domains, path shapes, the mode-only-under-alignment +rule) and raises a structural error before this dataclass is built. + +The field defaults ARE the materialized authoring defaults — the consumer +default-constructs ``WorkflowMemory()`` when the workflow carries no block, +sourcing ``max_rules`` / ``commit`` (and the default method) from them. + +Field order is fixed — ``method``, ``path``, ``max_rules``, ``commit``, +``mode`` — matching the canonical order of the block keys in the +workflow-file. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(kw_only=True) +class WorkflowMemory: + """The workflow-memory configuration extracted from a workflow-file's ``memory`` block. + + Every field carries a materialized default — defaults are values, not + omissions: ``method`` ``"reflect"``, ``path`` ``None``, ``max_rules`` + ``25``, ``commit`` ``False``, ``mode`` ``None`` for the ``"reflect"`` + method (``"rw"`` for ``"alignment"`` once ``parse_workflow`` materializes + it). Field order is fixed (``method``, ``path``, ``max_rules``, + ``commit``, ``mode``) to match the canonical order of the block keys in + the workflow-file. + + Args: + method: The authoring method — ``"reflect"`` or ``"alignment"``. A + goga-side selector of the instruction vocabulary; never part of + any output. This cell does not act on ``method`` — it is + declarative; the consumer selects the emission form. + path: Authored suffix inside the fixed memory root; ``None`` means no + suffix. Carries the authored suffix only — the fixed root prefix + is not part of this model; the consumer composes the final path. + max_rules: The maximum number of memory rules; always ``>= 1``. + ``parse_workflow`` enforces the bound during parsing. + commit: Whether memory changes are committed. + mode: The project-memory access mode — one of ``"r"``, ``"w"``, + ``"rw"``; exists only for the ``"alignment"`` method, ``None`` + for ``"reflect"``. ``parse_workflow`` enforces the domain (and + forbids ``mode`` under ``"reflect"``) during parsing. + """ + + method: str = "reflect" + path: str | None = None + max_rules: int = 25 + commit: bool = False + mode: str | None = None diff --git a/goga/pipeline/workflow/workflow_reflect.py b/goga/pipeline/workflow/workflow_reflect.py new file mode 100644 index 00000000..700d4387 --- /dev/null +++ b/goga/pipeline/workflow/workflow_reflect.py @@ -0,0 +1,50 @@ +"""The ``WorkflowReflect`` dataclass — one per-stage memory-reflection instruction. + +A workflow-file's ``stages`` map may carry a ``reflect`` entry per stage — the +memory-reflection instruction. ``WorkflowReflect`` is the parsed representation +of a single such entry: which memory file the stage reflects into (``file``) +and with which access mode (``mode``). It is constructed by ``parse_workflow`` +and carried verbatim inside ``WorkflowStage``. + +The model is intentionally declarative — it holds the instruction, never its +resolution. ``file`` is the reflection file — a path shape inside the memory +root, carried verbatim (the compiler emits it into the stage's ``reflect`` +field; the consumer composes paths). ``mode`` is the access mode — one of +``"r"``, ``"w"``, ``"rw"``, materialized to ``"rw"`` when the authoring entry +omits it. No validation lives here either: ``parse_workflow`` enforces every +invariant (the ``{file, mode}`` key set, ``file`` required and a valid path +shape, the ``mode`` domain) and raises a structural error before this +dataclass is built. This cell does not resolve ``file`` against any memory +root — the consumer composes the final path. + +Field order is fixed — ``file``, ``mode`` — matching the canonical order of +the reflect-instruction keys in the workflow-file. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(kw_only=True) +class WorkflowReflect: + """A single per-stage memory-reflection instruction from a workflow-file. + + ``file`` is required — every reflect instruction names a reflection file. + ``mode`` defaults to ``"rw"`` — a materialized value, not an omission, so + a consumer reading the model never distinguishes an authored ``rw`` from + an omitted mode. Field order is fixed (``file``, ``mode``) to match the + canonical order of the reflect-instruction keys in the workflow-file. + + Args: + file: The reflection file — a path shape inside the memory root; + carried verbatim. Required (no default); ``parse_workflow`` + enforces the path shape before this dataclass is built. + mode: The access mode — one of ``"r"``, ``"w"``, ``"rw"``; + materialized to ``"rw"`` when the authoring entry omits it. This + cell does not validate the mode domain — ``parse_workflow`` + enforces it during parsing. + """ + + file: str + mode: str = "rw" diff --git a/tests/pipeline/workflow/test_workflow_memory_contract.py b/tests/pipeline/workflow/test_workflow_memory_contract.py new file mode 100644 index 00000000..244b2868 --- /dev/null +++ b/tests/pipeline/workflow/test_workflow_memory_contract.py @@ -0,0 +1,66 @@ +"""Contract tests for the ``WorkflowMemory`` dataclass. + +Verifies the public API declared by the workflow-cell CODEMANIFEST: +importability from the facade (including the ``__all__`` obligation), the five +declared properties, the fixed field order, and kw_only construction. These +tests pin the contract surface — behavior lives in the logic test module. +""" + +from __future__ import annotations + +from dataclasses import fields + +import pytest +from goga.pipeline.workflow import WorkflowMemory + + +class TestWorkflowMemoryContract: + """Contract tests — the public API declared by the workflow-cell CODEMANIFEST.""" + + def test_workflow_memory_importable_from_facade(self) -> None: + """WorkflowMemory is importable from the facade and listed in ``__all__``.""" + import goga.pipeline.workflow as facade + + assert facade.WorkflowMemory is WorkflowMemory + assert "WorkflowMemory" in facade.__all__ + + def test_workflow_memory_has_method_property(self) -> None: + """WorkflowMemory exposes a ``method`` property defaulting to "reflect".""" + assert hasattr(WorkflowMemory(), "method") + assert WorkflowMemory().method == "reflect" + assert WorkflowMemory(method="alignment").method == "alignment" + + def test_workflow_memory_has_path_property(self) -> None: + """WorkflowMemory exposes a ``path`` property defaulting to None.""" + assert hasattr(WorkflowMemory(), "path") + assert WorkflowMemory().path is None + assert WorkflowMemory(path="goga-development").path == "goga-development" + + def test_workflow_memory_has_max_rules_property(self) -> None: + """WorkflowMemory exposes a ``max_rules`` property defaulting to 25.""" + assert hasattr(WorkflowMemory(), "max_rules") + assert WorkflowMemory().max_rules == 25 + assert WorkflowMemory(max_rules=40).max_rules == 40 + + def test_workflow_memory_has_commit_property(self) -> None: + """WorkflowMemory exposes a ``commit`` property defaulting to False.""" + assert hasattr(WorkflowMemory(), "commit") + assert WorkflowMemory().commit is False + assert WorkflowMemory(commit=True).commit is True + + def test_workflow_memory_has_mode_property(self) -> None: + """WorkflowMemory exposes a ``mode`` property defaulting to None.""" + assert hasattr(WorkflowMemory(), "mode") + assert WorkflowMemory().mode is None + assert WorkflowMemory(mode="rw").mode == "rw" + + def test_workflow_memory_field_order_fixed(self) -> None: + """Field order is fixed: method, path, max_rules, commit, mode.""" + names = [field.name for field in fields(WorkflowMemory)] + + assert names == ["method", "path", "max_rules", "commit", "mode"] + + def test_workflow_memory_constructible_kw_only(self) -> None: + """WorkflowMemory is keyword-only — positional construction raises TypeError.""" + with pytest.raises(TypeError): + WorkflowMemory("reflect") # type: ignore[misc] diff --git a/tests/pipeline/workflow/test_workflow_memory_logic.py b/tests/pipeline/workflow/test_workflow_memory_logic.py new file mode 100644 index 00000000..97470cee --- /dev/null +++ b/tests/pipeline/workflow/test_workflow_memory_logic.py @@ -0,0 +1,51 @@ +"""Logic tests for the ``WorkflowMemory`` dataclass. + +Covers construction behavior beyond the contract surface: the materialized +defaults (values, not omissions) and the verbatim round-trip of every authored +field. The defaults pin is load-bearing downstream — the consumer +default-constructs ``WorkflowMemory()`` when the workflow carries no block and +sources ``max_rules`` / ``commit`` (and the default method) from the field +defaults, so a silent shift of these values would change compiled output. +""" + +from __future__ import annotations + +from goga.pipeline.workflow import WorkflowMemory + + +class TestWorkflowMemoryLogic: + """Logic tests — construction behavior of the ``WorkflowMemory`` dataclass.""" + + def test_workflow_memory_defaults_are_materialized(self) -> None: + """Constructing with no arguments yields the materialized authoring defaults. + + method "reflect", path None, max_rules 25, commit False, mode None — + the values the emission case «no ``memory:`` block authored» sources. + The pin prevents a silent drift of the defaults into compiled output. + """ + config = WorkflowMemory() + + assert config.method == "reflect" + assert config.path is None + assert config.max_rules == 25 + assert config.commit is False + assert config.mode is None + + def test_workflow_memory_stores_authored_values_verbatim(self) -> None: + """Every field round-trips an authored block verbatim (no composition, no rewrite).""" + config = WorkflowMemory(method="alignment", path="p", max_rules=9, commit=True, mode="r") + + assert config.method == "alignment" + assert config.path == "p" + assert config.max_rules == 9 + assert config.commit is True + assert config.mode == "r" + + def test_equality_of_identical_constructions(self) -> None: + """Two configurations with identical fields compare equal. + + The no-block default equals an explicitly authored ``memory: {}`` — + both describe the same configuration, so the parser's materialized + block and the consumer's default construction must compare equal. + """ + assert WorkflowMemory() == WorkflowMemory(method="reflect", path=None, max_rules=25, commit=False, mode=None) diff --git a/tests/pipeline/workflow/test_workflow_reflect_contract.py b/tests/pipeline/workflow/test_workflow_reflect_contract.py new file mode 100644 index 00000000..ae0f8b4b --- /dev/null +++ b/tests/pipeline/workflow/test_workflow_reflect_contract.py @@ -0,0 +1,49 @@ +"""Contract tests for the ``WorkflowReflect`` dataclass. + +Verifies the public API declared by the workflow-cell CODEMANIFEST: +importability from the facade (including the ``__all__`` obligation), the two +declared properties, the fixed field order, and kw_only construction. These +tests pin the contract surface — behavior lives in the logic test module. +""" + +from __future__ import annotations + +from dataclasses import fields + +import pytest +from goga.pipeline.workflow import WorkflowReflect + + +class TestWorkflowReflectContract: + """Contract tests — the public API declared by the workflow-cell CODEMANIFEST.""" + + def test_workflow_reflect_importable_from_facade(self) -> None: + """WorkflowReflect is importable from the facade and listed in ``__all__``.""" + import goga.pipeline.workflow as facade + + assert facade.WorkflowReflect is WorkflowReflect + assert "WorkflowReflect" in facade.__all__ + + def test_workflow_reflect_has_file_property(self) -> None: + """WorkflowReflect exposes a ``file`` property.""" + reflect = WorkflowReflect(file="a.md") + + assert hasattr(reflect, "file") + assert reflect.file == "a.md" + + def test_workflow_reflect_has_mode_property(self) -> None: + """WorkflowReflect exposes a ``mode`` property defaulting to the materialized "rw".""" + assert hasattr(WorkflowReflect(file="a.md"), "mode") + assert WorkflowReflect(file="a.md").mode == "rw" + assert WorkflowReflect(file="a.md", mode="r").mode == "r" + + def test_workflow_reflect_field_order_fixed(self) -> None: + """Field order is fixed: file, mode.""" + names = [field.name for field in fields(WorkflowReflect)] + + assert names == ["file", "mode"] + + def test_workflow_reflect_constructible_kw_only(self) -> None: + """WorkflowReflect is keyword-only — positional construction raises TypeError.""" + with pytest.raises(TypeError): + WorkflowReflect("a.md") # type: ignore[misc] diff --git a/tests/pipeline/workflow/test_workflow_reflect_logic.py b/tests/pipeline/workflow/test_workflow_reflect_logic.py new file mode 100644 index 00000000..8b089288 --- /dev/null +++ b/tests/pipeline/workflow/test_workflow_reflect_logic.py @@ -0,0 +1,44 @@ +"""Logic tests for the ``WorkflowReflect`` dataclass. + +Covers construction behavior beyond the contract surface: the materialized +``mode`` default (a value, not an omission) and the verbatim round-trip of +authored values (the dataclass is not frozen, but supplied values must +round-trip unchanged). +""" + +from __future__ import annotations + +from goga.pipeline.workflow import WorkflowReflect + + +class TestWorkflowReflectLogic: + """Logic tests — construction behavior of the ``WorkflowReflect`` dataclass.""" + + def test_workflow_reflect_mode_defaults_to_rw(self) -> None: + """Omitting ``mode`` yields the materialized "rw" — not None. + + The contract materializes the default: «materialized to "rw" when the + authoring entry omits it». A consumer reading the model must never + distinguish an authored ``rw`` from an omitted mode, so the default + pins a value rather than an absence. + """ + reflect = WorkflowReflect(file="shared.md") + + assert reflect.file == "shared.md" + assert reflect.mode == "rw" + + def test_workflow_reflect_stores_authored_values_verbatim(self) -> None: + """Both fields round-trip authored values verbatim (no path or mode rewriting).""" + reflect = WorkflowReflect(file="a.md", mode="r") + + assert (reflect.file, reflect.mode) == ("a.md", "r") + + def test_equality_of_identical_constructions(self) -> None: + """Two instructions with identical fields compare equal. + + The omitted-mode and explicit-``rw`` constructions describe the same + instruction and must compare equal — the materialized default is + indistinguishable from the authored value. + """ + assert WorkflowReflect(file="a.md") == WorkflowReflect(file="a.md", mode="rw") + assert WorkflowReflect(file="a.md", mode="r") != WorkflowReflect(file="a.md") From f2e831d7e107ea4e66c22e0ea608f26002e69ce6 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 21:24:01 +0000 Subject: [PATCH 148/229] feat: add the reflect and memory fields to WorkflowStage and WorkflowDocument --- goga/pipeline/workflow/workflow_document.py | 53 ++++++++++----- goga/pipeline/workflow/workflow_stage.py | 53 +++++++++++---- .../test_workflow_document_contract.py | 48 ++++++++++++-- .../workflow/test_workflow_document_logic.py | 37 +++++++++++ .../workflow/test_workflow_stage_contract.py | 38 +++++++++-- .../workflow/test_workflow_stage_logic.py | 64 ++++++++++++++++++- 6 files changed, 247 insertions(+), 46 deletions(-) diff --git a/goga/pipeline/workflow/workflow_document.py b/goga/pipeline/workflow/workflow_document.py index cfa47241..d3c6dead 100644 --- a/goga/pipeline/workflow/workflow_document.py +++ b/goga/pipeline/workflow/workflow_document.py @@ -1,21 +1,32 @@ """The ``WorkflowDocument`` dataclass — the aggregated workflow-file document. A workflow-file combines an optional top-level ``prompt``, a map of -per-stage override instructions (``stages``, keyed by stage name), and a map -of new-stage declarations (``extend``, keyed by stage name). -``WorkflowDocument`` is the single parsed value representing the whole file. -It is built by ``parse_workflow`` and consumed by the compiler via its -workflow parameter. +per-stage override instructions (``stages``, keyed by stage name), a map of +new-stage declarations (``extend``, keyed by stage name), and an optional +workflow-memory configuration (``memory``). ``WorkflowDocument`` is the +single parsed value representing the whole file. It is built by +``parse_workflow`` and consumed by the compiler via its workflow parameter. The model is intentionally declarative — it carries instructions, never their resolution. ``prompt`` is verbatim text (the compiler emits it as the first top-level key of the flow-file); ``stages`` is a verbatim map of ``WorkflowStage`` instructions (the compiler applies each to the matching pipeline stage); ``extend`` is a verbatim map of ``WorkflowExtendStage`` -declarations (the compiler embeds each as a new stage). No validation lives -here: ``parse_workflow`` enforces every structural invariant (key set, field -types, the at-least-one requirement) and raises a structural error before -this dataclass is built. +declarations (the compiler embeds each as a new stage); ``memory`` is the +workflow-memory configuration extracted from the optional top-level +``memory`` block (a :class:`WorkflowMemory`, or ``None`` when the +workflow-file carries no block) — the compiler decides block emission and +stage participation from it. No validation lives here: ``parse_workflow`` +enforces every structural invariant (key set, field types, the +at-least-one requirement, the memory-block key set and value domains) and +raises a structural error before this dataclass is built. + +The empty-workflow rule counts the memory block: a workflow is empty only +when ``prompt`` is ``None`` AND ``stages`` is empty AND ``extend`` is empty +AND ``memory`` is ``None`` — a workflow consisting of the block alone is +valid. Field order is fixed — ``prompt``, ``stages``, ``extend``, +``memory`` — matching the canonical order of the top-level keys in the +workflow-file. """ from __future__ import annotations @@ -23,6 +34,7 @@ from dataclasses import dataclass, field from .workflow_extend_stage import WorkflowExtendStage +from .workflow_memory import WorkflowMemory from .workflow_stage import WorkflowStage @@ -30,15 +42,16 @@ class WorkflowDocument: """Aggregated workflow-file document — top-level prompt plus per-stage overrides. - ``prompt`` defaults to ``None`` and ``stages``/``extend`` default to - empty dicts via ``field(default_factory=dict)`` (the DSL signature lists + ``prompt`` defaults to ``None``, ``stages``/``extend`` default to empty + dicts via ``field(default_factory=dict)`` (the DSL signature lists ``None``; the factory is applied at construction so two documents never - share a stages or extend map). A workflow-file with neither a top-level - ``prompt``, any stage entries, nor any extend entries is rejected by - ``parse_workflow`` with a structural error before this dataclass is - built — at least one must be present. The new ``extend`` field is - backward-compatible: its default means existing consumers (which never - pass ``extend``) keep working. + share a stages or extend map), and ``memory`` defaults to ``None``. A + workflow-file with neither a top-level ``prompt``, any stage entries, any + extend entries, nor a memory block is rejected by ``parse_workflow`` with + a structural error before this dataclass is built — at least one must be + present. Field order is fixed (``prompt``, ``stages``, ``extend``, + ``memory``) to match the canonical order of the top-level keys in the + workflow-file. Args: prompt: Top-level prompt text emitted by the compiler as the first @@ -50,8 +63,14 @@ class WorkflowDocument: :class:`WorkflowExtendStage` carrying ``before``/``after`` positioning and a verbatim body). Empty map when the workflow-file has no extend section. + memory: Workflow-memory configuration extracted from the optional + top-level ``memory`` block (a :class:`WorkflowMemory` with + materialized defaults), or ``None`` when the workflow-file + carries no block. A workflow consisting of the block alone is + valid. """ prompt: str | None = None stages: dict[str, WorkflowStage] = field(default_factory=dict) extend: dict[str, WorkflowExtendStage] = field(default_factory=dict) + memory: WorkflowMemory | None = None diff --git a/goga/pipeline/workflow/workflow_stage.py b/goga/pipeline/workflow/workflow_stage.py index 90ae5117..eefa46c1 100644 --- a/goga/pipeline/workflow/workflow_stage.py +++ b/goga/pipeline/workflow/workflow_stage.py @@ -23,33 +23,41 @@ ``manual: false`` are distinct instructions (the compiler resolves them); ``notes`` is an optional map of note name → prompt text (a declarative note-buttons instruction — the compiler emits the stage's ``buttons`` field -from it). No validation lives here either: ``parse_workflow`` enforces every -invariant (key set, field types, ``loop >= 1``, ``skip`` is a bool, -``approve`` is one of ``"auto"``/``"plan"``/``"dialog"``, ``manual`` is a -bool, ``notes`` is a str→str map) and raises a structural error before this -dataclass is built. +from it); ``reflect`` is an optional memory-reflection instruction (a +:class:`WorkflowReflect` naming the reflection file and its access mode); +``memory`` is an optional memory-participation instruction (a bool). The two +memory instructions are declarative — extracted here, consumed by the +compiler to emit the stage's ``reflect`` field / ``memory_use`` participation +when the workflow's memory block is emitted. No validation lives here +either: ``parse_workflow`` enforces every invariant (key set, field types, +``loop >= 1``, ``skip`` is a bool, ``approve`` is one of +``"auto"``/``"plan"``/``"dialog"``, ``manual`` is a bool, ``notes`` is a +str→str map, ``reflect`` is a ``{file, mode}`` mapping, ``memory`` is a bool) +and raises a structural error before this dataclass is built. Field order is fixed — ``agent``, ``prompt``, ``loop``, ``skills``, ``skip``, -``approve``, ``manual``, ``notes`` — to match the canonical order of the -per-stage keys in the workflow-file. +``approve``, ``manual``, ``notes``, ``reflect``, ``memory`` — to match the +canonical order of the per-stage keys in the workflow-file. """ from __future__ import annotations from dataclasses import dataclass +from .workflow_reflect import WorkflowReflect + @dataclass(kw_only=True) class WorkflowStage: """A single per-stage override instruction from a workflow-file. - The seven fields ``agent``, ``prompt``, ``loop``, ``skills``, - ``approve``, ``manual``, and ``notes`` default to ``None`` — a - workflow-file may omit any of them, and ``parse_workflow`` produces - ``None`` for missing fields; ``skip`` defaults to ``False``. Field order - is fixed (``agent``, ``prompt``, ``loop``, ``skills``, ``skip``, - ``approve``, ``manual``, ``notes``) to match the canonical order of the - per-stage keys in the workflow-file. + The nine fields ``agent``, ``prompt``, ``loop``, ``skills``, + ``approve``, ``manual``, ``notes``, ``reflect``, and ``memory`` default to + ``None`` — a workflow-file may omit any of them, and ``parse_workflow`` + produces ``None`` for missing fields; ``skip`` defaults to ``False``. + Field order is fixed (``agent``, ``prompt``, ``loop``, ``skills``, + ``skip``, ``approve``, ``manual``, ``notes``, ``reflect``, ``memory``) to + match the canonical order of the per-stage keys in the workflow-file. Args: agent: Agent name consumed by the compiler to compose the per-stage @@ -103,6 +111,21 @@ class WorkflowStage: normalizes it to ``None``), so this field carries either ``None`` or a non-empty map. This cell does not act on ``notes`` — it is declarative. + reflect: Optional memory-reflection instruction (a + :class:`WorkflowReflect` carrying the reflection file and the + access mode), or ``None`` when not specified. Declarative — + extracted here, consumed by the compiler to emit the stage's + ``reflect`` field. This cell does not act on ``reflect`` — it is + declarative; the compiler performs the emission when applying the + workflow. + memory: Optional memory-participation instruction, or ``None`` when + not specified. Declarative — extracted here, consumed by the + compiler to emit the stage's memory participation. An explicit + ``memory: false`` equals absence (normalized to ``None`` by + ``parse_workflow``), so this field carries either ``None`` or + ``True``. This cell does not act on ``memory`` — it is + declarative; the compiler performs the emission when applying the + workflow. """ agent: str | None = None @@ -113,3 +136,5 @@ class WorkflowStage: approve: str | None = None manual: bool | None = None notes: dict[str, str] | None = None + reflect: WorkflowReflect | None = None + memory: bool | None = None diff --git a/tests/pipeline/workflow/test_workflow_document_contract.py b/tests/pipeline/workflow/test_workflow_document_contract.py index 2bfbe58b..0d6812e8 100644 --- a/tests/pipeline/workflow/test_workflow_document_contract.py +++ b/tests/pipeline/workflow/test_workflow_document_contract.py @@ -1,19 +1,23 @@ """Contract tests for the ``WorkflowDocument`` dataclass. Verifies the public API declared by the workflow-cell CODEMANIFEST: -importability from the facade, the three declared properties (``prompt``, -``stages``, and ``extend``), the ``prompt=None`` / ``stages={}`` / -``extend={}`` defaults (the factories are applied at construction, so the -default ``stages``/``extend`` are empty dicts, not None), and kw_only -construction with explicit stages and extend maps. These tests pin the -contract surface — behavior lives in the logic test module. +importability from the facade, the declared properties (``prompt``, +``stages``, ``extend``, and ``memory``), the ``prompt=None`` / ``stages={}`` / +``extend={}`` / ``memory=None`` defaults (the factories are applied at +construction, so the default ``stages``/``extend`` are empty dicts, not None), +the fixed field order, and kw_only construction with explicit stages and +extend maps. These tests pin the contract surface — behavior lives in the +logic test module. """ from __future__ import annotations +import dataclasses + from goga.pipeline.workflow import ( WorkflowDocument, WorkflowExtendStage, + WorkflowMemory, WorkflowStage, ) @@ -78,8 +82,38 @@ def test_workflow_document_default_extend_is_empty_dict(self) -> None: def test_workflow_document_constructible_kw_only_with_explicit_extend(self) -> None: """WorkflowDocument accepts extend as a keyword-only argument and stores the map.""" extend = {"extra": WorkflowExtendStage(after=["review"], body={"title": "Extra"})} - document = WorkflowDocument(extend=extend) + document = WorkflowDocument(prompt="guidance", stages={}, extend=extend, memory=None) assert set(document.extend) == {"extra"} assert document.extend["extra"].after == ["review"] assert document.extend["extra"].body == {"title": "Extra"} + assert document.memory is None + + def test_workflow_document_has_memory_property(self) -> None: + """WorkflowDocument exposes a ``memory`` property defaulting to None.""" + assert hasattr(WorkflowDocument(), "memory") + assert WorkflowDocument().memory is None + assert WorkflowDocument(memory=WorkflowMemory()).memory == WorkflowMemory() + + def test_workflow_document_memory_defaults_to_none(self) -> None: + """The default ``memory`` is None — no workflow-memory block was authored.""" + document = WorkflowDocument() + + assert document.memory is None + # A document built with stages/extend and no memory keeps the default. + assert WorkflowDocument(stages={"build": WorkflowStage()}).memory is None + assert WorkflowDocument(extend={"x": WorkflowExtendStage(body={})}).memory is None + + def test_workflow_document_field_order_fixed(self) -> None: + """Field order is fixed: prompt, stages, extend, memory.""" + names = [field.name for field in dataclasses.fields(WorkflowDocument)] + + assert names == ["prompt", "stages", "extend", "memory"] + + def test_workflow_document_constructible_kw_only_with_memory(self) -> None: + """WorkflowDocument accepts memory as a keyword-only argument, stored verbatim.""" + memory = WorkflowMemory(method="alignment", path="goga-development") + document = WorkflowDocument(memory=memory) + + assert document.memory is memory + assert document.memory == WorkflowMemory(method="alignment", path="goga-development") diff --git a/tests/pipeline/workflow/test_workflow_document_logic.py b/tests/pipeline/workflow/test_workflow_document_logic.py index 023e3485..bb872e0b 100644 --- a/tests/pipeline/workflow/test_workflow_document_logic.py +++ b/tests/pipeline/workflow/test_workflow_document_logic.py @@ -12,6 +12,7 @@ from goga.pipeline.workflow import ( WorkflowDocument, WorkflowExtendStage, + WorkflowMemory, WorkflowStage, ) @@ -103,3 +104,39 @@ def test_equality_of_identical_documents(self) -> None: second = WorkflowDocument(prompt="guidance", stages=dict(stages)) assert first == second + + def test_workflow_document_memory_defaults_none(self) -> None: + """The default ``memory`` is None — no workflow-memory block was authored.""" + assert WorkflowDocument().memory is None + assert WorkflowDocument(prompt="guidance").memory is None + assert WorkflowDocument(stages={"x": WorkflowStage()}).memory is None + assert WorkflowDocument(extend={"y": WorkflowExtendStage(body={})}).memory is None + + def test_workflow_document_memory_stored_verbatim(self) -> None: + """The supplied ``memory`` is stored verbatim — the same object, not a copy.""" + memory = WorkflowMemory(method="alignment", path="goga-development", max_rules=7) + document = WorkflowDocument(memory=memory) + + assert document.memory is memory + assert document.memory == WorkflowMemory(method="alignment", path="goga-development", max_rules=7) + + def test_workflow_document_memory_block_alone_is_a_document(self) -> None: + """A document carrying only the memory block is a valid parsed shape. + + The empty-workflow rule counts the block (``parse_workflow`` enforces + it, Task 3): the model itself accepts a block-only document. + """ + document = WorkflowDocument(memory=WorkflowMemory(max_rules=40)) + + assert document.prompt is None + assert document.stages == {} + assert document.extend == {} + assert document.memory == WorkflowMemory(max_rules=40) + + def test_workflow_document_memory_does_not_leak_into_other_instances(self) -> None: + """A default-constructed document never inherits another document's memory.""" + first = WorkflowDocument(memory=WorkflowMemory()) + second = WorkflowDocument() + + assert first.memory is not None + assert second.memory is None diff --git a/tests/pipeline/workflow/test_workflow_stage_contract.py b/tests/pipeline/workflow/test_workflow_stage_contract.py index 2a7a5351..02a2da26 100644 --- a/tests/pipeline/workflow/test_workflow_stage_contract.py +++ b/tests/pipeline/workflow/test_workflow_stage_contract.py @@ -1,14 +1,16 @@ """Contract tests for the ``WorkflowStage`` dataclass. Verifies the public API declared by the workflow-cell CODEMANIFEST: -importability from the facade, the five declared properties, the all-``None`` -defaults (``skip`` defaults to ``False``), and kw_only construction. These tests -pin the contract surface — behavior lives in the logic test module. +importability from the facade, the declared properties (``agent``, ``prompt``, +``loop``, ``skills``, ``skip``, ``approve``, ``manual``, ``notes``, ``reflect``, +``memory``), the all-``None`` defaults (``skip`` defaults to ``False``), and +kw_only construction. These tests pin the contract surface — behavior lives in +the logic test module. """ from __future__ import annotations -from goga.pipeline.workflow import WorkflowStage +from goga.pipeline.workflow import WorkflowReflect, WorkflowStage class TestWorkflowStageContract: @@ -80,6 +82,28 @@ def test_workflow_stage_has_notes_property(self) -> None: assert WorkflowStage().notes is None assert WorkflowStage(notes={"fix": "F"}).notes == {"fix": "F"} + def test_workflow_stage_has_reflect_property(self) -> None: + """WorkflowStage exposes a ``reflect`` property defaulting to None. + + The property carries the optional memory-reflection instruction (a + :class:`WorkflowReflect`) — declarative, consumed by the compiler to + emit the stage's ``reflect`` field. + """ + assert hasattr(WorkflowStage(), "reflect") + assert WorkflowStage().reflect is None + assert WorkflowStage(reflect=WorkflowReflect(file="a.md")).reflect == WorkflowReflect(file="a.md") + + def test_workflow_stage_has_memory_property(self) -> None: + """WorkflowStage exposes a ``memory`` property defaulting to None. + + The property is tri-state in the authoring file but two-valued in the + model — ``None`` (no instruction) or ``True``; an explicit + ``memory: false`` is normalized to ``None`` by ``parse_workflow``. + """ + assert hasattr(WorkflowStage(), "memory") + assert WorkflowStage().memory is None + assert WorkflowStage(memory=True).memory is True + def test_workflow_stage_defaults_all_none(self) -> None: """Every field defaults to None when constructed with no arguments.""" stage = WorkflowStage() @@ -91,7 +115,7 @@ def test_workflow_stage_defaults_all_none(self) -> None: assert stage.approve is None def test_workflow_stage_constructible_kw_only(self) -> None: - """WorkflowStage accepts all eight fields as keyword-only arguments.""" + """WorkflowStage accepts all ten fields as keyword-only arguments.""" stage = WorkflowStage( agent="codex", prompt="text", @@ -101,6 +125,8 @@ def test_workflow_stage_constructible_kw_only(self) -> None: approve="auto", manual=True, notes={"fix": "Fix and continue"}, + reflect=WorkflowReflect(file="a.md"), + memory=True, ) assert stage.agent == "codex" @@ -111,3 +137,5 @@ def test_workflow_stage_constructible_kw_only(self) -> None: assert stage.approve == "auto" assert stage.manual is True assert stage.notes == {"fix": "Fix and continue"} + assert stage.reflect == WorkflowReflect(file="a.md") + assert stage.memory is True diff --git a/tests/pipeline/workflow/test_workflow_stage_logic.py b/tests/pipeline/workflow/test_workflow_stage_logic.py index 75791848..1c9848da 100644 --- a/tests/pipeline/workflow/test_workflow_stage_logic.py +++ b/tests/pipeline/workflow/test_workflow_stage_logic.py @@ -10,7 +10,7 @@ from dataclasses import fields -from goga.pipeline.workflow import WorkflowStage +from goga.pipeline.workflow import WorkflowReflect, WorkflowStage class TestWorkflowStageLogic: @@ -114,10 +114,26 @@ def test_workflow_stage_skip_defaults_false(self) -> None: assert WorkflowStage(skip=True).skip is True def test_field_order_fixed_canonical(self) -> None: - """Field order is fixed: agent, prompt, loop, skills, skip, approve, manual, notes.""" + """Field order is fixed across all ten fields. + + agent, prompt, loop, skills, skip, approve, manual, notes, reflect, + memory — the memory instructions occupy the two final canonical + slots, matching the per-stage key order of the workflow-file. + """ names = [field.name for field in fields(WorkflowStage)] - assert names == ["agent", "prompt", "loop", "skills", "skip", "approve", "manual", "notes"] + assert names == [ + "agent", + "prompt", + "loop", + "skills", + "skip", + "approve", + "manual", + "notes", + "reflect", + "memory", + ] def test_workflow_stage_approve_defaults_none(self) -> None: """Omitting ``approve`` yields None — no auto-approval directive.""" @@ -192,3 +208,45 @@ def test_all_defaults_construction_yields_skip_false(self) -> None: assert stage == WorkflowStage(agent=None, prompt=None, loop=None, skills=None) assert stage.skip is False + + def test_workflow_stage_reflect_defaults_none(self) -> None: + """Omitting ``reflect`` yields None — no memory-reflection instruction.""" + assert WorkflowStage().reflect is None + assert WorkflowStage(agent="codex").reflect is None + assert WorkflowStage(skip=True).reflect is None + + def test_workflow_stage_reflect_stored_verbatim(self) -> None: + """reflect stores the supplied WorkflowReflect verbatim (same object).""" + reflect = WorkflowReflect(file="shared.md", mode="r") + stage = WorkflowStage(reflect=reflect) + + assert stage.reflect == WorkflowReflect(file="shared.md", mode="r") + assert stage.reflect is reflect + + def test_workflow_stage_memory_defaults_none_not_false(self) -> None: + """memory defaults to None (NOT False) — absence is the only "no" state. + + Unlike ``manual`` (whose tri-state keeps an explicit false + distinguishable), an explicit ``memory: false`` equals absence: + ``parse_workflow`` normalizes it to ``None`` (Task 3), so this field + carries either ``None`` or ``True``. + """ + assert WorkflowStage().memory is None + assert WorkflowStage(skip=True).memory is None + assert WorkflowStage(agent="codex").memory is None + + def test_workflow_stage_memory_true_stored_verbatim(self) -> None: + """memory=True round-trips verbatim and coexists with the other fields.""" + stage = WorkflowStage(agent="codex", memory=True) + + assert stage.memory is True + assert stage.agent == "codex" + assert WorkflowStage(memory=True).memory is True + + def test_reflect_and_memory_occupy_final_two_slots(self) -> None: + """reflect and memory sit immediately after notes, in that order.""" + names = [field.name for field in fields(WorkflowStage)] + + assert names.index("notes") == 7 + assert names.index("reflect") == 8 + assert names.index("memory") == 9 From 0f75456b22a23b89816abafb9a1cf673d9406aef Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 21:33:09 +0000 Subject: [PATCH 149/229] feat: validate the memory authoring surface in parse_workflow --- goga/pipeline/workflow/parse_workflow.py | 571 +++++++++++++++--- .../workflow/test_parse_workflow_contract.py | 8 +- .../workflow/test_parse_workflow_memory.py | 371 ++++++++++++ 3 files changed, 879 insertions(+), 71 deletions(-) create mode 100644 tests/pipeline/workflow/test_parse_workflow_memory.py diff --git a/goga/pipeline/workflow/parse_workflow.py b/goga/pipeline/workflow/parse_workflow.py index 70361e31..5c048275 100644 --- a/goga/pipeline/workflow/parse_workflow.py +++ b/goga/pipeline/workflow/parse_workflow.py @@ -14,13 +14,15 @@ A workflow-file is structurally malformed when its YAML is invalid, its root is not a mapping, it carries an unknown top-level or per-stage key, a field has the wrong type (including a non-bool ``manual``), an extend-entry forbids -``depends_on`` / ``skip`` / ``manual`` / ``notes`` / mistypes ``before`` / -``after`` / omits both / mistypes an inline ``agent`` / ``loop`` / ``approve``, -a ``loop`` is below one, or it provides neither a top-level prompt, any stage -entry, nor any extend entry. Each of those raises ``WorkflowSyntaxError`` (a -``ValueError`` subclass, mirroring the compiler cell's ``StructuralError``) -with an authored-time message. A missing or unreadable file lets the underlying -``OSError`` propagate unchanged — consistent with the compiler behavior. +``depends_on`` / ``skip`` / ``manual`` / ``notes`` / ``reflect`` / ``memory`` / +mistypes ``before`` / ``after`` / omits both / mistypes an inline ``agent`` / +``loop`` / ``approve``, a ``loop`` is below one, its memory authoring violates +the structural schema, or it provides neither a top-level prompt, any stage +entry, any extend entry, nor the memory block. Each of those raises +``WorkflowSyntaxError`` (a ``ValueError`` subclass, mirroring the compiler +cell's ``StructuralError``) with an authored-time message. A missing or +unreadable file lets the underlying ``OSError`` propagate unchanged — +consistent with the compiler behavior. ``manual`` is accepted ONLY in the ``stages`` block (strictly a bool; an absent key builds ``None``, NOT ``False`` — the three states stay distinguishable for @@ -36,31 +38,73 @@ likewise forbidden in an extend-entry — the compiler consumes it per stage name to emit the flow-file buttons. This cell does not act on ``notes`` — it is declarative; the runtime meaning of the buttons belongs to afm. + +``memory`` is a declarative workflow-memory configuration — an optional +top-level block plus two per-stage participation instructions. The block keys +are ``method`` (``reflect``/``alignment``), ``path`` (a suffix inside the fixed +memory root), ``max_rules`` (int >= 1), ``commit`` (bool), and ``mode`` +(``r``/``w``/``rw`` — alignment only, a structural error under ``reflect``); +``path`` and every ``reflect.file`` must be a valid path shape (non-empty, not +absolute, no ``..``). Under ``reflect`` a stage carries ``reflect: {file, +mode?}``; under ``alignment`` it carries ``memory: <bool>`` — an explicit +``false`` equals absence and builds ``None``. The method ↔ instruction +correspondence is enforced here (the default method is ``reflect``, so a +``memory`` instruction without an authored ``method: alignment`` block is an +error), both instructions are forbidden in an extend-entry (participation of a +new stage is authored in the ``stages`` block by its name), and a workflow +consisting of the block alone is valid. Defaults are materialized in the model, +never omitted: ``WorkflowMemory`` (method ``reflect``, ``max_rules`` 25, +``commit`` False, ``mode`` ``rw`` under alignment / ``None`` under reflect) and +``WorkflowReflect`` (``mode`` ``rw`` when the entry omits it). This cell +performs NO memory logic — it composes no paths, decides no block emission, and +resolves no stage participation; the consumer consumes the extracted values. """ from __future__ import annotations -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any import yaml from .workflow_document import WorkflowDocument from .workflow_extend_stage import WorkflowExtendStage +from .workflow_memory import WorkflowMemory +from .workflow_reflect import WorkflowReflect from .workflow_stage import WorkflowStage # Fixed keys of the top-level workflow mapping. Used for unknown-key rejection. -_TOP_LEVEL_KEYS = ("prompt", "stages", "extend") +_TOP_LEVEL_KEYS = ("prompt", "stages", "extend", "memory") # Fixed keys of a per-stage entry, in canonical order. Used both for unknown-key # rejection and for documenting the accepted per-stage field set. -_STAGE_KEYS = ("agent", "prompt", "loop", "skills", "skip", "approve", "manual", "notes") +_STAGE_KEYS = ("agent", "prompt", "loop", "skills", "skip", "approve", "manual", "notes", "reflect", "memory") + +# Fixed keys of the top-level ``memory`` block, in canonical order. Used for +# unknown-key rejection and for documenting the accepted block field set. The +# goga-side ``method`` selector never reaches any output — it selects the +# instruction vocabulary the per-stage entries must conform to. +_MEMORY_KEYS = ("method", "path", "max_rules", "commit", "mode") + +# Fixed keys of a per-stage ``reflect`` instruction, in canonical order. Used +# for the unknown-key rejection of the instruction's own key set. +_REFLECT_KEYS = ("file", "mode") + +# Accepted values of the ``memory`` block's ``method`` selector — the goga-side +# choice of the per-stage instruction vocabulary: ``reflect`` pairs with the +# per-stage ``reflect`` instruction, ``alignment`` with the per-stage ``memory`` +# instruction. ``reflect`` is the default when the block is absent entirely. +_MEMORY_METHODS = ("reflect", "alignment") + +# Accepted values of the project-memory access mode — shared by the block's +# ``mode`` key and a ``reflect`` instruction's ``mode`` key. +_MEMORY_MODES = ("r", "w", "rw") # Keys extracted out of an extend-entry's body before construction: the # positioning keys (``before``/``after``) and the inline default overrides # (``agent``/``loop``/``approve``). Every other key passes through verbatim as -# the stage body (``depends_on``, ``skip``, ``manual`` and ``notes`` never -# reach the body — they are rejected outright). +# the stage body (``depends_on``, ``skip``, ``manual``, ``notes``, ``reflect`` +# and ``memory`` never reach the body — they are rejected outright). _EXTEND_BODY_EXCLUDED = ("before", "after", "agent", "loop", "approve") # Accepted values for the ``approve`` directive (per-stage AND inline extend), @@ -81,14 +125,19 @@ class WorkflowSyntaxError(ValueError): A structural error is an authored-time defect in the workflow-file: invalid YAML, a non-mapping root, an unknown top-level or per-stage key, a wrong-typed field (including a non-bool ``manual`` or a malformed - ``notes``), an extend-entry that - forbids ``depends_on`` / ``skip`` / ``manual`` / ``notes`` / mistypes + ``notes``), a malformed memory block or memory instruction (unknown key, + wrong type, value outside its domain, bad path shape, a ``mode`` under the + ``reflect`` method, or a method ↔ instruction mismatch), an extend-entry + that + forbids ``depends_on`` / ``skip`` / ``manual`` / ``notes`` / ``reflect`` / + ``memory`` / mistypes ``before`` / ``after`` / an inline ``agent`` / an inline ``loop`` / an inline ``approve`` / omits both ``before`` and ``after``, a ``loop`` below one, or a workflow that provides neither a top-level prompt, any stage - entry, nor any extend entry. Agent-name resolution, loop expansion, - extend-stage embedding, and ``depends_on`` rewriting are the compiler's + entry, any extend entry, nor the memory block. Agent-name resolution, loop + expansion, extend-stage embedding, and ``depends_on`` rewriting are the + compiler's responsibility — they never surface as structural errors here. """ @@ -97,27 +146,41 @@ def parse_workflow(workflow_path: Path) -> WorkflowDocument: """Structurally parse a workflow-file into a ``WorkflowDocument``. Read the file at ``workflow_path``, parse it as YAML, validate the expected - top-level keys (``prompt``, ``stages``, ``extend``) and the per-stage key + top-level keys (``prompt``, ``stages``, ``extend``, ``memory``) and the + per-stage key set (``agent``, ``prompt``, ``loop``, ``skills``, ``skip``, ``approve``, - ``manual``, ``notes``), + ``manual``, ``notes``, ``reflect``, ``memory``), type-check each present field (``manual`` strictly a bool; an absent key builds ``None``, NOT ``False``; ``notes`` a map of note name → prompt text - whose empty form builds ``None``), validate each extend-entry's positioning + whose empty form builds ``None``), validate the optional top-level + ``memory`` block (``method`` one of ``reflect``/``alignment``, ``path`` a + valid path shape, ``max_rules`` an ``int >= 1``, ``commit`` a bool, + ``mode`` one of ``r``/``w``/``rw`` and forbidden under ``reflect``), the + per-stage ``reflect`` instruction (``{file, mode?}``, ``file`` required and + a valid path shape) and the per-stage ``memory`` instruction (strictly a + bool; an explicit ``false`` equals absence), enforce the correspondence + between the materialized method and the per-stage instructions (the method + is ``reflect`` when no block is authored), validate each extend-entry's + positioning (``before``/``after`` as ``list[str]``, ``depends_on`` forbidden, ``skip`` - forbidden, ``manual`` forbidden, ``notes`` forbidden, at least one of + forbidden, ``manual`` forbidden, ``notes`` forbidden, ``reflect`` + forbidden, ``memory`` forbidden, at least one of ``before``/``after`` required) and any inline ``agent`` (str) / ``loop`` (int >= 1) / ``approve`` (one of ``auto``/ ``plan``/``dialog``), enforce - ``loop >= 1``, build one ``WorkflowStage`` per ``stages`` entry and one - ``WorkflowExtendStage`` per ``extend`` entry, and return the aggregated + ``loop >= 1``, build one ``WorkflowStage`` per ``stages`` entry, one + ``WorkflowExtendStage`` per ``extend`` entry, and one ``WorkflowMemory`` + from the ``memory`` block, and return the aggregated ``WorkflowDocument``. No content validation beyond the structural schema; no agent-name resolution, no loop expansion, no extend-stage embedding, no - ``depends_on`` rewriting, no stage removal. A ``trigger`` key in the + ``depends_on`` rewriting, no stage removal, and NO memory logic — no + memory-root composition, no block-emission decision, no participation + resolution. A ``trigger`` key in the ``stages`` block is an unknown-key structural error; a ``trigger`` key in an extend-entry body passes through verbatim (the compiler validates its - value). This cell does not act on ``manual`` or ``notes`` — they are - declarative. + value). This cell does not act on ``manual``, ``notes``, ``reflect``, + ``memory``, or the memory block — they are declarative. Args: workflow_path: Absolute path to the workflow-file. @@ -133,16 +196,24 @@ def parse_workflow(workflow_path: Path) -> WorkflowDocument: mapping, an unknown top-level or per-stage key is present, a field has the wrong type (including a non-bool ``skip``, a non-bool ``manual``, or a ``notes`` that is non-mapping or carries a - non-str value), an extend-entry + non-str value), the ``memory`` block or a memory instruction is + malformed (non-mapping, unknown key, non-str ``method``/``path``/ + ``mode``/``reflect.file``, a value outside its domain, a + ``max_rules`` that is not an ``int >= 1``, a non-bool ``commit`` or + ``memory``, an invalid path shape, a missing ``reflect.file``, a + ``mode`` authored under ``method: reflect``, a ``reflect`` + instruction under ``alignment``, or a ``memory`` instruction under + ``reflect``), an extend-entry is malformed (non-mapping value, ``depends_on`` present, ``skip`` - present, ``manual`` present, ``notes`` present, ``before``/``after`` + present, ``manual`` present, ``notes`` present, ``reflect`` + present, ``memory`` present, ``before``/``after`` not a ``list[str]``, an inline ``agent`` not a str or ``loop`` not an ``int >= 1`` or ``approve`` not one of ``auto``/``plan``/``dialog``, neither ``before`` nor ``after``), ``loop`` is below one, or the workflow provides neither a top-level prompt, any - stage entry, nor any extend entry. + stage entry, any extend entry, nor the memory block. """ text = workflow_path.read_text() @@ -156,45 +227,55 @@ def parse_workflow(workflow_path: Path) -> WorkflowDocument: if not isinstance(loaded, dict): raise WorkflowSyntaxError("workflow must be a mapping") - prompt, stages_raw, extend_raw = _extract_top_level(loaded) + prompt, stages_raw, extend_raw, memory_raw = _extract_top_level(loaded) + memory = _build_memory(memory_raw) stages = _build_stages(stages_raw) extend = _build_extend(extend_raw) + _validate_instruction_correspondence(stages, memory) - if prompt is None and not stages and not extend: - raise WorkflowSyntaxError("empty workflow — provide at least prompt, one stage, or one extend entry") + if prompt is None and not stages and not extend and memory is None: + raise WorkflowSyntaxError( + "empty workflow — provide at least prompt, one stage, one extend entry, or the memory block" + ) - return WorkflowDocument(prompt=prompt, stages=stages, extend=extend) + return WorkflowDocument(prompt=prompt, stages=stages, extend=extend, memory=memory) def _extract_top_level( loaded: dict[str, Any], -) -> tuple[str | None, dict[str, Any] | None, dict[str, Any] | None]: - """Validate the top-level mapping and split out ``prompt``/``stages``/``extend``. +) -> tuple[str | None, dict[str, Any] | None, dict[str, Any] | None, dict[str, Any] | None]: + """Validate the top-level mapping and split out ``prompt``/``stages``/``extend``/``memory``. Iterates the top-level keys once: ``prompt`` must be a str, ``stages`` must - be a mapping, ``extend`` must be a mapping, and any other key is unknown. + be a mapping, ``extend`` must be a mapping, ``memory`` must be a mapping, + and any other key is unknown. Returns the validated ``prompt`` text (or ``None``), the raw ``stages`` - mapping (or ``None``), and the raw ``extend`` mapping (or ``None``); the - per-stage entries are validated by ``_build_stages`` and the per-extend - entries by ``_build_extend``. + mapping (or ``None``), the raw ``extend`` mapping (or ``None``), and the raw + ``memory`` mapping (or ``None``); the per-stage entries are validated by + ``_build_stages``, the per-extend entries by ``_build_extend``, and the + memory block by ``_build_memory``. Args: loaded: The YAML-parsed top-level mapping. Returns: - A 3-tuple ``(prompt, stages_raw, extend_raw)`` where ``prompt`` is the + A 4-tuple ``(prompt, stages_raw, extend_raw, memory_raw)`` where + ``prompt`` is the validated top-level prompt (``None`` when absent), ``stages_raw`` is the - raw stages mapping (``None`` when absent), and ``extend_raw`` is the raw - extend mapping (``None`` when absent). + raw stages mapping (``None`` when absent), ``extend_raw`` is the raw + extend mapping (``None`` when absent), and ``memory_raw`` is the raw + memory block (``None`` when absent). Raises: WorkflowSyntaxError: If a top-level key is unknown, ``prompt`` is not a - str, ``stages`` is not a mapping, or ``extend`` is not a mapping. + str, ``stages`` is not a mapping, ``extend`` is not a mapping, or + ``memory`` is not a mapping. """ prompt: str | None = None stages_raw: dict[str, Any] | None = None extend_raw: dict[str, Any] | None = None + memory_raw: dict[str, Any] | None = None for key, value in loaded.items(): if key == "prompt": @@ -212,10 +293,15 @@ def _extract_top_level( raise WorkflowSyntaxError("non-mapping extend block in workflow") extend_raw = value + elif key == "memory": + if not isinstance(value, dict): + raise WorkflowSyntaxError("non-mapping memory block in workflow") + + memory_raw = value else: raise WorkflowSyntaxError(f"unknown key in workflow: {key}; valid keys: {', '.join(_TOP_LEVEL_KEYS)}") - return prompt, stages_raw, extend_raw + return prompt, stages_raw, extend_raw, memory_raw def _build_stages(stages_raw: dict[str, Any] | None) -> dict[str, WorkflowStage]: @@ -243,15 +329,19 @@ def _build_stage(name: Any, value: Any) -> WorkflowStage: The entry value must be a mapping. Its key set is validated against ``agent``, ``prompt``, ``loop``, ``skills``, ``skip``, ``approve``, - ``manual``, ``notes`` (unknown + ``manual``, ``notes``, ``reflect``, ``memory`` (unknown key → structural error); each present field is then type-checked, ``loop`` must be an ``int >= 1``, ``skills`` must be a ``list[str]``, ``skip`` must be - a ``bool``, ``approve`` must be one of ``auto``/``plan``/``dialog``, and - ``manual`` must be a ``bool``. Absent fields + a ``bool``, ``approve`` must be one of ``auto``/``plan``/``dialog``, + ``manual`` must be a ``bool``, ``reflect`` must be a ``{file, mode?}`` + mapping, and ``memory`` must be a ``bool``. Absent fields stay ``None`` on the built ``WorkflowStage`` (``skip`` stays ``False`` — its default — since absence is equivalent to ``False``; ``manual`` stays ``None`` — NOT ``False`` — since an absent key and an explicit - ``manual: false`` are DIFFERENT instructions the compiler must tell apart). + ``manual: false`` are DIFFERENT instructions the compiler must tell apart; + ``memory`` stays ``None`` too — an explicit ``memory: false`` is normalized + to ``None`` at parse time because absence and an explicit false are the + SAME state for this instruction). ``notes`` must be a ``dict`` of ``str``→``str``; an EMPTY map is normalized to ``None`` — the model carries either ``None`` or a non-empty map. @@ -268,7 +358,9 @@ def _build_stage(name: Any, value: Any) -> WorkflowStage: ``loop`` is not an ``int >= 1``, ``skills`` is not a ``list[str]``, ``skip`` is not a ``bool``, ``approve`` is not one of ``auto``/``plan``/``dialog``, ``manual`` is not a - ``bool``, or ``notes`` is not a ``dict`` of ``str``→``str``. + ``bool``, ``notes`` is not a ``dict`` of ``str``→``str``, + ``reflect`` is malformed (see ``_build_reflect``), or ``memory`` + is not a ``bool``. """ if not isinstance(value, dict): raise WorkflowSyntaxError(f"non-mapping stage {name} in workflow.stages") @@ -291,6 +383,8 @@ def _build_stage(name: Any, value: Any) -> WorkflowStage: approve=fields.get("approve"), manual=fields.get("manual"), notes=fields.get("notes"), + reflect=fields.get("reflect"), + memory=fields.get("memory"), ) @@ -300,15 +394,17 @@ def _validate_stage_field(name: Any, key: Any, field_value: Any) -> Any: Dispatches by ``key`` over the ``_STAGE_KEYS`` set, enforcing each field's type (``agent``/``prompt`` str, ``loop`` int >= 1, ``skills`` list[str], ``skip`` bool, ``approve`` one of ``auto``/``plan``/``dialog``, ``manual`` - bool, ``notes`` a str→str map). An unknown key raises - the unknown-key structural error with the full valid-set fragment + bool, ``notes`` a str→str map, ``reflect`` a ``{file, mode?}`` mapping + built by ``_build_reflect``, ``memory`` strictly a bool). An unknown key + raises the unknown-key structural error with the full valid-set fragment (``_STAGE_KEYS`` is the single source of that fragment — ``trigger`` is a full stage-body field, NOT a workflow key, so it lands here as an unknown key). Returns the validated value unchanged (only ``loop`` is normalized via ``_validate_loop``, which already returns an ``int``; ``notes`` is normalized via ``_validate_notes``, which returns ``None`` for an empty - map). + map; ``reflect`` is normalized via ``_build_reflect``, which materializes + the mode; ``memory`` is normalized to ``None`` when explicitly ``False``). Args: name: The stage-name map key (used in error messages). @@ -318,16 +414,19 @@ def _validate_stage_field(name: Any, key: Any, field_value: Any) -> Any: Returns: The validated field value (``agent``/``prompt`` str, ``loop`` int, ``skills`` list[str], ``skip`` bool, ``approve`` str equal to one of - ``auto``/``plan``/``dialog``, ``manual`` bool, or ``notes`` a non-empty - ``dict[str, str]`` — ``None`` when the map is empty). + ``auto``/``plan``/``dialog``, ``manual`` bool, ``notes`` a non-empty + ``dict[str, str]`` — ``None`` when the map is empty —, ``reflect`` a + ``WorkflowReflect``, or ``memory`` a bool — ``None`` when explicitly + ``False``). Raises: WorkflowSyntaxError: If ``key`` is an unknown per-stage key, or the field value has the wrong type (non-str agent/prompt, non-int/<1 loop, non-list[str] skills, non-bool skip, an ``approve`` that is not a str equal to ``auto``/``plan``/``dialog``, a non-bool - ``manual``, or ``notes`` that is non-mapping or carries a non-str - value). + ``manual``, ``notes`` that is non-mapping or carries a non-str + value, a malformed ``reflect`` instruction, or a non-bool + ``memory``). """ if key in ("agent", "prompt"): return _validate_str_field(f"workflow.stages.{name}", key, field_value) @@ -345,14 +444,55 @@ def _validate_stage_field(name: Any, key: Any, field_value: Any) -> Any: raise WorkflowSyntaxError(f"non-bool value in workflow.stages.{name}.{key}") return field_value - elif key == "approve": - return _validate_approve(f"workflow.stages.{name}", field_value) - elif key == "notes": - return _validate_notes(f"workflow.stages.{name}", field_value) + elif key in ("approve", "notes"): + # Two scoped single-value validators sharing the stage as their + # location; each validator owns its own message shape. + scope = f"workflow.stages.{name}" + validator = _validate_approve if key == "approve" else _validate_notes + return validator(scope, field_value) + elif key in ("reflect", "memory"): + return _validate_memory_instruction(name, key, field_value) else: raise WorkflowSyntaxError(f"unknown key in workflow.stages.{name}: {key}; valid keys: {', '.join(_STAGE_KEYS)}") +def _validate_memory_instruction(name: Any, key: Any, field_value: Any) -> WorkflowReflect | bool | None: + """Validate one per-stage memory-participation instruction and return it (normalized). + + Dispatches the two participation instructions of the ``stages`` block: + ``reflect`` delegates to ``_build_reflect`` (key set, required ``file``, + path shape, ``mode`` domain — with the mode materialized to ``"rw"``), + and ``memory`` is strictly a bool whose explicit ``False`` is normalized to + ``None`` — absence and an opting-out instruction are the SAME state, so the + compiler's ``is True`` check never distinguishes them. The instruction's + validity does NOT depend on the workflow's method here — that + correspondence is a separate pass + (``_validate_instruction_correspondence``). + + Args: + name: The stage-name map key (used in error messages). + key: The instruction key — ``"reflect"`` or ``"memory"``. + field_value: The raw instruction value. + + Returns: + The built ``WorkflowReflect`` for ``reflect``, or the bool / ``None`` + participation state for ``memory``. + + Raises: + WorkflowSyntaxError: If ``reflect`` is malformed (see + ``_build_reflect``) or ``memory`` is not a bool. + """ + if key == "reflect": + return _build_reflect(name, field_value) + + # ``memory`` — the participation instruction is strictly a bool, but an + # explicit ``False`` equals absence. + if not isinstance(field_value, bool): + raise WorkflowSyntaxError(f"non-bool value in workflow.stages.{name}.memory") + + return field_value if field_value else None + + def _build_extend(extend_raw: dict[str, Any] | None) -> dict[str, WorkflowExtendStage]: """Validate every ``extend`` entry and build the ``WorkflowExtendStage`` map. @@ -385,7 +525,10 @@ def _build_extend_stage(name: Any, value: Any) -> WorkflowExtendStage: verbatim and is validated by the compiler — never via a workflow instruction); ``notes`` is forbidden likewise (a declarative note-buttons instruction is stages-block only — the compiler consumes it per stage - name); + name); and the two memory-participation instructions ``reflect`` / + ``memory`` are forbidden likewise (participation of a new stage is + authored in the ``stages`` block by its name — the same channel the + compiler reads); ``before`` and ``after`` (when present) must each be a ``list[str]``; an inline ``agent`` (when present) must be a ``str``; an inline ``loop`` (when present) must be an ``int >= 1`` (``bool`` rejected first, symmetric with @@ -395,11 +538,12 @@ def _build_extend_stage(name: Any, value: Any) -> WorkflowExtendStage: at least one of ``before``/``after`` must be present. Every other key passes through verbatim as the stage body. ``before``, ``after``, ``agent``, ``loop`` and ``approve`` are removed from the body before construction - (``depends_on``, ``skip``, ``manual`` and ``notes`` never reach it: they are - rejected outright). + (``depends_on``, ``skip``, ``manual``, ``notes``, ``reflect`` and + ``memory`` never reach it: they are rejected outright). The structural checks run in the CODEMANIFEST order (step 6.2): non-mapping → ``depends_on`` → ``skip`` → ``manual`` → ``notes`` → + ``reflect`` → ``memory`` → ``before`` → ``after`` → ``agent`` → ``loop`` → ``approve`` → at-least-one-of-before/after. The @@ -418,7 +562,8 @@ def _build_extend_stage(name: Any, value: Any) -> WorkflowExtendStage: Raises: WorkflowSyntaxError: If the entry value is not a mapping, it contains a ``depends_on`` key, it contains a ``skip`` key, it contains a - ``manual`` key, it contains a ``notes`` key, ``before`` is not a + ``manual`` key, it contains a ``notes`` key, it contains a + ``reflect`` key, it contains a ``memory`` key, ``before`` is not a ``list[str]``, ``after`` is not a ``list[str]``, an inline ``agent`` is not a ``str``, an inline ``loop`` is not an ``int >= 1``, an inline ``approve`` is not a str equal to one of @@ -469,18 +614,21 @@ def _build_extend_stage(name: Any, value: Any) -> WorkflowExtendStage: def _reject_forbidden_extend_keys(name: Any, value: dict[str, Any]) -> None: - """Reject the keys an extend-entry must never carry (contract 6.2.2 - 6.2.5). + """Reject the keys an extend-entry must never carry (contract 6.2.2 - 6.2.7). - Four keys are forbidden outright, each with its own message, checked in the + Six keys are forbidden outright, each with its own message, checked in the CODEMANIFEST order before any positioning/type validation: ``depends_on`` (positioning is declared via ``before``/``after`` instead), ``skip`` (a new stage has no existing stage to delete — skip is defined only for existing pipeline stages via the ``stages`` block), ``manual`` (the launch mode of a new stage is authored in its body via ``trigger``, never via a - workflow instruction), and ``notes`` (a declarative note-buttons + workflow instruction), ``notes`` (a declarative note-buttons instruction is stages-block only — it mirrors ``manual``: the compiler consumes it per stage name, and an extend-stage receives it through the - ``stages`` block). All four never reach the extend body. + ``stages`` block), and the two memory-participation instructions + ``reflect`` / ``memory`` (participation of a new stage is authored in the + ``stages`` block by its name — the same channel the compiler reads). All + six never reach the extend body. Args: name: The stage-name map key (used in error messages). @@ -488,13 +636,298 @@ def _reject_forbidden_extend_keys(name: Any, value: dict[str, Any]) -> None: Raises: WorkflowSyntaxError: If the entry carries ``depends_on``, ``skip``, - ``manual``, or ``notes`` (checked in that order). + ``manual``, ``notes``, ``reflect``, or ``memory`` (checked in that + order). """ - for forbidden_key in ("depends_on", "skip", "manual", "notes"): + for forbidden_key in ("depends_on", "skip", "manual", "notes", "reflect", "memory"): if forbidden_key in value: raise WorkflowSyntaxError(f"{forbidden_key} is forbidden in workflow.extend.{name}") +def _build_memory(memory_raw: dict[str, Any] | None) -> WorkflowMemory | None: + """Validate the ``memory`` block and build the ``WorkflowMemory`` (contract 6.0). + + An absent block (``None``) yields ``None`` — the document carries no memory + configuration and the compiler's default-constructed ``WorkflowMemory()`` + supplies the authoring defaults downstream. A present mapping is validated + key by key (unknown key → structural error listing ``_MEMORY_KEYS``; + ``method`` / ``path`` / ``max_rules`` / ``commit`` / ``mode`` each with + their own type and domain checks), then the mode-forbidden-under-reflect + rule runs and the model is built with every default materialized: + ``method`` ``"reflect"``, ``max_rules`` ``25``, ``commit`` ``False``, + ``mode`` ``"rw"`` under alignment (the authored mode verbatim when one is + present) and ``None`` under reflect. + + No memory-root composition happens here — ``path`` carries the authored + suffix only; the consumer joins it with the fixed root. + + Args: + memory_raw: The raw ``memory`` mapping, or ``None`` when absent. + + Returns: + The validated ``WorkflowMemory``, or ``None`` when the block is absent. + + Raises: + WorkflowSyntaxError: If the block carries an unknown key, a value has + the wrong type (non-str ``method``/``path``/``mode``, non-int + ``max_rules`` — ``bool`` counts as non-int —, or a non-bool + ``commit``), a value falls outside its domain (``method`` not one + of ``reflect``/``alignment``, ``mode`` not one of ``r``/``w``/ + ``rw``, ``max_rules`` below one), ``path`` has an invalid shape, or + a ``mode`` is authored together with ``method: reflect``. + """ + if memory_raw is None: + return None + + values: dict[str, Any] = {} + + for key, field_value in memory_raw.items(): + if key == "method": + values["method"] = _validate_memory_method("workflow.memory", field_value) + elif key == "path": + values["path"] = _validate_path_shape("workflow.memory.path", field_value) + elif key == "max_rules": + values["max_rules"] = _validate_max_rules(field_value) + elif key == "commit": + if not isinstance(field_value, bool): + raise WorkflowSyntaxError("non-bool value in workflow.memory.commit") + + values["commit"] = field_value + elif key == "mode": + values["mode"] = _validate_memory_mode("workflow.memory.mode", "workflow.memory", field_value) + else: + raise WorkflowSyntaxError(f"unknown key in workflow.memory: {key}; valid keys: {', '.join(_MEMORY_KEYS)}") + + method = values.get("method", "reflect") + + # The mode exists only for the alignment method — an authored mode under + # the default reflect method is a structural error, not a silent ignore. + if "mode" in values and method == "reflect": + raise WorkflowSyntaxError("mode is forbidden in workflow.memory with method: reflect") + + return WorkflowMemory( + method=method, + path=values.get("path"), + max_rules=values.get("max_rules", 25), + commit=values.get("commit", False), + mode=(values.get("mode", "rw") if method == "alignment" else None), + ) + + +def _build_reflect(name: Any, value: Any) -> WorkflowReflect: + """Validate one ``reflect`` instruction and build the ``WorkflowReflect`` (contract 6.1.11). + + The instruction value must be a mapping whose key set is within + ``{file, mode}`` — a non-mapping value raises the non-mapping error, an + unknown key raises the unknown-key error listing ``_REFLECT_KEYS``. + ``file`` is required and must be a str of a valid path shape; ``mode`` + (when present) must be a str in ``_MEMORY_MODES``. The built model carries + the authored ``file`` verbatim and the mode materialized to ``"rw"`` when + the entry omits it. + + The instruction's validity does NOT depend on the workflow's method here — + the method ↔ instruction correspondence is a separate pass + (``_validate_instruction_correspondence``) that runs after every stage is + built, so a reflect instruction under ``alignment`` is reported by that + pass, not this one. + + Args: + name: The stage-name map key (used in error messages). + value: The raw ``reflect`` entry value. + + Returns: + The validated ``WorkflowReflect`` with the mode materialized. + + Raises: + WorkflowSyntaxError: If the value is not a mapping, carries an unknown + key, omits ``file``, carries a ``file`` that is not a str or not a + valid path shape, or carries a ``mode`` that is not a str in + ``r``/``w``/``rw``. + """ + if not isinstance(value, dict): + raise WorkflowSyntaxError(f"non-mapping reflect in workflow.stages.{name}") + + file_value: str | None = None + mode_value: str | None = None + + for key, field_value in value.items(): + if key == "file": + file_value = _validate_path_shape(f"workflow.stages.{name}.reflect.file", field_value) + elif key == "mode": + mode_value = _validate_memory_mode( + f"workflow.stages.{name}.reflect.mode", + f"workflow.stages.{name}", + field_value, + ) + else: + raise WorkflowSyntaxError( + f"unknown key in workflow.stages.{name}.reflect: {key}; valid keys: {', '.join(_REFLECT_KEYS)}" + ) + + if file_value is None: + raise WorkflowSyntaxError(f"file is required in workflow.stages.{name}.reflect") + + return WorkflowReflect(file=file_value, mode=mode_value or "rw") + + +def _validate_instruction_correspondence( + stages: dict[str, WorkflowStage], + memory: WorkflowMemory | None, +) -> None: + """Reject a per-stage instruction the workflow's method does not allow (contract 6.3). + + The method is ``"reflect"`` when no block is authored — the default + vocabulary is the reflect instruction, so a ``memory: true`` instruction + without an explicit ``method: alignment`` block is a structural error. + Under ``alignment`` a stage carries ``memory: true``; under ``reflect`` it + carries a ``reflect`` instruction. Every stage is checked; the first + mismatch raises with the stage name interpolated verbatim. + + Args: + stages: The built per-stage map (name → ``WorkflowStage``). + memory: The built memory configuration, or ``None`` when the + workflow-file carries no block. + + Raises: + WorkflowSyntaxError: If a stage carries a ``reflect`` instruction under + the alignment method, or a ``memory`` instruction under the reflect + method. + """ + method = memory.method if memory is not None else "reflect" + + for name, stage in stages.items(): + if method == "alignment" and stage.reflect is not None: + raise WorkflowSyntaxError(f"reflect is forbidden in workflow.stages.{name} with method: alignment") + + if method == "reflect" and stage.memory is True: + raise WorkflowSyntaxError(f"memory is forbidden in workflow.stages.{name} with method: reflect") + + +def _validate_memory_method(scope: str, field_value: Any) -> str: + """Validate the ``memory`` block's ``method`` value and return it. + + A non-``str`` value is rejected first (``bool`` is not a ``str``), then any + ``str`` other than ``"reflect"`` / ``"alignment"`` (see + ``_MEMORY_METHODS``) is rejected. ``scope`` is the dotted location up to + but excluding ``method`` (``"workflow.memory"``), used verbatim in both + messages. Mirrors the non-str-then-enum shape of ``_validate_approve``. + + Args: + scope: The dotted location (without the trailing ``.method``). + field_value: The raw ``method`` value to validate. + + Returns: + The validated method (one of ``_MEMORY_METHODS``). + + Raises: + WorkflowSyntaxError: If ``field_value`` is not a ``str``, or is a + ``str`` other than ``"reflect"``/``"alignment"``. + """ + if not isinstance(field_value, str): + raise WorkflowSyntaxError(f"non-str value in {scope}.method") + + if field_value not in _MEMORY_METHODS: + raise WorkflowSyntaxError(f"method must be one of: {', '.join(_MEMORY_METHODS)} in {scope}") + + return field_value + + +def _validate_memory_mode(value_location: str, domain_location: str, field_value: Any) -> str: + """Validate a memory ``mode`` value and return it. + + Shared by the ``memory`` block's ``mode`` key and a ``reflect`` + instruction's ``mode`` key. The two messages deliberately carry different + locations (a CODEMANIFEST asymmetry): the type message names the exact key + (``value_location``, e.g. ``"workflow.memory.mode"`` or + ``"workflow.stages.NAME.reflect.mode"``), while the domain message names + the enclosing container (``domain_location`` — for a reflect instruction + that is the STAGE, ``"workflow.stages.NAME"``, not its ``reflect`` + sub-scope). + + Args: + value_location: The dotted location used in the non-str message. + domain_location: The dotted location used in the domain message. + field_value: The raw ``mode`` value to validate. + + Returns: + The validated mode (one of ``_MEMORY_MODES``). + + Raises: + WorkflowSyntaxError: If ``field_value`` is not a ``str``, or is a + ``str`` other than ``"r"``/``"w"``/``"rw"``. + """ + if not isinstance(field_value, str): + raise WorkflowSyntaxError(f"non-str value in {value_location}") + + if field_value not in _MEMORY_MODES: + raise WorkflowSyntaxError(f"mode must be one of: {', '.join(_MEMORY_MODES)} in {domain_location}") + + return field_value + + +def _validate_max_rules(field_value: Any) -> int: + """Validate the ``memory`` block's ``max_rules`` value and return the confirmed ``int``. + + ``bool`` is rejected first — it is a subclass of ``int`` in Python, so + ``max_rules: true`` must be reported as a non-int, not silently accepted — + then non-int types, then the ``>= 1`` bound. Mirrors ``_validate_loop``, + the established bool-first int check of this module. + + Args: + field_value: The raw ``max_rules`` value to validate. + + Returns: + The validated rule cap (an ``int >= 1``). + + Raises: + WorkflowSyntaxError: If ``field_value`` is not an ``int`` (``bool`` + counts as not-an-int), or is an ``int`` below one. + """ + if isinstance(field_value, bool) or not isinstance(field_value, int): + raise WorkflowSyntaxError("non-int value in workflow.memory.max_rules") + + if field_value < 1: + raise WorkflowSyntaxError("max_rules must be >= 1 in workflow.memory") + + return field_value + + +def _validate_path_shape(scope: str, field_value: Any) -> str: + """Validate a memory path value's shape and return the confirmed ``str``. + + Shared by the ``memory`` block's ``path`` key and a ``reflect`` + instruction's ``file`` key: both name a location INSIDE the fixed memory + root, so an empty string, an absolute path (a leading ``/``), or any ``..`` + segment is a structural error — the authored value must be a relative, + non-escaping suffix. ``scope`` is the full dotted location of the key (e.g. + ``"workflow.memory.path"``), interpolated verbatim into both messages; the + invalid-shape message additionally repeats the offending value. + + The value is NOT resolved against any root here — the consumer composes + the final path (this cell performs no memory logic). + + Args: + scope: The dotted location of the key being validated. + field_value: The raw path value to validate. + + Returns: + The validated path suffix, carried verbatim. + + Raises: + WorkflowSyntaxError: If ``field_value`` is not a ``str``, is empty, is + absolute, or contains a ``..`` segment. + """ + if not isinstance(field_value, str): + raise WorkflowSyntaxError(f"non-str value in {scope}") + + path = PurePosixPath(field_value) + + if field_value == "" or path.is_absolute() or ".." in path.parts: + raise WorkflowSyntaxError(f"invalid path in {scope}: {field_value}") + + return field_value + + def _is_list_of_str(value: Any) -> bool: """Return whether ``value`` is a ``list`` whose every element is a ``str``. diff --git a/tests/pipeline/workflow/test_parse_workflow_contract.py b/tests/pipeline/workflow/test_parse_workflow_contract.py index b422f746..b82dcf76 100644 --- a/tests/pipeline/workflow/test_parse_workflow_contract.py +++ b/tests/pipeline/workflow/test_parse_workflow_contract.py @@ -145,13 +145,15 @@ def test_parse_workflow_stage_keys_includes_approve(self) -> None: Pins the contract: ``_STAGE_KEYS`` is the single source of the accepted per-stage key set and of the unknown-key ``valid keys`` message fragment, so it must carry ``approve`` (after ``skip``), ``manual`` (after - ``approve``), and ``notes`` (after ``manual``). + ``approve``), ``notes`` (after ``manual``), ``reflect`` (after + ``notes``), and ``memory`` (after ``reflect``) — the two + memory-participation instructions close the canonical order. """ from goga.pipeline.workflow.parse_workflow import _STAGE_KEYS assert "approve" in _STAGE_KEYS # Fixed canonical order: agent, prompt, loop, skills, skip, approve, - # manual, notes. + # manual, notes, reflect, memory. assert _STAGE_KEYS == ( "agent", "prompt", @@ -161,6 +163,8 @@ def test_parse_workflow_stage_keys_includes_approve(self) -> None: "approve", "manual", "notes", + "reflect", + "memory", ) def test_parse_workflow_manual_is_accepted_stage_key(self, tmp_path: Path) -> None: diff --git a/tests/pipeline/workflow/test_parse_workflow_memory.py b/tests/pipeline/workflow/test_parse_workflow_memory.py new file mode 100644 index 00000000..c6945071 --- /dev/null +++ b/tests/pipeline/workflow/test_parse_workflow_memory.py @@ -0,0 +1,371 @@ +"""Contract and logic tests for the memory authoring surface of ``parse_workflow``. + +Covers the memory vocabulary the workflow-cell CODEMANIFEST declares: the +optional top-level ``memory`` block (key set, value domains, path shapes, +materialized defaults), the per-stage ``reflect`` / ``memory`` instructions, +the prohibition of both keys in an extend-entry, the correspondence between +the materialized method and the per-stage instructions, and the new +empty-workflow rule that counts the block. The contract class pins the API +shape; the logic classes exercise every documented message verbatim. +""" + +from __future__ import annotations + +import inspect +import re +from pathlib import Path + +import pytest +from goga.pipeline.workflow import ( + WorkflowDocument, + WorkflowMemory, + WorkflowReflect, + WorkflowStage, + WorkflowSyntaxError, + parse_workflow, +) + + +def _write(tmp_path: Path, name: str, text: str) -> Path: + """Write ``text`` to ``tmp_path / name`` and return the path.""" + workflow_path = tmp_path / name + + workflow_path.write_text(text) + + return workflow_path + + +def _parse(tmp_path: Path, text: str) -> WorkflowDocument: + """Parse ``text`` as a workflow-file and return the built document.""" + return parse_workflow(_write(tmp_path, "workflow.yml", text)) + + +class TestParseWorkflowMemoryContract: + """Contract tests — the memory surface of the ``parse_workflow`` routine.""" + + def test_parse_workflow_signature_unchanged(self) -> None: + """``parse_workflow`` still takes exactly one parameter (``workflow_path``).""" + parameters = list(inspect.signature(parse_workflow).parameters) + + assert parameters == ["workflow_path"] + + def test_parse_workflow_document_memory_is_optional_workflow_memory(self, tmp_path: Path) -> None: + """The parsed document carries ``memory`` as a ``WorkflowMemory | None``.""" + with_block = _parse(tmp_path, "memory:\n max_rules: 40\n") + without_block = _parse(tmp_path, "prompt: guidance\n") + + assert isinstance(with_block.memory, WorkflowMemory) + assert without_block.memory is None + + def test_parse_workflow_stage_reflect_is_optional_workflow_reflect(self, tmp_path: Path) -> None: + """A parsed stage carries ``reflect`` as a ``WorkflowReflect | None``.""" + document = _parse(tmp_path, "stages:\n brainstorm:\n reflect:\n file: shared.md\n") + + assert isinstance(document.stages["brainstorm"].reflect, WorkflowReflect) + assert WorkflowStage().reflect is None + + def test_parse_workflow_stage_memory_is_optional_bool(self, tmp_path: Path) -> None: + """A parsed stage carries ``memory`` as a ``bool | None`` (never ``False``).""" + document = _parse( + tmp_path, + "memory:\n method: alignment\nstages:\n brainstorm:\n memory: true\n review:\n agent: codex\n", + ) + + assert document.stages["brainstorm"].memory is True + assert isinstance(document.stages["brainstorm"].memory, bool) + assert document.stages["review"].memory is None + + +class TestParseWorkflowMemoryPositive: + """Positive logic tests — valid memory authoring parses to the expected models.""" + + def test_parse_workflow_memory_block_only_is_valid_not_empty(self, tmp_path: Path) -> None: + """A workflow of the memory block alone is valid — the block counts as content.""" + document = _parse(tmp_path, "memory:\n max_rules: 40\n") + + assert document.prompt is None + assert document.stages == {} + assert document.extend == {} + assert isinstance(document.memory, WorkflowMemory) + assert document.memory.max_rules == 40 + assert document.memory.method == "reflect" + assert document.memory.commit is False + assert document.memory.mode is None + + def test_parse_workflow_empty_memory_block_materializes_defaults(self, tmp_path: Path) -> None: + """An empty ``memory: {}`` block builds the model with every default materialized.""" + document = _parse(tmp_path, "memory: {}\n") + + assert isinstance(document.memory, WorkflowMemory) + assert document.memory == WorkflowMemory() + assert document.memory.method == "reflect" + assert document.memory.path is None + assert document.memory.max_rules == 25 + assert document.memory.commit is False + assert document.memory.mode is None + + def test_parse_workflow_memory_block_absent_leaves_memory_none(self, tmp_path: Path) -> None: + """A workflow-file without a ``memory`` block yields ``document.memory is None``.""" + document = _parse(tmp_path, "prompt: guidance\nstages:\n build:\n agent: codex\n") + + assert document.memory is None + + def test_parse_workflow_alignment_block_materializes_mode_rw(self, tmp_path: Path) -> None: + """The alignment method materializes ``mode`` to ``rw`` when the block omits it.""" + document = _parse( + tmp_path, + "memory:\n method: alignment\n path: goga-development\nstages:\n brainstorm:\n memory: true\n", + ) + + assert document.memory is not None + assert document.memory.method == "alignment" + assert document.memory.mode == "rw" + assert document.memory.path == "goga-development" + assert document.stages["brainstorm"].memory is True + + def test_parse_workflow_stage_reflect_builds_workflow_reflect(self, tmp_path: Path) -> None: + """Per-stage reflect instructions build WorkflowReflect with the mode materialized.""" + document = _parse( + tmp_path, + "memory:\n" + " max_rules: 40\n" + "stages:\n" + " brainstorm:\n" + " reflect:\n" + " file: shared.md\n" + " mode: r\n" + " review:\n" + " reflect:\n" + " file: shared.md\n", + ) + + assert document.stages["brainstorm"].reflect == WorkflowReflect(file="shared.md", mode="r") + assert document.stages["review"].reflect == WorkflowReflect(file="shared.md", mode="rw") + + def test_parse_workflow_stage_reflect_without_memory_block_is_valid(self, tmp_path: Path) -> None: + """A reflect instruction with no authored block is valid — the default method is reflect.""" + document = _parse(tmp_path, "stages:\n brainstorm:\n reflect:\n file: shared.md\n") + + assert document.memory is None + assert document.stages["brainstorm"].reflect == WorkflowReflect(file="shared.md", mode="rw") + + def test_parse_workflow_stage_memory_false_equals_absence(self, tmp_path: Path) -> None: + """An explicit ``memory: false`` is normalized to ``None`` — absence, not False.""" + document = _parse( + tmp_path, + "memory:\n method: alignment\nstages:\n build:\n memory: false\n", + ) + + assert document.stages["build"].memory is None + + +class TestParseWorkflowMemoryBlockRejections: + """Negative logic tests — every structural defect of the ``memory`` block.""" + + @pytest.mark.parametrize( + ("block_yaml", "message"), + [ + ("memory: [1]", "non-mapping memory block in workflow"), + ("memory: text", "non-mapping memory block in workflow"), + ( + "memory: {bad: 1}", + "unknown key in workflow.memory: bad; valid keys: method, path, max_rules, commit, mode", + ), + ("memory: {method: 3}", "non-str value in workflow.memory.method"), + ("memory: {method: sync}", "method must be one of: reflect, alignment in workflow.memory"), + ("memory: {path: 4}", "non-str value in workflow.memory.path"), + ("memory: {path: ''}", "invalid path in workflow.memory.path: "), + ("memory: {path: /abs}", "invalid path in workflow.memory.path: /abs"), + ("memory: {path: ../x}", "invalid path in workflow.memory.path: ../x"), + ("memory: {max_rules: '9'}", "non-int value in workflow.memory.max_rules"), + ("memory: {max_rules: 0}", "max_rules must be >= 1 in workflow.memory"), + ("memory: {max_rules: -3}", "max_rules must be >= 1 in workflow.memory"), + ("memory: {max_rules: true}", "non-int value in workflow.memory.max_rules"), + ("memory: {commit: 'yes'}", "non-bool value in workflow.memory.commit"), + ("memory: {commit: 1}", "non-bool value in workflow.memory.commit"), + ("memory: {mode: 1}", "non-str value in workflow.memory.mode"), + ("memory: {mode: x}", "mode must be one of: r, w, rw in workflow.memory"), + ("memory: {method: reflect, mode: rw}", "mode is forbidden in workflow.memory with method: reflect"), + ], + ids=[ + "list-block", + "scalar-block", + "unknown-key", + "non-str-method", + "method-outside-domain", + "non-str-path", + "empty-path", + "absolute-path", + "parent-segment-path", + "non-int-max-rules", + "zero-max-rules", + "negative-max-rules", + "bool-max-rules", + "non-bool-commit", + "int-commit", + "non-str-mode", + "mode-outside-domain", + "mode-under-reflect", + ], + ) + def test_parse_workflow_rejects_memory_block_shape_errors( + self, + tmp_path: Path, + block_yaml: str, + message: str, + ) -> None: + """A structurally malformed memory block raises WorkflowSyntaxError with the documented message.""" + with pytest.raises(WorkflowSyntaxError, match=re.escape(message)): + _parse(tmp_path, f"{block_yaml}\n") + + def test_parse_workflow_unknown_top_level_message_lists_memory(self, tmp_path: Path) -> None: + """The unknown top-level key message now lists ``memory`` in the valid-keys fragment.""" + with pytest.raises(WorkflowSyntaxError) as exc_info: + _parse(tmp_path, "bogus: 1\n") + + message = str(exc_info.value) + assert "unknown key in workflow: bogus" in message + assert "valid keys: prompt, stages, extend, memory" in message + + def test_parse_workflow_unknown_stage_key_message_lists_reflect_and_memory( + self, + tmp_path: Path, + ) -> None: + """The unknown per-stage key message now lists ``reflect, memory`` in the valid-keys fragment.""" + with pytest.raises(WorkflowSyntaxError) as exc_info: + _parse(tmp_path, "stages:\n build:\n bogus: 1\n") + + message = str(exc_info.value) + assert "unknown key in workflow.stages.build: bogus" in message + assert "valid keys: agent, prompt, loop, skills, skip, approve, manual, notes, reflect, memory" in message + + +class TestParseWorkflowReflectRejections: + """Negative logic tests — every structural defect of a per-stage ``reflect`` instruction.""" + + @pytest.mark.parametrize( + ("stage_yaml", "block_yaml", "message"), + [ + (" reflect: [1]", "", "non-mapping reflect in workflow.stages.brainstorm"), + ( + " reflect:\n file: a.md\n bad: 1", + "", + "unknown key in workflow.stages.brainstorm.reflect: bad; valid keys: file, mode", + ), + (" reflect: {}", "", "file is required in workflow.stages.brainstorm.reflect"), + (" reflect:\n mode: r", "", "file is required in workflow.stages.brainstorm.reflect"), + ( + " reflect:\n file: 3", + "", + "non-str value in workflow.stages.brainstorm.reflect.file", + ), + ( + " reflect:\n file: ../x", + "", + "invalid path in workflow.stages.brainstorm.reflect.file: ../x", + ), + ( + " reflect:\n file: a.md\n mode: 3", + "", + "non-str value in workflow.stages.brainstorm.reflect.mode", + ), + ( + " reflect:\n file: a.md\n mode: x", + "", + "mode must be one of: r, w, rw in workflow.stages.brainstorm", + ), + (" memory: 1", "memory:\n method: alignment\n", "non-bool value in workflow.stages.brainstorm.memory"), + ], + ids=[ + "list-reflect", + "unknown-key", + "empty-map", + "missing-file", + "non-str-file", + "bad-path-shape", + "non-str-mode", + "mode-outside-domain", + "non-bool-memory", + ], + ) + def test_parse_workflow_rejects_reflect_instruction_errors( + self, + tmp_path: Path, + stage_yaml: str, + block_yaml: str, + message: str, + ) -> None: + """A malformed per-stage instruction raises WorkflowSyntaxError with the documented message.""" + text = f"stages:\n brainstorm:\n{stage_yaml}\n" + if block_yaml: + text = f"{block_yaml}{text}" + + with pytest.raises(WorkflowSyntaxError, match=re.escape(message)): + _parse(tmp_path, text) + + +class TestParseWorkflowMethodInstructionMismatch: + """Negative logic tests — the method ↔ instruction correspondence and the extend prohibition.""" + + @pytest.mark.parametrize( + ("text", "message"), + [ + ( + "memory:\n method: alignment\nstages:\n x:\n reflect:\n file: a.md\n", + "reflect is forbidden in workflow.stages.x with method: alignment", + ), + ( + "memory: {}\nstages:\n x:\n memory: true\n", + "memory is forbidden in workflow.stages.x with method: reflect", + ), + ( + "stages:\n x:\n memory: true\n", + "memory is forbidden in workflow.stages.x with method: reflect", + ), + ( + "extend:\n extra:\n after: [build]\n title: Extra\n reflect:\n file: a.md\n", + "reflect is forbidden in workflow.extend.extra", + ), + ( + "extend:\n extra:\n after: [build]\n title: Extra\n memory: true\n", + "memory is forbidden in workflow.extend.extra", + ), + ], + ids=[ + "reflect-under-alignment", + "memory-under-reflect-with-block", + "memory-under-reflect-without-block", + "reflect-in-extend-entry", + "memory-in-extend-entry", + ], + ) + def test_parse_workflow_rejects_method_instruction_mismatch( + self, + tmp_path: Path, + text: str, + message: str, + ) -> None: + """A method/instruction mismatch or an extend-entry instruction raises the documented message.""" + with pytest.raises(WorkflowSyntaxError, match=re.escape(message)): + _parse(tmp_path, text) + + def test_parse_workflow_alignment_accepts_authored_mode_and_path(self, tmp_path: Path) -> None: + """An alignment block with an authored mode carries it verbatim alongside the suffix.""" + document = _parse( + tmp_path, + "memory:\n method: alignment\n path: p\n mode: r\nstages:\n build:\n memory: true\n", + ) + + assert document.memory is not None + assert document.memory.mode == "r" + assert document.memory.path == "p" + assert document.stages["build"].memory is True + + def test_parse_workflow_empty_workflow_message_mentions_memory_block(self, tmp_path: Path) -> None: + """The empty-workflow message now offers the memory block as a fourth alternative.""" + with pytest.raises(WorkflowSyntaxError) as exc_info: + _parse(tmp_path, "stages: {}\n") + + assert str(exc_info.value) == ( + "empty workflow — provide at least prompt, one stage, one extend entry, or the memory block" + ) From bf0388f1523318e7956db9b23cbc1b490dca94b4 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 21:36:28 +0000 Subject: [PATCH 150/229] feat: add the FlowMemory model, FlowDocument.memory slot, and compiler facade export --- goga/pipeline/compiler/__init__.py | 4 +- goga/pipeline/compiler/flow_document.py | 29 ++++--- goga/pipeline/compiler/flow_memory.py | 57 ++++++++++++ .../compiler/test_flow_document_contract.py | 60 ++++++++++++- .../compiler/test_flow_memory_contract.py | 87 +++++++++++++++++++ .../compiler/test_flow_memory_logic.py | 68 +++++++++++++++ 6 files changed, 292 insertions(+), 13 deletions(-) create mode 100644 goga/pipeline/compiler/flow_memory.py create mode 100644 tests/pipeline/compiler/test_flow_memory_contract.py create mode 100644 tests/pipeline/compiler/test_flow_memory_logic.py diff --git a/goga/pipeline/compiler/__init__.py b/goga/pipeline/compiler/__init__.py index 9df36f6d..4977eee9 100644 --- a/goga/pipeline/compiler/__init__.py +++ b/goga/pipeline/compiler/__init__.py @@ -1,12 +1,13 @@ """Compiler cell — pure transformer from goga DSL pipeline-files to afm flow-files. Built incrementally: each entity task adds its module's import and ``__all__`` -entry. Once all entity tasks land, all 15 contract names are re-exported here. +entry. Once all entity tasks land, all 16 contract names are re-exported here. """ from .body_format import BodyFormat from .compile_flow import compile_flow, translate_role from .flow_document import FlowDocument +from .flow_memory import FlowMemory from .flow_stage import FlowStage from .parse_dsl import StructuralError, parse_dsl from .phase_step import PhaseStep @@ -21,6 +22,7 @@ __all__: list[str] = [ "BodyFormat", "FlowDocument", + "FlowMemory", "FlowStage", "PhaseStep", "PhasesBody", diff --git a/goga/pipeline/compiler/flow_document.py b/goga/pipeline/compiler/flow_document.py index 6bd27645..42d9ce15 100644 --- a/goga/pipeline/compiler/flow_document.py +++ b/goga/pipeline/compiler/flow_document.py @@ -1,26 +1,30 @@ """The ``FlowDocument`` dataclass — output afm flow-file model. -An afm flow-file is a single flat YAML document with up to five top-level keys -(prompt (when present), root_dir (when present), name, description, stages) — no -segmentation, no header sub-object. ``FlowDocument`` mirrors that flatness: it -carries the optional top-level ``prompt`` (populated from a workflow's prompt -when one is supplied, ``None`` otherwise), the optional top-level ``root_dir`` -(populated by the caller from the in-container project root — typically -``Path.cwd()`` inside the goga container, ``None`` otherwise), the carried 1:1 -``name`` and ``description`` from ``PipelineHeader``, and the ordered list of -``FlowStage`` items. It is the only object ``serialize_flow`` accepts as input. +An afm flow-file is a single flat YAML document with up to six top-level keys +(prompt (when present), root_dir (when supplied), name, description, memory +(when memory participates), stages) — no segmentation, no header sub-object. +``FlowDocument`` mirrors that flatness: it carries the optional top-level +``prompt`` (populated from a workflow's prompt when one is supplied, ``None`` +otherwise), the optional top-level ``root_dir`` (populated by the caller from +the in-container project root — typically ``Path.cwd()`` inside the goga +container, ``None`` otherwise), the carried 1:1 ``name`` and ``description`` +from ``PipelineHeader``, the optional compiled memory block (``FlowMemory``, +or ``None`` when memory does not participate), and the ordered list of +``FlowStage`` items. It is the only object ``serialize_flow`` accepts as +input. """ from __future__ import annotations from dataclasses import dataclass +from .flow_memory import FlowMemory from .flow_stage import FlowStage @dataclass(kw_only=True) class FlowDocument: - """Output afm flow-file — a flat document with up to five top-level keys. + """Output afm flow-file — a flat document with up to six top-level keys. Args: prompt: Top-level flow prompt, or ``None`` when no workflow supplied @@ -37,6 +41,10 @@ class FlowDocument: name: Top-level flow name (carried 1:1 from PipelineHeader name). description: Top-level flow description (carried 1:1 from PipelineHeader description). + memory: The compiled memory block, or ``None`` when memory does not + participate. Emitted between ``description`` and ``stages`` when + not ``None``; omitted entirely when ``None`` — byte-identical + output for memory-free workflows. stages: Ordered list of flow stages, output as the stages list. """ @@ -44,4 +52,5 @@ class FlowDocument: root_dir: str | None = None name: str description: str + memory: FlowMemory | None = None stages: list[FlowStage] diff --git a/goga/pipeline/compiler/flow_memory.py b/goga/pipeline/compiler/flow_memory.py new file mode 100644 index 00000000..867b67d6 --- /dev/null +++ b/goga/pipeline/compiler/flow_memory.py @@ -0,0 +1,57 @@ +"""The ``FlowMemory`` dataclass — the emitted top-level memory block of a flow-file. + +A flow-file may carry a top-level ``memory`` block between ``description`` and +``stages``. ``FlowMemory`` is the compiled form of the workflow-memory +configuration: it is built by ``compile_flow`` when memory participates and +consumed by ``serialize_flow``. The model is intentionally declarative — it +holds the emitted values, never their resolution. + +The field order IS the emission order of the block keys — ``path``, ``mode``, +``memory_use``, ``max_rules``, ``commit``. ``path`` is the composed memory +root: the fixed root joined with the authored suffix (the bare root when no +suffix was authored) — the caller composes it, this model never does. +``mode`` is the project-memory access mode; it is present only for the +alignment method (``None`` for the reflect method). ``memory_use`` is the +global participation default; ``True`` only for the alignment method +(``None`` for the reflect method). ``max_rules`` is the maximum number of +memory rules (always ``>= 1``); ``commit`` is whether memory changes are +committed. A ``None`` field is omitted from the output entirely — the +serializer drops it, it never emits an empty value. + +Only ``mode`` / ``memory_use`` default (to ``None``); ``path`` / ``max_rules`` +/ ``commit`` carry NO defaults — a block is always complete, and +``compile_flow`` is its single construction site. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(kw_only=True) +class FlowMemory: + """The emitted top-level memory block of a flow-file. + + Field order is fixed (``path``, ``mode``, ``memory_use``, ``max_rules``, + ``commit``) — the emission order of the block keys. reflect method — + ``mode`` ``None``, ``memory_use`` ``None``; alignment method — ``mode`` + the materialized value, ``memory_use`` ``True``. A ``None`` field is + omitted from the output entirely. + + Args: + path: The composed memory root — the fixed root joined with the + authored suffix. Composed by ``compile_flow``; carried verbatim + here. + mode: The project-memory access mode; present only for the alignment + method. + memory_use: The global participation default; ``True`` only for the + alignment method. + max_rules: The maximum number of memory rules; always ``>= 1``. + commit: Whether memory changes are committed. + """ + + path: str + mode: str | None = None + memory_use: bool | None = None + max_rules: int + commit: bool diff --git a/tests/pipeline/compiler/test_flow_document_contract.py b/tests/pipeline/compiler/test_flow_document_contract.py index dd48f3af..d14cd029 100644 --- a/tests/pipeline/compiler/test_flow_document_contract.py +++ b/tests/pipeline/compiler/test_flow_document_contract.py @@ -1,4 +1,4 @@ -"""Contract tests for the ``FlowDocument.prompt`` first-slot extension. +"""Contract tests for the ``FlowDocument`` slot extensions. Covers the Task 5 data-model extension: ``FlowDocument`` gains an optional ``prompt: str | None`` field as its FIRST slot (default ``None``), emitted as @@ -6,13 +6,19 @@ entirely when ``None``. Because the dataclass is ``kw_only=True``, the new first-slot field with a default does not break existing keyword construction sites. + +Also covers the memory-slot extension: ``FlowDocument`` gains an optional +``memory: FlowMemory | None`` field between ``description`` and ``stages`` +(default ``None``), emitted between the two by ``serialize_flow`` when not +``None`` and omitted entirely when ``None`` — the byte-identity default for +memory-free workflows. """ from __future__ import annotations from dataclasses import fields as dataclass_fields -from goga.pipeline.compiler import FlowDocument, FlowStage +from goga.pipeline.compiler import FlowDocument, FlowMemory, FlowStage class TestFlowDocumentPromptContract: @@ -61,3 +67,53 @@ def test_existing_construction_sites_without_prompt_still_work(self) -> None: assert doc.description == "Feature implementation" assert doc.stages == [] assert doc.prompt is None + + +class TestFlowDocumentMemoryContract: + """Contract tests — the ``memory`` slot declared by the CODEMANIFEST.""" + + def test_flow_document_has_memory_field(self) -> None: + """``FlowDocument`` must declare a ``memory`` field.""" + names = [f.name for f in dataclass_fields(FlowDocument)] + + assert "memory" in names + + def test_flow_document_field_order_with_memory_slot(self) -> None: + """The fixed field order is prompt, root_dir, name, description, memory, stages. + + The order matches the canonical emission order in ``serialize_flow`` — + the memory block sits strictly between ``description`` and ``stages``. + """ + ordered = [f.name for f in dataclass_fields(FlowDocument)] + + assert ordered == ["prompt", "root_dir", "name", "description", "memory", "stages"] + assert ordered[3] == "description" + assert ordered[4] == "memory" + assert ordered[5] == "stages" + + def test_flow_document_memory_type_is_optional_flow_memory(self) -> None: + """``memory`` must be typed ``FlowMemory | None``.""" + memory_field = next(f for f in dataclass_fields(FlowDocument) if f.name == "memory") + + assert memory_field.type == "FlowMemory | None" + + def test_flow_document_memory_defaults_to_none(self) -> None: + """A ``FlowDocument`` built without ``memory`` has ``memory=None``. + + The default is the byte-identity pin — a document without memory + serializes without the block entirely. + """ + doc = FlowDocument(name="N", description="D", stages=[]) + + assert doc.memory is None + + def test_flow_document_round_trips_with_memory(self) -> None: + """An explicit ``FlowMemory`` round-trips through construction verbatim.""" + stage = FlowStage(id="a", name="A", depends_on=None, fields={}) + block = FlowMemory(path=".goga/memory/x", mode="rw", memory_use=True, max_rules=25, commit=False) + doc = FlowDocument(name="N", description="D", memory=block, stages=[stage]) + + assert doc.memory is block + assert doc.name == "N" + assert doc.description == "D" + assert doc.stages == [stage] diff --git a/tests/pipeline/compiler/test_flow_memory_contract.py b/tests/pipeline/compiler/test_flow_memory_contract.py new file mode 100644 index 00000000..32894cab --- /dev/null +++ b/tests/pipeline/compiler/test_flow_memory_contract.py @@ -0,0 +1,87 @@ +"""Contract tests for the ``FlowMemory`` dataclass. + +Verifies the public API declared by the compiler-cell CODEMANIFEST: +importability from the facade (including the ``__all__`` obligation), the five +declared properties, the fixed field order (the emission order of the block +keys), kw_only construction, and the no-default pins of the three required +fields (``path`` / ``max_rules`` / ``commit`` — only ``compile_flow`` builds +this type). These tests pin the contract surface — behavior lives in the +logic test module. +""" + +from __future__ import annotations + +from dataclasses import fields + +import pytest +from goga.pipeline.compiler import FlowMemory + + +class TestFlowMemoryContract: + """Contract tests — the public API declared by the compiler-cell CODEMANIFEST.""" + + def test_flow_memory_importable_from_facade(self) -> None: + """FlowMemory is importable from the facade and listed in ``__all__``.""" + import goga.pipeline.compiler as facade + + assert facade.FlowMemory is FlowMemory + assert "FlowMemory" in facade.__all__ + + def test_flow_memory_has_path_property(self) -> None: + """FlowMemory exposes a ``path`` property.""" + assert hasattr(FlowMemory(path=".goga/memory", max_rules=25, commit=False), "path") + assert ( + FlowMemory(path=".goga/memory/x", max_rules=25, commit=False).path == ".goga/memory/x" + ) + + def test_flow_memory_has_mode_property(self) -> None: + """FlowMemory exposes a ``mode`` property defaulting to None.""" + assert hasattr(FlowMemory(path=".goga/memory", max_rules=25, commit=False), "mode") + block = FlowMemory(path=".goga/memory", max_rules=25, commit=False) + assert block.mode is None + assert FlowMemory(path=".goga/memory", mode="rw", max_rules=25, commit=False).mode == "rw" + + def test_flow_memory_has_memory_use_property(self) -> None: + """FlowMemory exposes a ``memory_use`` property defaulting to None.""" + assert hasattr(FlowMemory(path=".goga/memory", max_rules=25, commit=False), "memory_use") + block = FlowMemory(path=".goga/memory", max_rules=25, commit=False) + assert block.memory_use is None + assert ( + FlowMemory(path=".goga/memory", memory_use=True, max_rules=25, commit=False).memory_use + is True + ) + + def test_flow_memory_has_max_rules_property(self) -> None: + """FlowMemory exposes a ``max_rules`` property.""" + assert hasattr(FlowMemory(path=".goga/memory", max_rules=25, commit=False), "max_rules") + assert FlowMemory(path=".goga/memory", max_rules=40, commit=False).max_rules == 40 + + def test_flow_memory_has_commit_property(self) -> None: + """FlowMemory exposes a ``commit`` property.""" + assert hasattr(FlowMemory(path=".goga/memory", max_rules=25, commit=False), "commit") + assert FlowMemory(path=".goga/memory", max_rules=25, commit=True).commit is True + + def test_flow_memory_field_order_fixed(self) -> None: + """Field order is fixed: path, mode, memory_use, max_rules, commit.""" + names = [field.name for field in fields(FlowMemory)] + + assert names == ["path", "mode", "memory_use", "max_rules", "commit"] + + def test_flow_memory_constructible_kw_only(self) -> None: + """FlowMemory is keyword-only — positional construction raises TypeError.""" + with pytest.raises(TypeError): + FlowMemory(".goga/memory", None, None, 25, False) # type: ignore[misc] + + def test_flow_memory_required_fields_have_no_defaults(self) -> None: + """Only ``mode`` / ``memory_use`` default — every other field is required. + + ``path`` / ``max_rules`` / ``commit`` carry NO defaults: a block is + always complete (``compile_flow`` is the single construction site, and + a silently-defaulted field would fabricate emission values). + """ + with pytest.raises(TypeError): + FlowMemory() # type: ignore[call-arg] + with pytest.raises(TypeError): + FlowMemory(path=".goga/memory") # type: ignore[call-arg] + with pytest.raises(TypeError): + FlowMemory(path=".goga/memory", max_rules=25) # type: ignore[call-arg] diff --git a/tests/pipeline/compiler/test_flow_memory_logic.py b/tests/pipeline/compiler/test_flow_memory_logic.py new file mode 100644 index 00000000..8bc41360 --- /dev/null +++ b/tests/pipeline/compiler/test_flow_memory_logic.py @@ -0,0 +1,68 @@ +"""Logic tests for the ``FlowMemory`` dataclass. + +Covers construction behavior beyond the contract surface: the emission-order +pin of the field list and the two method shapes — the reflect-method block +(``mode`` / ``memory_use`` both ``None``) versus the alignment-method block +(``mode`` the materialized value, ``memory_use`` ``True``). The ``None`` +fields are the omission signal the serializer drops — a block must never +conflate an unset field with an authored value. +""" + +from __future__ import annotations + +from dataclasses import fields + +from goga.pipeline.compiler import FlowMemory + + +class TestFlowMemoryLogic: + """Logic tests — construction behavior of the ``FlowMemory`` dataclass.""" + + def test_flow_memory_field_order_is_emission_order(self) -> None: + """Field order equals the emission order of the block keys. + + ``serialize_flow`` reads the block in this order (path, mode, + memory_use, max_rules, commit) — a reorder of the dataclass fields + would silently shift the compiled output. + """ + names = [field.name for field in fields(FlowMemory)] + + assert names == ["path", "mode", "memory_use", "max_rules", "commit"] + + def test_flow_memory_none_fields_distinct_from_values(self) -> None: + """The reflect-method shape leaves ``mode``/``memory_use`` None; alignment carries values. + + A reflect-method block (no ``mode``, no ``memory_use``) is the shape + the compiler builds from a bare reflect configuration — both optional + fields fall to their ``None`` defaults and are omitted from the + output. The alignment-method block carries the materialized mode and + the global participation default ``True``. + """ + reflect_block = FlowMemory(path=".goga/memory", max_rules=25, commit=False) + + assert reflect_block.mode is None + assert reflect_block.memory_use is None + assert reflect_block.path == ".goga/memory" + assert reflect_block.max_rules == 25 + assert reflect_block.commit is False + + alignment_block = FlowMemory( + path=".goga/memory/goga-development", + mode="rw", + memory_use=True, + max_rules=25, + commit=False, + ) + + assert alignment_block.mode == "rw" + assert alignment_block.memory_use is True + assert alignment_block.path == ".goga/memory/goga-development" + + def test_flow_memory_equality_of_identical_constructions(self) -> None: + """Two blocks with identical fields compare equal (dataclass equality).""" + left = FlowMemory(path=".goga/memory", max_rules=25, commit=False) + right = FlowMemory( + path=".goga/memory", mode=None, memory_use=None, max_rules=25, commit=False + ) + + assert left == right From 534b1576ea06cc4b33cd3fea5bcde0d1fc40731c Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 21:38:42 +0000 Subject: [PATCH 151/229] feat: emit the top-level memory block in serialize_flow --- goga/pipeline/compiler/serialize_flow.py | 36 +++- .../test_serialize_flow_memory_slot.py | 185 ++++++++++++++++++ 2 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 tests/pipeline/compiler/test_serialize_flow_memory_slot.py diff --git a/goga/pipeline/compiler/serialize_flow.py b/goga/pipeline/compiler/serialize_flow.py index c9f6e9fb..92e7eb1e 100644 --- a/goga/pipeline/compiler/serialize_flow.py +++ b/goga/pipeline/compiler/serialize_flow.py @@ -3,7 +3,14 @@ ``serialize_flow`` is the serialization half of the compiler cell: it takes a fully-built ``FlowDocument`` (with ``FlowStage.fields`` already in canonical key order — enforced by ``compile_flow``) and renders it into the canonical afm -flow-file format. It performs no file I/O and no reordering. +flow-file format. It performs no file I/O and no reordering. The top-level keys +are emitted in fixed order — ``prompt`` (when present), ``root_dir`` (when +supplied), ``name``, ``description``, ``memory`` (when memory participates), +``stages``; the memory block carries the fixed key order ``path``, ``mode``, +``memory_use``, ``max_rules``, ``commit`` with every present value a plain +scalar and a ``None`` field omitted entirely, while the stage memory keys +(``reflect`` mapping, ``memory_use`` bool) ride the regular ``fields`` +emission — a block-style mapping and a plain bool scalar respectively. The non-standard rules are isolated behind marker subclasses and their custom representers on ``_CanonicalDumper``: flow-style for ``agents`` @@ -136,9 +143,16 @@ def serialize_flow(doc: FlowDocument) -> str: Top-level keys are emitted in fixed order — ``prompt`` first when not ``None`` (block-literal scalar style), then ``root_dir`` when not - ``None`` (plain scalar), then ``name``, ``description``, ``stages``. + ``None`` (plain scalar), then ``name``, ``description``, ``memory`` (when + present), ``stages``. When ``doc.prompt is None`` the ``prompt`` key is omitted entirely; when ``doc.root_dir is None`` the ``root_dir`` key is omitted entirely. + When ``doc.memory is not None`` the memory block is emitted after + ``description`` and before ``stages`` with the fixed key order ``path``, + ``mode``, ``memory_use``, ``max_rules``, ``commit`` — every present value + a plain scalar, a ``None`` field omitted entirely (no key in the output); + when ``doc.memory is None`` the block is omitted entirely — + byte-identical output for memory-free workflows. Each stage is emitted as ``id``, ``name``, then the stage's ``fields`` verbatim (preserving their canonical order), then ``depends_on`` only when it is not ``None``. ``agents`` lists serialize in flow-style; @@ -153,7 +167,11 @@ def serialize_flow(doc: FlowDocument) -> str: serializes only as ``auto_run: false``). ``buttons`` values serialize as plain scalars when single-line (quoted as needed) and in block-literal scalar style when multi-line, preserving the map's insertion order; a stage - without a ``buttons`` key serializes without it. The output ends with exactly + without a ``buttons`` key serializes without it. The stage memory keys ride + the same ``fields`` passthrough verbatim: ``reflect`` serializes as a + block-style mapping of plain scalars (``file``, ``mode`` — the nested keys + at the second indent level under ``stages``) and ``memory_use`` as a plain + bool scalar. The output ends with exactly one trailing newline. The serializer does not reorder, validate, or otherwise transform the input — @@ -176,6 +194,18 @@ def serialize_flow(doc: FlowDocument) -> str: top["name"] = doc.name top["description"] = doc.description + if doc.memory is not None: + top["memory"] = { + key: value + for key, value in ( + ("path", doc.memory.path), + ("mode", doc.memory.mode), + ("memory_use", doc.memory.memory_use), + ("max_rules", doc.memory.max_rules), + ("commit", doc.memory.commit), + ) + if value is not None + } top["stages"] = [_build_stage_repr(stage) for stage in doc.stages] text = yaml.dump( diff --git a/tests/pipeline/compiler/test_serialize_flow_memory_slot.py b/tests/pipeline/compiler/test_serialize_flow_memory_slot.py new file mode 100644 index 00000000..a3ad9c68 --- /dev/null +++ b/tests/pipeline/compiler/test_serialize_flow_memory_slot.py @@ -0,0 +1,185 @@ +"""Logic tests for the top-level ``memory`` slot + ``serialize_flow`` emission. + +Covers the memory serializer extension: a non-``None`` ``FlowDocument.memory`` +is emitted between ``description`` and ``stages`` with the fixed key order +``path, mode, memory_use, max_rules, commit`` and a ``None`` field omitted +entirely; a ``None`` memory omits the block entirely — byte-identical output +for memory-free workflows. The per-stage memory keys (``reflect`` mapping, +``memory_use`` bool) ride the existing ``fields`` passthrough: a dict renders +block-style under the default ``beautiful_yaml`` parameters, a bool renders as +a plain scalar — no representer and no ``_build_stage_repr`` change. +""" + +from __future__ import annotations + +from goga.pipeline.compiler import FlowDocument, FlowMemory, FlowStage, serialize_flow + +# Golden byte-identity literal for a memory-free document — frozen from the +# serializer output BEFORE the memory branch existed. Any leak of memory into +# the output (a block, a stage key, a key-order shift) changes this string. +MEMORY_FREE_GOLDEN = ( + "name: demo\n" + "description: Demo pipeline\n" + "stages:\n" + "- id: brainstorm\n" + " name: Brainstorm\n" + " agents: [auto]\n" + " prompt: Think\n" + "- id: build\n" + " name: Build\n" + " agents: [auto]\n" + " prompt: Make\n" + " depends_on:\n" + " - brainstorm\n" +) + + +def _stage(id: str, name: str, fields: dict[str, object], depends_on: list[str] | None = None) -> FlowStage: + """Build one ``FlowStage`` with the given fields (mirrors the compile output shape).""" + return FlowStage(id=id, name=name, depends_on=depends_on, fields=fields) + + +class TestSerializeFlowMemoryBlock: + """Behavioral tests for the top-level memory block emission rules.""" + + def test_serialize_flow_memory_block_position_and_key_order(self) -> None: + """The block sits between ``description`` and ``stages`` with fixed key order, plain scalars.""" + doc = FlowDocument( + name="n", + description="d", + memory=FlowMemory( + path=".goga/memory/x", + mode="rw", + memory_use=True, + max_rules=25, + commit=False, + ), + stages=[_stage("a", "A", {"prompt": "Do"})], + ) + + text = serialize_flow(doc) + + # Position: description < memory < stages. + idx_description = text.index("description:") + idx_memory = text.index("memory:") + idx_stages = text.index("stages:") + assert idx_description < idx_memory < idx_stages + # In-block key order: path < mode < memory_use < max_rules < commit. + idx_path = text.index("path: .goga/memory/x") + idx_mode = text.index("mode: rw") + idx_use = text.index("memory_use: true") + idx_max = text.index("max_rules: 25") + idx_commit = text.index("commit: false") + assert idx_memory < idx_path < idx_mode < idx_use < idx_max < idx_commit + assert idx_commit < idx_stages + # The exact block literal — every value a plain scalar, 2-space indent + # (beautiful_yaml indent=2; the nested stage keys sit at 4, not 2). + assert ( + "memory:\n" + " path: .goga/memory/x\n" + " mode: rw\n" + " memory_use: true\n" + " max_rules: 25\n" + " commit: false\n" + ) in text + + def test_serialize_flow_none_fields_omitted_from_block(self) -> None: + """A ``None`` field is omitted entirely — the reflect-method block carries exactly path/max_rules/commit.""" + doc = FlowDocument( + name="n", + description="d", + memory=FlowMemory(path=".goga/memory", max_rules=9, commit=True), + stages=[_stage("a", "A", {"prompt": "Do"})], + ) + + text = serialize_flow(doc) + + block_text = text[text.index("memory:") : text.index("stages:")] + assert "path: .goga/memory" in block_text + assert "max_rules: 9" in block_text + assert "commit: true" in text + assert "commit: true" in block_text + # None fields never reach the output — no key at all, not an empty value. + assert "mode:" not in block_text + assert "memory_use:" not in block_text + + def test_serialize_flow_document_without_memory_omits_block(self) -> None: + """A ``None`` memory produces no top-level ``memory:`` key at all.""" + doc = FlowDocument(name="n", description="d", memory=None, stages=[_stage("a", "A", {})]) + + text = serialize_flow(doc) + + assert "memory:" not in text + assert "memory" not in text + + +class TestSerializeFlowStageMemoryKeys: + """Behavioral tests for the per-stage memory keys riding the ``fields`` passthrough.""" + + def test_serialize_flow_reflect_is_block_style_mapping(self) -> None: + """A stage ``reflect`` dict renders block-style (4-space nested keys) without any block emitted.""" + doc = FlowDocument( + name="n", + description="d", + stages=[_stage("s", "S", {"reflect": {"file": "shared.md", "mode": "rw"}})], + ) + + text = serialize_flow(doc) + + assert "reflect:" in text + assert "file: shared.md" in text + assert "mode: rw" in text + # Block-style, not flow-style. + assert "reflect: {file" not in text + # The nested keys sit at 4 spaces — the second level under ``stages``. + assert " file: shared.md" in text + assert " mode: rw" in text + # No block emitted -> no stage carries an opting-out key. + assert "memory_use: false" not in text + assert "memory:" not in text + + def test_serialize_flow_memory_use_is_plain_bool_scalar(self) -> None: + """A stage ``memory_use`` bool renders as a plain scalar (true/false, unquoted, not block).""" + doc = FlowDocument( + name="n", + description="d", + stages=[ + _stage("s", "S", {"memory_use": True}), + _stage("t", "T", {"memory_use": False}), + ], + ) + + text = serialize_flow(doc) + + assert "memory_use: true" in text + assert "memory_use: false" in text + assert "memory_use: 'true'" not in text + assert "memory_use: |" not in text + + +class TestSerializeFlowMemoryFreeByteIdentity: + """The byte-identity barrier: a memory-free document serializes unchanged.""" + + def test_serialize_flow_memory_free_document_is_byte_identical(self) -> None: + """A document without memory serializes to the frozen pre-change golden, byte for byte.""" + doc = FlowDocument( + name="demo", + description="Demo pipeline", + memory=None, + stages=[ + _stage("brainstorm", "Brainstorm", {"agents": ["auto"], "prompt": "Think"}), + _stage( + "build", + "Build", + {"agents": ["auto"], "prompt": "Make"}, + depends_on=["brainstorm"], + ), + ], + ) + + text = serialize_flow(doc) + + assert text == MEMORY_FREE_GOLDEN + # No memory key anywhere — top level or stage level. + assert "memory" not in text + assert "reflect" not in text From 7ec9909ea1661b5f1350463f94ae058e9215b7fb Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 21:48:08 +0000 Subject: [PATCH 152/229] feat: compute memory participation and assemble stage memory keys in compile_flow --- goga/pipeline/compiler/compile_flow.py | 408 +++++++++++++-- goga/pipeline/compiler/flow_stage.py | 25 +- tests/pipeline/compiler/test_compile_flow.py | 18 +- .../compiler/test_compile_flow_memory.py | 493 ++++++++++++++++++ 4 files changed, 885 insertions(+), 59 deletions(-) create mode 100644 tests/pipeline/compiler/test_compile_flow_memory.py diff --git a/goga/pipeline/compiler/compile_flow.py b/goga/pipeline/compiler/compile_flow.py index de6dfdf2..ec57bca4 100644 --- a/goga/pipeline/compiler/compile_flow.py +++ b/goga/pipeline/compiler/compile_flow.py @@ -77,6 +77,38 @@ buttons belongs to afm — the compiler only assembles and serializes the field. Output-side only — the ``PipelineDocument.body`` returned to consumers stays the faithful mirror of the source pipeline-file. + +Symmetrically, the workflow memory instructions compile into the top-level +``memory`` block and the per-stage memory keys. Step 4.9 +(``_memory_emission``) resolves the effective memory configuration — the +workflow document's ``memory`` block, else a default-constructed +``WorkflowMemory()`` whose field defaults ARE the materialized authoring +defaults — and counts participation over the working body (after skip removal +and loop expansion, embedded extend-stages included; a skipped stage's +instructions never count): under the reflect method a stage participates by +carrying a ``reflect`` instruction, under the alignment method by carrying a +true ``memory`` instruction. The block is emitted if and only if at least one +stage participates — a memory configuration alone is a silent no-op (no +block, no stage keys, not even an opting-out stage key). The emitted block +composes the fixed memory root ``.goga/memory`` with the authored suffix +(the bare root when the suffix is ``None``); the reflect method emits ``mode`` +and ``memory_use`` as ``None`` (omitted from the output), the alignment +method emits the materialized ``mode`` and ``memory_use: true``; ``max_rules`` +and ``commit`` carry from the effective configuration. The stage keys occupy +the canonical slots immediately after ``script_timeout``: under reflect a +participating stage carries ``reflect: {file, mode}`` (file verbatim, mode +materialized); under alignment EVERY stage carries ``memory_use`` — true on a +participating stage, an explicit false on every non-participating one (afm's +``UseFor(stage)`` inherits the global default for an unset key, so the +compiler never leaves one unset). Both are uniform across every loop-expanded +copy. The goga-side method selector never reaches the output. An authoring +``reflect`` / ``memory_use`` key in a stage body (pipeline-file stage OR +embedded extend-stage body) is a structural error — the memory stage keys are +compiled exclusively from the workflow instructions. The authoring vocabulary +is NOT re-validated here — ``parse_workflow`` already rejected non-conforming +authoring. Interpretation of the memory keys belongs to afm — the compiler +only assembles and serializes them. Output-side only — nothing memory-side +leaks into the ``PipelineDocument.body`` returned to consumers. ``auto`` is a sentinel string emitted verbatim (goga does not interpret it; afm resolves the agent). In a body carrying ``script``, the ``agents`` directive is NOT @@ -110,12 +142,14 @@ import copy import logging +from dataclasses import dataclass from pathlib import Path from typing import Any -from ..workflow import WorkflowDocument, WorkflowExtendStage, WorkflowStage +from ..workflow import WorkflowDocument, WorkflowExtendStage, WorkflowMemory, WorkflowStage from .body_format import BodyFormat from .flow_document import FlowDocument +from .flow_memory import FlowMemory from .flow_stage import FlowStage from .parse_dsl import StructuralError, parse_dsl from .phase_step import PhaseStep @@ -150,6 +184,15 @@ # deep copy per ``FlowStage``); present only when the workflow supplied a # non-empty notes instruction for the stage, so pipelines without notes # compile byte-identically. +# ``reflect`` (map of file + mode) and ``memory_use`` (bool) close the list — +# the compiled form of the workflow memory instructions. ``reflect`` is present +# only when the memory block is emitted and the stage's reflect instruction is +# effective (the authored file verbatim, the materialized mode), uniform across +# every loop-expanded copy. ``memory_use`` is present only when the block is +# emitted under the alignment method — ``True`` on a participating stage, an +# explicit ``False`` on every non-participating one. A stage of a memory-free +# workflow carries neither key, so pipelines without memory participation +# compile byte-identically. _CANONICAL_KEY_ORDER = [ "interactive", "auto_approve", @@ -166,8 +209,17 @@ "script", "script_after", "script_timeout", + "reflect", + "memory_use", ] +# The fixed project-memory root of the emitted memory block. The authored +# ``memory.path`` suffix is joined onto this root (``.goga/memory/<suffix>``); +# a ``None`` suffix emits the bare root. The root is fixed here — the workflow +# cell carries the authored suffix only, and this cell composes the final path +# (the single composition site). +_MEMORY_ROOT = ".goga/memory" + # Sentinel key threaded by ``_apply_per_stage_overrides`` into a reconstructed # step body to carry the effective ``approve`` directive through loop-expansion # (it survives ``copy.deepcopy`` in ``_expand_loops``/``_make_expanded_copy``) @@ -325,14 +377,17 @@ def _inject_defaults(body: dict[str, Any], suppress_agents: bool = False) -> dic def _reject_authoring_output_keys(body: dict[str, Any]) -> None: """Reject authoring-side stage-body keys that duplicate output-only afm fields. - Four authoring keys are forbidden because each names an afm OUTPUT field + Six authoring keys are forbidden because each names an afm OUTPUT field whose authoring-side counterpart is a different key: ``agents`` (author the ``roles`` field — translated element-wise), ``interactive`` (author the ``communication`` field — renamed), ``auto_run`` (author the ``trigger`` - field — ``trigger: manual`` assembles ``auto_run: false``), and ``buttons`` + field — ``trigger: manual`` assembles ``auto_run: false``), ``buttons`` (author the workflow ``notes`` instruction — a different FILE, not a different body key: buttons live in the workflow-file stages block, never - in a stage body). Checking them in + in a stage body), and the two memory keys ``reflect`` / ``memory_use`` + (author the workflow ``reflect`` / ``memory`` instructions — the memory + stage keys are compiled exclusively from the workflow instructions, never + from a stage body). Checking them in one place, at the very start of ``_canonical_fields``, keeps every prohibition ahead of any translation, exactly as the contract orders it. @@ -341,7 +396,7 @@ def _reject_authoring_output_keys(body: dict[str, Any]) -> None: extend body). Raises: - StructuralError: When ``body`` carries any of the four authoring keys, + StructuralError: When ``body`` carries any of the six authoring keys, with the contract message naming the authoring-side field to use. """ if "agents" in body: @@ -356,6 +411,12 @@ def _reject_authoring_output_keys(body: dict[str, Any]) -> None: if "buttons" in body: raise StructuralError("buttons key is forbidden in stage body; use notes in workflow.stages") + if "reflect" in body: + raise StructuralError("reflect key is forbidden in stage body; use reflect in workflow.stages") + + if "memory_use" in body: + raise StructuralError("memory_use key is forbidden in stage body; use memory in workflow.stages") + def _validate_trigger(body: dict[str, Any]) -> str | None: """Return the effective ``trigger`` of ``body``, validating the closed value set. @@ -448,10 +509,39 @@ def _assemble_buttons(source: dict[str, Any], notes: dict[str, str] | None) -> N source["buttons"] = copy.deepcopy(notes) +def _assemble_memory_keys(source: dict[str, Any], memory_fields: dict[str, Any] | None) -> None: + """Assign the stage's computed memory keys into the output fields dict. + + The single assembly site of the stage memory keys (by the + ``_assemble_buttons`` precedent, but a plain assignment — the values are + fresh dictionaries/scalars built per call by ``_memory_emission``, so no + deep copy is needed; strings are immutable). A non-empty ``memory_fields`` + map updates the REBUILT source dict in place (the canonical-order loop in + ``_canonical_fields`` then slots ``reflect`` / ``memory_use`` into their + slots immediately after ``script_timeout``). The memory value travels as a + function argument, never threaded through the stage body (the + authoring prohibition in ``_reject_authoring_output_keys`` guards that + channel). ``None`` or an empty map — a non-participating stage, or any + stage when the memory block is absent — assembles no key at all. + + Args: + source: The REBUILT body dict (authoring keys already translated) — + mutated in place by the assignment (the caller's fresh dict, never + the caller's original parsed body). + memory_fields: The computed memory keys for this stage (a + ``{"reflect": {...}}` or ``{"memory_use": bool}`` map from + ``_memory_emission``), or ``None``/empty when the stage carries + none. Read-only input. + """ + if memory_fields: + source.update(memory_fields) + + def _canonical_fields( body: dict[str, Any], stage_name: str, notes: dict[str, str] | None = None, + memory_fields: dict[str, Any] | None = None, ) -> dict[str, Any]: """Reorder ``body`` into canonical key order, deep-copying each value. @@ -469,7 +559,13 @@ def _canonical_fields( ``StructuralError("buttons key is forbidden in stage body; use notes in workflow.stages")`` — buttons are authored ONLY through the workflow ``notes`` instruction (a different FILE, not a different body key); - ``buttons`` is the output-only afm field. + ``buttons`` is the output-only afm field. Likewise, an authoring + ``reflect`` key is rejected with ``StructuralError("reflect key is + forbidden in stage body; use reflect in workflow.stages")`` and an + authoring ``memory_use`` key with ``StructuralError("memory_use key is + forbidden in stage body; use memory in workflow.stages")`` — the memory + stage keys are compiled exclusively from the workflow memory instructions; + ``reflect`` / ``memory_use`` are output-only afm fields. The ``trigger`` key (when present in a pipeline-file body, an embedded extend body, or a loop-expanded copy) is read and validated: any non-``None`` @@ -537,12 +633,23 @@ def _canonical_fields( trips the authoring-buttons prohibition above), keeping the single-authoring-source rule intact. A ``None`` notes (no instruction, or an empty map normalized to ``None`` upstream) assembles no ``buttons`` key - at all. Known keys (``interactive``, + at all. The ``memory_fields`` argument (the computed memory keys for this + stage, resolved by FINAL id so loop-expanded copies share them) is + assembled into the output by ``_assemble_memory_keys`` whenever it is + non-empty: under the reflect method a participating stage carries + ``reflect: {file, mode}``; under the alignment method EVERY stage carries + ``memory_use`` (an explicit ``False`` on every non-participating one). The + memory value travels as a function argument ONLY — never threaded into + ``body`` under any key (a body ``reflect``/``memory_use`` key trips the + authoring prohibition above); ``None`` or empty assembles no key at all + (a memory-free stage of a block-less workflow carries neither key, so + memory-free pipelines compile byte-identically). Known keys + (``interactive``, ``auto_approve``, ``auto_run``, ``command``, ``prompt``, ``description``, ``buttons``, ``agents``, ``supervisor``, ``supervisor_prompt``, ``skills``, ``script_before``, ``script``, ``script_after``, - ``script_timeout``) are emitted in + ``script_timeout``, ``reflect``, ``memory_use``) are emitted in that fixed order; any remaining keys are appended alphabetically. The input-only ``roles`` key never reaches the output (dropped in ``_inject_defaults``); the input-only ``communication`` key never reaches the @@ -567,6 +674,13 @@ def _canonical_fields( loop-expanded copies share it), or ``None`` when the workflow carries none. Read-only input — deep-copied into the assembled ``buttons`` field, never threaded into ``body``. + memory_fields: The computed memory keys for this stage (a + ``{"reflect": {"file": ..., "mode": ...}}`` map under the reflect + method, or a ``{"memory_use": bool}`` map under alignment, already + resolved by final id so loop-expanded copies share them), or + ``None``/empty when the stage carries none. Read-only input — + assigned into the assembled fields by ``_assemble_memory_keys``, + never threaded into ``body``. Returns: A new dict in canonical key order with deep-copied values. @@ -580,7 +694,10 @@ def _canonical_fields( field for the launch mode is ``trigger``; ``auto_run`` is output-only. Or if ``body`` carries an authoring ``buttons`` key — buttons are authored ONLY through the workflow ``notes`` - instruction; ``buttons`` is output-only. Or if ``body`` carries a + instruction; ``buttons`` is output-only. Or if ``body`` carries an + authoring ``reflect``/``memory_use`` key — the memory stage keys are + authored ONLY through the workflow memory instructions; + both are output-only. Or if ``body`` carries a ``trigger`` value outside the closed set ``on_success``/``manual`` (a ``None`` value counts as absent). Or if @@ -671,6 +788,13 @@ def _canonical_fields( # function stays non-mutating. _assemble_buttons(source, notes) + # Computed stage memory keys (step 4.9). The memory value travels as a + # function argument — never threaded through the body (the authoring + # prohibition above would trip on a body ``reflect``/``memory_use`` key) — + # and is assigned by ``_assemble_memory_keys`` BEFORE the canonical-order + # loop so both keys land in their canonical slots after ``script_timeout``. + _assemble_memory_keys(source, memory_fields) + # An approve directive that drives the roles effect (``auto``/``dialog``) + # ``planner`` in the raw roles ⇒ emit ``auto_approve: true`` (canonical slot # right after ``interactive``). The two approve effects are independent: @@ -716,25 +840,32 @@ def _effective_overrides(workflow: WorkflowDocument) -> dict[str, WorkflowStage] stages-block-only the same way: the extend seed carries ``notes=None`` (the constructor default — ``parse_workflow`` rejects ``notes`` in an extend-entry), and the merged branch passes ``notes=stg.notes`` - explicitly, mirroring ``manual``. + explicitly, mirroring ``manual``. The two memory-participation + instructions ``reflect``/``memory`` are stages-block-only the same way too + (``parse_workflow`` rejects both in an extend-entry), so the merged branch + passes ``reflect=stg.reflect, memory=stg.memory`` explicitly — an overlay + that relied on the constructor default would silently drop the + participation instruction of an extend-stage named in both ``extend`` and + ``stages``. Args: workflow: The declarative workflow instructions. Returns: The effective per-stage override map keyed by stage name. Extend-seeded - entries carry only ``agent``/``loop``/``approve`` (``manual`` and - ``notes`` stay ``None``); stages-block entries carry their full - ``WorkflowStage``; merged entries combine them per-field, always - carrying the stages-block ``manual`` and ``notes``. + entries carry only ``agent``/``loop``/``approve`` (``manual``, + ``notes``, ``reflect``, and ``memory`` stay ``None``); stages-block + entries carry their full ``WorkflowStage``; merged entries combine them + per-field, always carrying the stages-block ``manual``, ``notes``, + ``reflect``, and ``memory``. """ effective: dict[str, WorkflowStage] = {} for name, ext in workflow.extend.items(): # Extend-seeded default: only the inline fields an extend-entry can - # carry. ``manual``/``notes`` stay ``None`` (the constructor defaults) - # — both are stages-block-only (``parse_workflow`` rejects them in an - # extend-entry). + # carry. ``manual``/``notes``/``reflect``/``memory`` stay ``None`` (the + # constructor defaults) — all four are stages-block-only + # (``parse_workflow`` rejects them in an extend-entry). effective[name] = WorkflowStage(agent=ext.agent, loop=ext.loop, approve=ext.approve) for name, stg in workflow.stages.items(): @@ -744,9 +875,9 @@ def _effective_overrides(workflow: WorkflowDocument) -> dict[str, WorkflowStage] effective[name] = stg continue # Per-field overlay: stages-block wins whenever its field is not None. - # ``manual`` and ``notes`` are passed explicitly — the extend seed - # carries neither, and the constructor default (None) would silently - # drop the instruction. + # ``manual``, ``notes``, ``reflect``, and ``memory`` are passed + # explicitly — the extend seed carries none of them, and the + # constructor default (None) would silently drop the instruction. effective[name] = WorkflowStage( agent=stg.agent if stg.agent is not None else base.agent, prompt=stg.prompt, @@ -755,6 +886,8 @@ def _effective_overrides(workflow: WorkflowDocument) -> dict[str, WorkflowStage] approve=stg.approve if stg.approve is not None else base.approve, manual=stg.manual, notes=stg.notes, + reflect=stg.reflect, + memory=stg.memory, ) return effective @@ -801,6 +934,128 @@ def _effective_notes_by_id( return notes_by_id +@dataclass(kw_only=True) +class _MemoryEmission: + """The step-4.9 result — the memory block plus the per-stage memory keys. + + ``block`` is the compiled top-level ``FlowMemory`` (``None`` when no stage + participates — the block is emitted if and only if participation exists). + ``keys_by_id`` maps every FINAL step id that carries a memory key to its + computed keys (``{"reflect": {"file": ..., "mode": ...}}`` under the + reflect method — only participating ids; ``{"memory_use": bool}`` under + alignment — every final id). Both values are built fresh per + ``_memory_emission`` call; the dataclass is private plumbing, never part + of the facade. + + Args: + block: The compiled memory block, or ``None`` when memory does not + participate. + keys_by_id: The final-id → memory-keys map consumed per step by + ``_canonical_fields`` (loop-expanded copies resolve through their + base name's produced ids). + """ + + block: FlowMemory | None + keys_by_id: dict[str, dict[str, Any]] + + +def _memory_emission( + workflow: WorkflowDocument | None, + effective: dict[str, WorkflowStage], + expanded_ids: dict[str, list[str]], +) -> _MemoryEmission: + """Step 4.9 — compute the memory block and the per-stage memory keys. + + The single place where block emission is decided and the stage keys are + computed. The effective memory configuration is the workflow document's + ``memory`` block when it carries one, else a default-constructed + ``WorkflowMemory()`` (its field defaults ARE the materialized authoring + defaults — path ``None``, ``max_rules`` 25, ``commit`` False, ``mode`` + ``None``; the default method is ``"reflect"``). Participation is computed + over the WORKING body — the stages present in ``expanded_ids``, i.e. after + skip removal and loop expansion, embedded extend-stages included; a stage + removed by skip never appears there, so its instructions never count. A + final id whose base has no workflow record (``effective.get(base)`` is + ``None``) never participates — the same guard as + ``_effective_notes_by_id``. + + Under the reflect method a stage participates when its ``reflect`` + instruction is not ``None``; under the alignment method when its ``memory`` + instruction is ``True``. With no participants the emission is a silent + no-op: no block, no stage keys, not even an opting-out stage key (a memory + configuration alone never turns the block on). + + The block composes ``_MEMORY_ROOT`` with the authored suffix (the bare + root when the suffix is ``None``); the reflect method emits ``mode`` and + ``memory_use`` as ``None``, the alignment method the materialized ``mode`` + and ``memory_use: True``; ``max_rules``/``commit`` carry from the effective + configuration. The keys: under reflect every PARTICIPATING final id + carries ``{"reflect": {"file": ..., "mode": ...}}`` (the authored file + verbatim, the materialized mode); under alignment EVERY final id carries + ``{"memory_use": ...}`` — ``True`` for participants, an explicit ``False`` + for everyone else (afm's ``UseFor(stage)`` inherits the global default for + an unset key, so the compiler never leaves one unset). Every + loop-expanded copy of one base carries identical keys. The goga-side + method selector never reaches any output. + + The authoring vocabulary is NOT re-validated here — ``parse_workflow`` + already rejected non-conforming authoring. This helper only READS + ``effective``/``expanded_ids`` (no mutation) and builds fresh values, so + no deep copy is needed. + + Args: + workflow: The declarative workflow instructions, or ``None`` when no + workflow is applied (no block, no keys — byte-identical output). + effective: The resolved per-stage override map (from + ``_effective_overrides``), keyed by stage name. Read-only input. + expanded_ids: The base-name → produced-ids map from ``_expand_loops`` + over the working body (every final id appears in exactly one + produced-ids list). Read-only input. + + Returns: + The ``_MemoryEmission`` — the block (or ``None``) and the final-id → + memory-keys map. + """ + if workflow is None: + return _MemoryEmission(block=None, keys_by_id={}) + + config = workflow.memory if workflow.memory is not None else WorkflowMemory() + method = config.method + + participating_ids: set[str] = set() + for base_name, produced_ids in expanded_ids.items(): + stage = effective.get(base_name) + instr_reflect = stage.reflect if stage is not None else None + instr_memory = (stage.memory is True) if stage is not None else False + participates = (instr_reflect is not None) if method == "reflect" else instr_memory + + if participates: + participating_ids.update(produced_ids) + + if not participating_ids: + return _MemoryEmission(block=None, keys_by_id={}) + + path = _MEMORY_ROOT if config.path is None else f"{_MEMORY_ROOT}/{config.path}" + block = FlowMemory( + path=path, + mode=(config.mode if method == "alignment" else None), + memory_use=(True if method == "alignment" else None), + max_rules=config.max_rules, + commit=config.commit, + ) + + keys_by_id: dict[str, dict[str, Any]] = {} + for base_name, produced_ids in expanded_ids.items(): + for produced_id in produced_ids: + if method == "alignment": + keys_by_id[produced_id] = {"memory_use": produced_id in participating_ids} + elif produced_id in participating_ids: + reflect = effective[base_name].reflect + keys_by_id[produced_id] = {"reflect": {"file": reflect.file, "mode": reflect.mode}} + + return _MemoryEmission(block=block, keys_by_id=keys_by_id) + + def _merge_skills( pipeline_skills: list[str] | None, workflow_skills: list[str] | None, @@ -1517,7 +1772,7 @@ def _reconstruct_body( fmt: BodyFormat, body: PhasesBody | StagesBody, workflow: WorkflowDocument, -) -> tuple[list[PhaseStep | StageStep], dict[str, dict[str, str] | None]]: +) -> tuple[list[PhaseStep | StageStep], dict[str, dict[str, str] | None], _MemoryEmission]: """Apply the workflow reconstruction branch, returning a NEW step sequence. Deep-copies the parsed steps first so the ORIGINAL body (returned later via @@ -1531,9 +1786,10 @@ def _reconstruct_body( ``StructuralError("empty body")`` if nothing survives; resolve the effective per-stage override map ONCE (inline extend → stages overlay); (4a) per-stage overrides; (4b) loop-expansion with the expanded-ids map; (4c) external - depends_on rewrite (STAGES only); the result (4d) is the reconstructed step + depends_on rewrite (STAGES only); (4.9) compute the memory emission from + the working body; the result (4d) is the reconstructed step list consumed for ``FlowStage`` assembly. The mandatory - ``4a0-pre → 4a0 → 4pre → 4skip → empty-body guard → effective → 4a → 4b → 4c`` + ``4a0-pre → 4a0 → 4pre → 4skip → empty-body guard → effective → 4a → 4b → 4c → 4.9`` ordering is load-bearing: extend-ref validation runs before the 4a0 embed (extend names come from ``workflow.extend`` keys, so cross-references between extend-stages resolve without embedding); extend-stages must be in ``steps`` @@ -1541,17 +1797,20 @@ def _reconstruct_body( ``workflow.stages`` target) and before the effective map is resolved (so their inline ``agent``/``loop`` seed the default override); skip removal runs before ``4a`` so a skipped stage's overrides are never applied ("skip wins") - — and before the notes companion map is resolved, so a skipped stage's - notes can never leak into a survivor; the empty-body guard runs once on the + — and before the notes companion map and the memory emission are resolved, + so a skipped stage's notes and memory instructions can never leak into a + survivor; the empty-body guard runs once on the working copy, format-agnostic, before any assembly. - The return also carries the per-final-id effective-notes companion map - (built by ``_effective_notes_by_id`` from the effective override map and - the expanded-ids map). The companion travels as a SEPARATE map — never - threaded into the step bodies — so the notes instruction reaches - ``_canonical_fields`` as a function argument while the working bodies stay - free of any buttons/notes key (the authoring-buttons prohibition would trip - on a body ``buttons`` key). + The return also carries the two companion values: the per-final-id + effective-notes map (built by ``_effective_notes_by_id`` from the effective + override map and the expanded-ids map) and the memory emission (built by + ``_memory_emission`` from the same inputs plus the workflow's memory + block). Both travel as SEPARATE maps — never threaded into the step bodies + — so the notes and memory instructions reach ``_canonical_fields`` as + function arguments while the working bodies stay free of any + buttons/notes/reflect/memory_use key (the authoring prohibitions would trip + on any of them). Args: fmt: The body format — PHASES or STAGES. @@ -1559,10 +1818,12 @@ def _reconstruct_body( workflow: The declarative workflow instructions. Returns: - The reconstructed step sequence (PHASES or STAGES steps) and the - final-id → effective-notes map consumed per step by - ``_canonical_fields`` (loop-expanded copies resolve through their base - name, keeping the buttons uniform across copies). + The reconstructed step sequence (PHASES or STAGES steps), the + final-id → effective-notes map, and the ``_MemoryEmission`` (the + compiled memory block plus the final-id → memory-keys map). Both maps + are consumed per step by ``_canonical_fields`` (loop-expanded copies + resolve through their base name, keeping the keys uniform across + copies). Raises: StructuralError: When a ``workflow.extend.<name>.before/.after`` ref @@ -1584,7 +1845,11 @@ def _reconstruct_body( if fmt is BodyFormat.STAGES: _rewrite_external_depends_on(expanded, expanded_ids) - return expanded, _effective_notes_by_id(effective, expanded_ids) + return ( + expanded, + _effective_notes_by_id(effective, expanded_ids), + _memory_emission(workflow, effective, expanded_ids), + ) def compile_flow( @@ -1625,9 +1890,32 @@ def compile_flow( uniform across every loop-expanded copy and applied to embedded extend-stages by name); an authoring ``buttons`` key in any stage body is rejected with ``StructuralError`` — buttons are authored ONLY through the - workflow notes instruction. The translation/injection is local to + workflow notes instruction. Symmetrically, step 4.9 computes the memory + emission: the effective memory configuration (the workflow's ``memory`` + block, else a default-constructed ``WorkflowMemory()``) plus participation + over the working body (a ``reflect`` instruction under the reflect method, + a true ``memory`` instruction under alignment; embedded extend-stages + included, skipped stages never counted). When at least one stage + participates, the top-level ``memory`` block is built (``path`` = the + fixed root ``.goga/memory`` joined with the authored suffix; reflect — + ``mode``/``memory_use`` omitted, alignment — the materialized ``mode`` and + ``memory_use: true``; ``max_rules``/``commit`` from the configuration) and + placed between ``description`` and ``stages``, and the stage memory keys + are assembled into their canonical slots after ``script_timeout`` — + ``reflect: {file, mode}`` on participating stages under reflect, + ``memory_use`` (an explicit ``false`` on every non-participating stage) + on EVERY stage under alignment, uniform across loop-expanded copies. When + no stage participates the emission is a silent no-op — no block, no stage + keys — so a workflow without memory participation compiles + byte-identically to the current output. The goga-side method selector + never reaches the output, and the authoring vocabulary is not re-validated + here (``parse_workflow`` already rejected it). An authoring ``reflect`` / + ``memory_use`` key in any stage body is rejected with + ``StructuralError`` — the memory stage keys are authored ONLY through the + workflow memory instructions. The interpretation of the emitted memory + keys belongs to afm. The translation/injection is local to ``FlowStage.fields`` — the ``PipelineDocument.body`` returned to consumers is - never affected. + never affected (output-side only). When ``workflow`` is not ``None``, the parsed body is reconstructed (per-stage overrides, loop-expansion, external depends_on rewrite) on a deep @@ -1687,16 +1975,19 @@ def compile_flow( ``PipelineDocument`` carries the parsed header (with ``header.roles``), format, and the ORIGINAL body (always unprefixed); the ``FlowDocument`` carries the name, the optionally project-name-prefixed description, - optional top-level prompt, optional top-level ``root_dir``, and compiled - stages (the input ``roles`` field translated to the output ``agents`` - field). + optional top-level prompt, optional top-level ``root_dir``, the + optional compiled ``memory`` block (``None`` when memory does not + participate), and compiled stages (the input ``roles`` field + translated to the output ``agents`` field). Raises: StructuralError: On a structural defect in the DSL (propagated from ``parse_dsl``), on an empty body, on a legacy ``agents`` key in a stage body, on an authoring ``interactive``/``auto_run`` key in a stage body (the authoring-side fields are ``communication``/ - ``trigger``), or on a ``trigger`` value outside + ``trigger``), on an authoring ``reflect``/``memory_use`` key in a + stage body (the memory stage keys are authored through the workflow + memory instructions), or on a ``trigger`` value outside ``on_success``/``manual``. FileNotFoundError: If ``pipeline_path`` does not exist or ``flow_path``'s parent is missing (propagated). @@ -1712,22 +2003,31 @@ def compile_flow( # The step sequence consumed for FlowStage assembly. When a workflow is # applied, this is a reconstructed (deep-copied + overridden + expanded) - # sequence plus the per-final-id effective-notes companion map (the source - # of each stage's ``buttons`` field — resolved by base name so - # loop-expanded copies share it); the ORIGINAL `body` is preserved for - # PipelineDocument below. Workflow-less compiles carry an empty notes map, - # so every lookup below is ``None`` and no ``buttons`` key is assembled. + # sequence plus the two companion maps: the per-final-id effective-notes + # map (the source of each stage's ``buttons`` field) and the memory + # emission of step 4.9 (the compiled memory block plus the per-final-id + # memory keys — both resolved by base name / final id so loop-expanded + # copies share them); the ORIGINAL `body` is preserved for + # PipelineDocument below. Workflow-less compiles carry an empty notes map + # and an empty emission, so every lookup below is ``None``/falsy and no + # ``buttons``/``reflect``/``memory_use`` key is assembled. if workflow is not None: - reconstructed, notes_by_id = _reconstruct_body(fmt, body, workflow) + reconstructed, notes_by_id, memory_emission = _reconstruct_body(fmt, body, workflow) else: reconstructed, notes_by_id = list(body.steps), {} + memory_emission = _MemoryEmission(block=None, keys_by_id={}) stages: list[FlowStage] = [] if fmt is BodyFormat.PHASES: for i, step in enumerate(reconstructed): depends_on = [reconstructed[i - 1].name] if i > 0 else None - fields = _canonical_fields(step.body, step.name, notes=notes_by_id.get(step.name)) + fields = _canonical_fields( + step.body, + step.name, + notes=notes_by_id.get(step.name), + memory_fields=memory_emission.keys_by_id.get(step.name), + ) stages.append( FlowStage( id=step.name, @@ -1738,7 +2038,12 @@ def compile_flow( ) elif fmt is BodyFormat.STAGES: for step in reconstructed: - fields = _canonical_fields(step.body, step.name, notes=notes_by_id.get(step.name)) + fields = _canonical_fields( + step.body, + step.name, + notes=notes_by_id.get(step.name), + memory_fields=memory_emission.keys_by_id.get(step.name), + ) stages.append( FlowStage( id=step.name, @@ -1764,6 +2069,7 @@ def compile_flow( root_dir=root_dir, name=header.name, description=description, + memory=memory_emission.block, stages=stages, ) pipeline_doc = PipelineDocument(header=header, format=fmt, body=body) diff --git a/goga/pipeline/compiler/flow_stage.py b/goga/pipeline/compiler/flow_stage.py index 9e4ab61a..7cbc3447 100644 --- a/goga/pipeline/compiler/flow_stage.py +++ b/goga/pipeline/compiler/flow_stage.py @@ -13,12 +13,19 @@ ``prompt``, ``description``, ``buttons``, ``agents``, ``supervisor``, ``supervisor_prompt``, ``skills``, ``script_before``, ``script``, ``script_after``, ``script_timeout``, -then any unknown +``reflect``, ``memory_use``, then any unknown keys alphabetically. ``auto_run`` (bool) is present only when the stage's effective trigger is ``manual`` — the value is always ``False``; ``auto_run: true`` is never assembled. ``buttons`` (map of str→str) is present only when the workflow supplied a non-empty notes instruction for the stage — -the map passes through verbatim. +the map passes through verbatim. ``reflect`` (map of file + mode) is present +only when the memory block is emitted and the stage's reflect instruction is +effective — the authored file verbatim, the materialized mode; uniform across +every loop-expanded copy. ``memory_use`` (bool) is present only when the memory +block is emitted under the alignment method — ``True`` on a participating +stage, an explicit ``False`` on every non-participating one. Both occupy the +canonical slots immediately after ``script_timeout``. A stage of a memory-free +workflow carries neither key. """ from __future__ import annotations @@ -40,13 +47,23 @@ class FlowStage: ``prompt``, ``description``, ``buttons``, ``agents``, ``supervisor``, ``supervisor_prompt``, ``skills``, ``script_before``, ``script``, - ``script_after``, ``script_timeout``, then unknown keys + ``script_after``, ``script_timeout``, ``reflect``, ``memory_use``, + then unknown keys alphabetically). ``auto_run`` (bool) is present only when the stage's effective trigger is ``manual`` — the value is always ``False``; ``auto_run: true`` is never assembled. ``buttons`` (map of str→str) is present only when the workflow supplied a non-empty notes instruction for the stage - — the map passes through verbatim. + — the map passes through verbatim. ``reflect`` (map of file + + mode) is present only when the memory block is emitted and the + stage's reflect instruction is effective — the authored file + verbatim, the materialized mode; uniform across every + loop-expanded copy. ``memory_use`` (bool) is present only when the + memory block is emitted under the alignment method — ``True`` on a + participating stage, an explicit ``False`` on every + non-participating one. Both occupy the canonical slots immediately + after ``script_timeout``; a stage of a memory-free workflow + carries neither key. """ id: str diff --git a/tests/pipeline/compiler/test_compile_flow.py b/tests/pipeline/compiler/test_compile_flow.py index eb8ce262..161f5984 100644 --- a/tests/pipeline/compiler/test_compile_flow.py +++ b/tests/pipeline/compiler/test_compile_flow.py @@ -146,15 +146,16 @@ def test_private_helpers_not_on_facade(self) -> None: assert "_CANONICAL_KEY_ORDER" not in facade_all def test_canonical_fields_signature_has_stage_name(self) -> None: - """``_canonical_fields`` takes ``(body, stage_name, notes)`` — ``notes`` optional, defaults to None.""" + """``_canonical_fields`` takes ``(body, stage_name, notes, memory_fields)`` — both optional.""" import inspect from goga.pipeline.compiler.compile_flow import _canonical_fields parameters = list(inspect.signature(_canonical_fields).parameters) - assert parameters == ["body", "stage_name", "notes"] + assert parameters == ["body", "stage_name", "notes", "memory_fields"] assert inspect.signature(_canonical_fields).parameters["notes"].default is None + assert inspect.signature(_canonical_fields).parameters["memory_fields"].default is None def test_canonical_key_order_includes_approve_and_script_slots(self) -> None: """The extended canonical order slots ``auto_approve`` and the script_* keys.""" @@ -162,10 +163,19 @@ def test_canonical_key_order_includes_approve_and_script_slots(self) -> None: # ``auto_approve`` immediately follows ``interactive``; the script_* # family trails ``skills`` in authored order (before/script/after) and - # closes with the translated ``script_timeout``. + # closes with the translated ``script_timeout``, which the two memory + # stage keys follow. assert "auto_approve" in _CANONICAL_KEY_ORDER assert _CANONICAL_KEY_ORDER.index("auto_approve") == _CANONICAL_KEY_ORDER.index("interactive") + 1 - assert _CANONICAL_KEY_ORDER[-4:] == ["script_before", "script", "script_after", "script_timeout"] + assert _CANONICAL_KEY_ORDER[-6:] == [ + "script_before", + "script", + "script_after", + "script_timeout", + "reflect", + "memory_use", + ] + assert _CANONICAL_KEY_ORDER.index("reflect") == _CANONICAL_KEY_ORDER.index("script_timeout") + 1 assert _CANONICAL_KEY_ORDER.index("skills") < _CANONICAL_KEY_ORDER.index("script_before") # ``buttons`` — the compiled form of the workflow notes instruction — # occupies the canonical slot immediately after ``description``. diff --git a/tests/pipeline/compiler/test_compile_flow_memory.py b/tests/pipeline/compiler/test_compile_flow_memory.py new file mode 100644 index 00000000..e4b372de --- /dev/null +++ b/tests/pipeline/compiler/test_compile_flow_memory.py @@ -0,0 +1,493 @@ +"""Contract and logic tests for the ``compile_flow`` memory emission (step 4.9). + +Covers the memory half of the compiler: when the supplied workflow carries +memory participation, ``compile_flow`` assembles the top-level ``memory`` block +of the flow-file and the per-stage memory keys — + +- the block is emitted if and only if at least one stage participates: a + ``reflect`` instruction under the reflect method (the default), or a true + ``memory`` instruction under the alignment method; a memory configuration + alone never turns the block on (silent no-op, cases 3 and 5); +- participation is counted over the WORKING body — after skip removal and loop + expansion, embedded extend-stages included (a skipped stage's instructions + never count); +- the emitted path composes the fixed memory root ``.goga/memory`` with the + authored suffix (the bare root when the suffix is ``None``); +- reflect method — ``mode`` and ``memory_use`` stay ``None`` (omitted from the + output); alignment method — ``mode`` the materialized value and + ``memory_use: true``; +- reflect method — a participating stage carries ``reflect: {file, mode}`` + (file verbatim, mode materialized); alignment method — EVERY stage carries + ``memory_use`` (explicit ``false`` on every non-participating one, because + afm's ``UseFor(stage)`` inherits the global default for an unset key); +- both keys occupy the canonical slots immediately after ``script_timeout`` and + are uniform across every loop-expanded copy; +- the goga-side method selector never reaches the output; +- an authoring ``reflect`` / ``memory_use`` key in a stage body (pipeline-file + stage OR embedded extend-stage body) is a structural error; +- a workflow without memory participation compiles byte-identically to the + current output (no block, no stage keys); +- output-side only — the ``PipelineDocument`` mirror stays the faithful mirror + of the source pipeline-file. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import re +from pathlib import Path + +import pytest +from goga.pipeline.compiler import ( + FlowDocument, + FlowMemory, + PipelineDocument, + StructuralError, + compile_flow, +) +from goga.pipeline.compiler.compile_flow import ( + _CANONICAL_KEY_ORDER, + _assemble_memory_keys, + _canonical_fields, + _effective_overrides, + _memory_emission, + _MemoryEmission, +) +from goga.pipeline.workflow import ( + WorkflowDocument, + WorkflowExtendStage, + WorkflowMemory, + WorkflowReflect, + WorkflowStage, + parse_workflow, +) + +# Base STAGES-format pipeline-file for the compiler tests — three stages, +# verbatim from the design. +_BASE_STAGES = ( + "name: demo\n" + "description: Demo pipeline\n" + "---\n" + "brainstorm:\n" + " title: Brainstorm\n" + " prompt: Think\n" + "build:\n" + " title: Build\n" + " prompt: Make\n" + "review:\n" + " title: Review\n" + " prompt: Check\n" +) + +# Base PHASES-format pipeline-file — the list body derives ``depends_on`` by +# position, pinning that memory emission never disturbs the position rules. +_BASE_PHASES = ( + "name: demo\n" + "description: Demo pipeline\n" + "---\n" + "- name: brainstorm\n" + " title: Brainstorm\n" + " prompt: Think\n" + "- name: build\n" + " title: Build\n" + " prompt: Make\n" +) + + +def _compile( + tmp_path: Path, + pipeline_text: str, + workflow_text: str | None = None, +) -> tuple[PipelineDocument, FlowDocument, str]: + """Write the pipeline (and optional workflow), compile, return documents + text. + + Mirrors the ``_compile`` helper of ``test_compile_flow_timeout.py`` but also + returns the documents tuple so the memory tests can assert on the assembled + ``FlowDocument`` and its stages. + """ + pipeline_path = tmp_path / "pipeline.yml" + pipeline_path.write_text(pipeline_text) + flow_path = tmp_path / "flow.yml" + + workflow = None + + if workflow_text is not None: + workflow_path = tmp_path / "workflow.yml" + workflow_path.write_text(workflow_text) + workflow = parse_workflow(workflow_path) + + pipeline_doc, flow_doc = compile_flow(pipeline_path, flow_path, workflow=workflow) + + return pipeline_doc, flow_doc, flow_path.read_text() + + +class TestCompileFlowMemoryContract: + """Contract tests — the memory surface declared by the compiler CODEMANIFEST.""" + + def test_compile_flow_signature_unchanged_no_memory_parameter(self) -> None: + """``compile_flow`` keeps its five parameters — memory travels inside ``WorkflowDocument``.""" + parameters = list(inspect.signature(compile_flow).parameters) + + assert parameters == ["pipeline_path", "flow_path", "workflow", "root_dir", "project_name"] + + def test_memory_helpers_not_on_facade(self) -> None: + """``_memory_emission`` & co. are module-internal, not facade names.""" + from goga.pipeline.compiler import __all__ as facade_all + + assert "_memory_emission" not in facade_all + assert "_MemoryEmission" not in facade_all + assert "_assemble_memory_keys" not in facade_all + assert "_MEMORY_ROOT" not in facade_all + + def test_memory_helpers_exist_in_module(self) -> None: + """The step-4.9 helpers exist with the contract shape.""" + from goga.pipeline.compiler.compile_flow import _MEMORY_ROOT + + assert _MEMORY_ROOT == ".goga/memory" + assert callable(_memory_emission) + assert callable(_assemble_memory_keys) + assert [f.name for f in dataclasses.fields(_MemoryEmission)] == ["block", "keys_by_id"] + + def test_canonical_key_order_ends_with_memory_slots(self) -> None: + """``reflect`` and ``memory_use`` close the canonical order after ``script_timeout``.""" + assert _CANONICAL_KEY_ORDER[-6:] == [ + "script_before", + "script", + "script_after", + "script_timeout", + "reflect", + "memory_use", + ] + assert _CANONICAL_KEY_ORDER.index("reflect") == _CANONICAL_KEY_ORDER.index("script_timeout") + 1 + assert _CANONICAL_KEY_ORDER.index("memory_use") == _CANONICAL_KEY_ORDER.index("reflect") + 1 + + def test_canonical_fields_accepts_memory_fields(self) -> None: + """``_canonical_fields`` takes ``(body, stage_name, notes, memory_fields)`` — both optional.""" + parameters = list(inspect.signature(_canonical_fields).parameters) + + assert parameters == ["body", "stage_name", "notes", "memory_fields"] + assert inspect.signature(_canonical_fields).parameters["notes"].default is None + assert inspect.signature(_canonical_fields).parameters["memory_fields"].default is None + + +class TestCompileFlowMemoryEmission: + """Step 4.9 / 5 / 6 — block emission and stage-key assembly across the six cases.""" + + def test_compile_flow_no_block_with_reflect_instructions_emits_block(self, tmp_path: Path) -> None: + """Emission case 2 — instructions with no authored block emit it from the materialized defaults.""" + _pipeline_doc, flow_doc, text = _compile( + tmp_path, + _BASE_STAGES, + "stages:\n brainstorm:\n reflect:\n file: shared.md\n", + ) + + assert flow_doc.memory == FlowMemory(path=".goga/memory", max_rules=25, commit=False) + assert "memory:" in text + assert "path: .goga/memory" in text + assert "mode:" not in text.split("stages:")[0] + assert "max_rules: 25" in text + assert "commit: false" in text + assert " reflect:" in text + assert "file: shared.md" in text + assert flow_doc.stages[0].fields["reflect"] == {"file": "shared.md", "mode": "rw"} + assert flow_doc.stages[1].fields.get("reflect") is None + assert flow_doc.stages[2].fields.get("reflect") is None + + def test_compile_flow_alignment_emits_block_and_marks_every_stage(self, tmp_path: Path) -> None: + """Emission case 4 — alignment marks participating stages and opts the rest out explicitly.""" + workflow_text = ( + "memory:\n" + " method: alignment\n" + " path: goga-development\n" + "stages:\n" + " brainstorm:\n" + " memory: true\n" + " build:\n" + " memory: true\n" + ) + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory == FlowMemory( + path=".goga/memory/goga-development", + mode="rw", + memory_use=True, + max_rules=25, + commit=False, + ) + assert flow_doc.stages[0].fields["memory_use"] is True + assert flow_doc.stages[1].fields["memory_use"] is True + assert flow_doc.stages[2].fields["memory_use"] is False + assert all("reflect" not in stage.fields for stage in flow_doc.stages) + assert "memory_use: false" in text + + def test_compile_flow_alignment_authored_mode_carries_verbatim(self, tmp_path: Path) -> None: + """An authored alignment ``mode`` reaches the block verbatim (materialization is the fallback).""" + workflow_text = ( + "memory:\n" + " method: alignment\n" + " path: p\n" + " mode: r\n" + "stages:\n" + " build:\n" + " memory: true\n" + ) + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory is not None + assert flow_doc.memory.mode == "r" + assert "mode: r" in text + + def test_compile_flow_reflect_slot_after_script_timeout(self, tmp_path: Path) -> None: + """The stage ``reflect`` key occupies the canonical slot immediately after ``script_timeout``.""" + pipeline_text = ( + "name: demo\n" + "description: Demo pipeline\n" + "---\n" + "build:\n" + " title: Build\n" + " script: make build\n" + " timeout: 5m\n" + ) + _pipeline_doc, flow_doc, _text = _compile( + tmp_path, + pipeline_text, + "stages:\n build:\n reflect:\n file: shared.md\n", + ) + + fields = flow_doc.stages[0].fields + + assert list(fields).index("script_timeout") < list(fields).index("reflect") + + def test_compile_flow_reflect_uniform_across_loop_copies(self, tmp_path: Path) -> None: + """Every loop-expanded copy ``NAME-i`` carries the same reflect instruction as its base.""" + _pipeline_doc, flow_doc, _text = _compile( + tmp_path, + _BASE_STAGES, + "stages:\n brainstorm:\n loop: 3\n reflect:\n file: shared.md\n", + ) + + copies = [stage for stage in flow_doc.stages if stage.id.startswith("brainstorm")] + + assert len(copies) == 3 + for stage in copies: + assert stage.fields["reflect"] == {"file": "shared.md", "mode": "rw"} + + def test_compile_flow_phases_reflect_emits_block_and_stage_keys(self, tmp_path: Path) -> None: + """The PHASES list body emits the block and the stage keys identically — format-agnostic.""" + workflow_text = "stages:\n brainstorm:\n reflect:\n file: shared.md\n" + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_PHASES, workflow_text) + + assert flow_doc.memory == FlowMemory(path=".goga/memory", max_rules=25, commit=False) + assert flow_doc.stages[0].fields["reflect"] == {"file": "shared.md", "mode": "rw"} + assert flow_doc.stages[0].depends_on is None + assert flow_doc.stages[1].fields.get("reflect") is None + assert flow_doc.stages[1].depends_on == ["brainstorm"] + assert " reflect:" in text + + def test_compile_flow_reflect_applies_to_extend_stage_by_name(self, tmp_path: Path) -> None: + """An embedded extend-stage participates through its ``stages``-block entry (by name).""" + workflow_text = ( + "stages:\n" + " extra:\n" + " reflect:\n" + " file: extra.md\n" + "extend:\n" + " extra:\n" + " after:\n" + " - build\n" + " title: Extra\n" + " prompt: extra work\n" + ) + _pipeline_doc, flow_doc, _text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + extra_stage = next(stage for stage in flow_doc.stages if stage.id == "extra") + + assert extra_stage.fields["reflect"] == {"file": "extra.md", "mode": "rw"} + assert flow_doc.memory is not None + others = [stage for stage in flow_doc.stages if stage.id != "extra"] + assert all("reflect" not in stage.fields for stage in others) + + def test_compile_flow_block_without_instructions_is_silent_noop(self, tmp_path: Path) -> None: + """Emission case 3 — a configuration-only block emits nothing at all.""" + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, "memory:\n max_rules: 40\n") + + assert flow_doc.memory is None + assert "memory:" not in text + assert all("reflect" not in stage.fields and "memory_use" not in stage.fields for stage in flow_doc.stages) + + def test_compile_flow_alignment_all_false_is_silent_noop(self, tmp_path: Path) -> None: + """Emission case 5 — alignment with no true instruction is a silent no-op.""" + workflow_text = "memory:\n method: alignment\nstages:\n build:\n memory: false\n" + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory is None + assert "memory:" not in text + assert all("memory_use" not in stage.fields for stage in flow_doc.stages) + + def test_compile_flow_skip_of_only_participating_stage_emits_no_block(self, tmp_path: Path) -> None: + """A stage removed by skip never counts — its instructions die with it (design scenario 3).""" + workflow_text = "stages:\n brainstorm:\n reflect:\n file: a.md\n skip: true\n" + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory is None + assert "memory:" not in text + assert all("reflect" not in stage.fields and "memory_use" not in stage.fields for stage in flow_doc.stages) + + def test_compile_flow_reflect_block_omits_mode_and_memory_use(self, tmp_path: Path) -> None: + """Emission case 6 — the reflect-method block carries exactly path, max_rules, commit.""" + workflow_text = ( + "memory:\n" + " max_rules: 9\n" + " commit: true\n" + "stages:\n" + " brainstorm:\n" + " reflect:\n" + " file: shared.md\n" + ) + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory is not None + assert flow_doc.memory.mode is None + assert flow_doc.memory.memory_use is None + + block_text = text.split("stages:")[0].split("memory:")[1] + + assert "mode:" not in block_text + assert "memory_use:" not in block_text + assert "commit: true" in text + + +class TestCompileFlowMemoryAuthoringProhibition: + """Step 5 — the authoring-forbidden memory keys in any stage body.""" + + @pytest.mark.parametrize( + ("stage_body", "message"), + [ + pytest.param( + " reflect:\n file: a.md\n", + "reflect key is forbidden in stage body; use reflect in workflow.stages", + id="reflect", + ), + pytest.param( + " memory_use: false\n", + "memory_use key is forbidden in stage body; use memory in workflow.stages", + id="memory-use", + ), + ], + ) + def test_compile_flow_rejects_authoring_memory_keys_in_stage_body( + self, + tmp_path: Path, + stage_body: str, + message: str, + ) -> None: + """A pipeline-file stage body cannot author the output-side memory keys.""" + pipeline_path = tmp_path / "pipeline.yml" + pipeline_path.write_text( + "name: demo\ndescription: Demo pipeline\n---\nbuild:\n title: Build\n prompt: Make\n" + stage_body, + ) + + with pytest.raises(StructuralError, match=re.escape(message)): + compile_flow(pipeline_path, tmp_path / "flow.yml") + + def test_compile_flow_rejects_authoring_reflect_in_extend_body(self, tmp_path: Path) -> None: + """An embedded extend-stage body hits the same prohibition (same ``_canonical_fields`` pass).""" + pipeline_path = tmp_path / "pipeline.yml" + pipeline_path.write_text(_BASE_STAGES) + workflow = WorkflowDocument( + extend={ + "extra": WorkflowExtendStage( + after=["build"], + body={"title": "Extra", "reflect": {"file": "a.md"}}, + ), + }, + ) + + with pytest.raises( + StructuralError, + match=re.escape("reflect key is forbidden in stage body; use reflect in workflow.stages"), + ): + compile_flow(pipeline_path, tmp_path / "flow.yml", workflow=workflow) + + +class TestCompileFlowMemoryPlumbing: + """Direct unit tests of the private step-4.9 helpers (the ``notes_by_id`` precedent).""" + + def test_effective_overrides_merged_branch_passes_reflect_and_memory(self) -> None: + """A stage named in both ``extend`` and ``stages`` keeps its participation instructions.""" + workflow = WorkflowDocument( + stages={ + "x": WorkflowStage(reflect=WorkflowReflect(file="a.md"), memory=None), + "y": WorkflowStage(memory=True), + }, + extend={ + "x": WorkflowExtendStage(after=["y"], body={}), + "z": WorkflowExtendStage(after=["x"], body={}), + }, + ) + + effective = _effective_overrides(workflow) + + assert effective["x"].reflect == WorkflowReflect(file="a.md", mode="rw") + assert effective["y"].memory is True + assert effective["z"].reflect is None + assert effective["z"].memory is None + + def test_memory_emission_none_workflow_yields_no_block(self) -> None: + """A ``None`` workflow never participates — no block, no stage keys.""" + emission = _memory_emission(None, {}, {}) + + assert emission.block is None + assert emission.keys_by_id == {} + + def test_memory_emission_default_config_supplies_block_values(self) -> None: + """Reflect instructions with no authored block source the values from ``WorkflowMemory()``.""" + workflow = WorkflowDocument(stages={"build": WorkflowStage(reflect=WorkflowReflect(file="a.md"))}) + effective = _effective_overrides(workflow) + + emission = _memory_emission(workflow, effective, {"build": ["build"]}) + + assert emission.block == FlowMemory(path=".goga/memory", max_rules=25, commit=False) + assert emission.keys_by_id == {"build": {"reflect": {"file": "a.md", "mode": "rw"}}} + + def test_memory_emission_alignment_marks_every_final_id(self) -> None: + """Alignment emits an explicit opt-out on every non-participating final id.""" + alignment = WorkflowDocument( + memory=WorkflowMemory(method="alignment"), + stages={"build": WorkflowStage(memory=True), "review": WorkflowStage()}, + ) + + emission = _memory_emission( + alignment, + _effective_overrides(alignment), + {"build": ["build"], "review": ["review"]}, + ) + + assert emission.keys_by_id == {"build": {"memory_use": True}, "review": {"memory_use": False}} + + def test_memory_emission_no_participation_yields_empty_emission(self) -> None: + """A configuration without participants is a silent no-op (cases 3 and 5).""" + config_only = WorkflowDocument(memory=None) + + emission = _memory_emission(config_only, _effective_overrides(config_only), {}) + + assert emission.block is None + assert emission.keys_by_id == {} + + def test_assemble_memory_keys_noop_on_none_and_empty(self) -> None: + """``None`` and an empty map assemble nothing — a non-participating stage carries no key.""" + source: dict[str, object] = {"prompt": "p"} + + _assemble_memory_keys(source, None) + _assemble_memory_keys(source, {}) + + assert source == {"prompt": "p"} + + def test_assemble_memory_keys_assigns_fresh_values(self) -> None: + """A non-empty map updates the source — the canonical loop then slots the keys.""" + source: dict[str, object] = {"prompt": "p"} + + _assemble_memory_keys(source, {"reflect": {"file": "a.md", "mode": "rw"}}) + + assert source == {"prompt": "p", "reflect": {"file": "a.md", "mode": "rw"}} From 92f5f3148ba85a6cd218912d3b7a904f78b5814a Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 21:49:46 +0000 Subject: [PATCH 153/229] feat: carry the memory configuration verbatim through apply_skip_stages --- goga/pipeline/apply_skip_stages.py | 22 ++++++++--- tests/pipeline/test_apply_skip_stages.py | 47 ++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/goga/pipeline/apply_skip_stages.py b/goga/pipeline/apply_skip_stages.py index 59e255e3..e44e40f7 100644 --- a/goga/pipeline/apply_skip_stages.py +++ b/goga/pipeline/apply_skip_stages.py @@ -7,7 +7,9 @@ name in ``skip_stages`` is applied as a fresh :class:`~goga.pipeline.workflow.WorkflowStage` carrying ``skip=True`` over a copy of the workflow's stages map, so skip always wins over any pre-existing -override for that name. +override for that name. The rebuilt document carries the input's +``prompt``/``extend``/``memory`` — the memory configuration verbatim, as an +opaque value. This Routine is intentionally declarative — it only PREPARES the document the compiler consumes. It does NOT delete stages or rewrite ``depends_on`` (the @@ -32,8 +34,9 @@ def apply_skip_stages(workflow: WorkflowDocument | None, skip_stages: list[str]) COPY of the workflow's stages map, so a name present in both the workflow stages and ``skip_stages`` is replaced wholesale (skip always wins). A NEW :class:`~goga.pipeline.workflow.WorkflowDocument` is returned carrying the - merged map, the input's ``prompt``/``extend`` (or ``None``/empty when the - input is ``None``). The input document and its maps are never touched. + merged map, the input's ``prompt``/``extend``/``memory`` (or + ``None``/empty/``None`` when the input is ``None``). The input document and + its maps are never touched. Requirements: - Empty ``skip_stages`` is a no-op — return the input unchanged @@ -45,7 +48,10 @@ def apply_skip_stages(workflow: WorkflowDocument | None, skip_stages: list[str]) - Do not mutate the input ``workflow`` or its stages/extend maps. - When ``workflow`` is ``None`` and ``skip_stages`` is non-empty, construct a document whose stages map carries only the skip entries - (``prompt=None``, ``extend={}``). + (``prompt=None``, ``extend={}``, ``memory=None``). + - The memory configuration of the input workflow survives the rebuild + verbatim — a rebuild that drops it would silently disable memory + participation for skip-driven runs. - Stage-name validation is NOT performed here — the compiler's strict check raises a structural error on a name absent from the pipeline body; this Routine stays declarative. @@ -59,6 +65,10 @@ def apply_skip_stages(workflow: WorkflowDocument | None, skip_stages: list[str]) Returns: The resulting ``WorkflowDocument`` carrying the skip directives, or the input unchanged when ``skip_stages`` is empty (``None`` stays ``None``). + + Constraints: + - Do not interpret, validate, or rebuild the memory configuration — it + is carried as an opaque value (per the ``memory`` practice). """ # Step 1 — empty skip is a no-op (None stays None; input returned as-is). if not skip_stages: @@ -71,9 +81,11 @@ def apply_skip_stages(workflow: WorkflowDocument | None, skip_stages: list[str]) for name in skip_stages: new_stages[name] = WorkflowStage(skip=True) - # Step 4 — return a NEW document; the input is NOT mutated. + # Step 4 — return a NEW document; the input is NOT mutated. The memory + # configuration rides along verbatim (same object, opaque — never rebuilt). return WorkflowDocument( prompt=workflow.prompt if workflow is not None else None, stages=new_stages, extend=dict(workflow.extend) if workflow is not None else {}, + memory=workflow.memory if workflow is not None else None, ) diff --git a/tests/pipeline/test_apply_skip_stages.py b/tests/pipeline/test_apply_skip_stages.py index 5994fa8c..f1310f73 100644 --- a/tests/pipeline/test_apply_skip_stages.py +++ b/tests/pipeline/test_apply_skip_stages.py @@ -24,6 +24,7 @@ from goga.pipeline.workflow import ( WorkflowDocument, WorkflowExtendStage, + WorkflowMemory, WorkflowStage, ) @@ -48,6 +49,30 @@ def test_apply_skip_stages_return_annotation(self) -> None: acceptable = {"WorkflowDocument | None", WorkflowDocument | None} assert annotation in acceptable + def test_apply_skip_stages_carries_memory_verbatim(self) -> None: + """The rebuild carries the input's memory configuration verbatim. + + The SAME ``WorkflowMemory`` object (not a rebuild) rides into the + returned document — a rebuild that dropped it would silently disable + memory participation for skip-driven runs. + """ + memory = WorkflowMemory(max_rules=7) + workflow = WorkflowDocument( + memory=memory, + stages={"build": WorkflowStage(agent="codex")}, + ) + + result = apply_skip_stages(workflow, ["review"]) + + assert result.memory is memory + + def test_apply_skip_stages_none_input_memory_stays_none(self) -> None: + """A rebuild over a None workflow carries memory=None.""" + result = apply_skip_stages(None, ["x"]) + + assert result is not None + assert result.memory is None + class TestApplySkipStagesLogic: """Logic tests — the verbatim behavior from the design Test Stack Trace.""" @@ -86,6 +111,11 @@ def test_apply_skip_stages_skip_wins_over_existing(self) -> None: assert result.stages["build"].prompt is None assert result.stages["build"].loop is None assert result.stages["build"].skills is None + # The wholesale replacement drops the memory instructions too — the + # WorkflowStage(skip=True) constructor defaults (a skipped stage never + # participates; the compiler removes it before any memory assembly). + assert result.stages["build"].reflect is None + assert result.stages["build"].memory is None def test_apply_skip_stages_none_nonempty_constructs_doc(self) -> None: """A None workflow with non-empty skip constructs a fresh skip-only doc.""" @@ -133,6 +163,23 @@ def test_apply_skip_stages_does_not_mutate_input(self) -> None: assert "test" not in workflow.stages assert set(workflow.stages.keys()) == {"build"} + def test_apply_skip_stages_does_not_mutate_memory(self) -> None: + """The input's memory configuration and stages map survive the rebuild.""" + workflow = WorkflowDocument( + memory=WorkflowMemory(max_rules=7), + stages={"build": WorkflowStage(agent="codex")}, + ) + + result = apply_skip_stages(workflow, ["review"]) + + # The rebuild carries the memory verbatim; the input keeps its own. + assert result.memory == WorkflowMemory(max_rules=7) + assert workflow.memory == WorkflowMemory(max_rules=7) + # The input stages map is unchanged. + assert set(workflow.stages.keys()) == {"build"} + assert workflow.stages["build"].agent == "codex" + assert workflow.stages["build"].skip is False + def test_apply_skip_stages_does_not_validate_names(self) -> None: """An unknown skip name is accepted — validation is the compiler's job.""" workflow = WorkflowDocument(prompt="P", stages={"build": WorkflowStage(agent="codex")}) From 4ea7bf33a697915073c9ae1d05a6ffab9540e692 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 21:53:59 +0000 Subject: [PATCH 154/229] feat: add cross-cell integration tests for the workflow-memory surface --- .../test_compile_flow_memory_integration.py | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 tests/pipeline/compiler/test_compile_flow_memory_integration.py diff --git a/tests/pipeline/compiler/test_compile_flow_memory_integration.py b/tests/pipeline/compiler/test_compile_flow_memory_integration.py new file mode 100644 index 00000000..9217d7e4 --- /dev/null +++ b/tests/pipeline/compiler/test_compile_flow_memory_integration.py @@ -0,0 +1,300 @@ +"""Cross-cell integration tests for the workflow-memory surface. + +The cell-local memory tests (``test_parse_workflow_memory.py``, +``test_serialize_flow_memory_slot.py``, ``test_compile_flow_memory.py``, +``test_apply_skip_stages.py``) exercise the memory surface of each cell in +isolation. This module layers ON TOP of them the cross-cell scenarios the +design flags as needing separate verification once every coding task is done — +the full chain workflow parse → skip merge → compile → serialize → text: + +- byte-identity — a memory-free workflow compiles to the byte-exact current + output: a golden literal freezes the full flow-file text, so any memory leak + into the output (a block, a stage key, a key-order shift) changes the string + and fails the test; +- CLI skip channel — ``apply_skip_stages`` feeding ``compile_flow`` is + equivalent to the workflow ``skip`` channel (design scenario 3): skipping the + only participating stage disables the block, while skipping a bystander + leaves everyone else's participation intact; +- ``PipelineDocument`` mirror — the memory block and the stage memory keys are + output-side only; the mirror stays the faithful mirror of the source + pipeline-file; +- method-selector absence — the goga-side ``method`` selector never reaches + the output under either method. + +No new production code is exercised here that the cell-local tests do not +already cover — this module pins that the three cells COMPOSE. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from goga.pipeline import apply_skip_stages +from goga.pipeline.compiler import FlowDocument, PipelineDocument, compile_flow +from goga.pipeline.workflow import WorkflowDocument, parse_workflow + +# Base STAGES-format pipeline-file for the integration tests — three stages, +# verbatim from the design. +_BASE_STAGES = ( + "name: demo\n" + "description: Demo pipeline\n" + "---\n" + "brainstorm:\n" + " title: Brainstorm\n" + " prompt: Think\n" + "build:\n" + " title: Build\n" + " prompt: Make\n" + "review:\n" + " title: Review\n" + " prompt: Check\n" +) + +# The golden byte-exact output of the memory-free compile: the workflow carries +# only a top-level prompt, so the flow-file carries NO memory block and NO +# reflect / memory_use stage key — exactly the pre-memory output. Frozen from a +# single run of the memory-free compile (the main backward-compatibility +# invariant: a workflow without memory participation compiles byte-identically). +_GOLDEN_MEMORY_FREE = ( + "prompt: |-\n" + " P\n" + "name: demo\n" + "description: Demo pipeline\n" + "stages:\n" + "- id: brainstorm\n" + " name: Brainstorm\n" + " prompt: Think\n" + " agents: [auto]\n" + "- id: build\n" + " name: Build\n" + " prompt: Make\n" + " agents: [auto]\n" + "- id: review\n" + " name: Review\n" + " prompt: Check\n" + " agents: [auto]\n" +) + +# A reflect-method workflow that participates (emission case 6 — a block with +# a reflect instruction): the block carries path / max_rules / commit only. +_REFLECT_WORKFLOW = ( + "memory:\n" + " max_rules: 40\n" + "stages:\n" + " brainstorm:\n" + " reflect:\n" + " file: shared.md\n" +) + +# An alignment-method workflow that participates (emission case 4): the block +# carries the composed path, the materialized mode, and memory_use: true. +_ALIGNMENT_WORKFLOW = ( + "memory:\n" + " method: alignment\n" + " path: goga-development\n" + "stages:\n" + " brainstorm:\n" + " memory: true\n" +) + + +def _write(tmp_path: Path, name: str, text: str) -> Path: + """Write ``text`` to ``tmp_path / name`` and return the path. + + Args: + tmp_path: The pytest temporary directory of the test. + name: The file name to write under. + text: The file content. + + Returns: + The path of the written file. + """ + path = tmp_path / name + path.write_text(text) + return path + + +def _compile( + tmp_path: Path, + pipeline_text: str, + workflow_text: str | None = None, +) -> tuple[PipelineDocument, FlowDocument, str]: + """Write the pipeline (and optional workflow), compile, return documents + text. + + Drives the real parser→compiler handoff: the workflow-file is parsed by + ``parse_workflow`` and the resulting document handed to ``compile_flow``, + so the assertions cover the composed surface, not a hand-built document. + + Args: + tmp_path: The pytest temporary directory of the test. + pipeline_text: The pipeline-file content to compile. + workflow_text: The optional workflow-file content; ``None`` compiles + without a workflow. + + Returns: + The ``(PipelineDocument, FlowDocument, text)`` triple — the documents + returned by ``compile_flow`` plus the compiled flow-file text. + """ + workflow: WorkflowDocument | None = None + + if workflow_text is not None: + workflow = parse_workflow(_write(tmp_path, "workflow.yml", workflow_text)) + + pipeline_path = _write(tmp_path, "pipeline.yml", pipeline_text) + flow_path = tmp_path / "flow.yml" + + pipeline_doc, flow_doc = compile_flow(pipeline_path, flow_path, workflow=workflow) + + return pipeline_doc, flow_doc, flow_path.read_text() + + +class TestMemoryFreeByteIdentity: + """The byte-identity gate — a memory-free workflow compiles byte-identically.""" + + def test_compile_flow_memory_free_workflow_compiles_byte_identically(self, tmp_path: Path) -> None: + """A workflow with only a prompt compiles to the exact pre-memory output. + + The workflow-file carries nothing but ``prompt: "P"`` — no memory block, + no participation instruction — so ``compile_flow`` must produce the same + bytes as before the feature existed: the golden literal pins the full + flow-file text (prompt block-literal first, then name, description, and + the three stages with their ``agents: [auto]`` lines, and nothing else). + Any memory leak into the output — a ``memory:`` block, a stage + ``reflect`` / ``memory_use`` key, or a key-order shift — changes the + string and fails the test. + """ + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, 'prompt: "P"\n') + + assert text == _GOLDEN_MEMORY_FREE + assert flow_doc.memory is None + assert not any("reflect" in stage.fields or "memory_use" in stage.fields for stage in flow_doc.stages) + + +class TestSkipChannelParity: + """The CLI skip channel — ``apply_skip_stages`` feeding ``compile_flow``. + + The workflow ``skip`` channel is covered cell-locally + (``test_compile_flow_memory.py``); these drive the CLI channel — the + rebuilt ``WorkflowDocument`` from ``apply_skip_stages`` (which must carry + the memory configuration verbatim) handed to ``compile_flow``. + """ + + @pytest.mark.parametrize( + ("skip_stages", "expects_block"), + [ + pytest.param(["brainstorm"], False, id="skip-participating-disables-block"), + pytest.param(["review"], True, id="skip-bystander-keeps-block"), + ], + ) + def test_compile_flow_skip_via_cli_channel_uses_same_path( + self, + tmp_path: Path, + skip_stages: list[str], + expects_block: bool, + ) -> None: + """The CLI skip channel is equivalent to the workflow skip channel. + + The workflow carries a single participating stage (``brainstorm`` with + a reflect instruction). Skipping it through the CLI channel + (``apply_skip_stages`` → ``compile_flow``) removes the stage before the + participation count, so the block is not emitted — design scenario 3. + Skipping a non-participating bystander (``review``) leaves everyone + else's participation intact: the block stays, ``brainstorm`` keeps its + reflect key, and ``review`` disappears from the output. + """ + workflow_text = "stages:\n brainstorm:\n reflect:\n file: a.md\n" + workflow = parse_workflow(_write(tmp_path, "workflow.yml", workflow_text)) + merged = apply_skip_stages(workflow, skip_stages) + + pipeline_path = _write(tmp_path, "pipeline.yml", _BASE_STAGES) + flow_path = tmp_path / "flow.yml" + + _pipeline_doc, flow_doc = compile_flow(pipeline_path, flow_path, workflow=merged) + text = flow_path.read_text() + + if not expects_block: + assert flow_doc.memory is None + assert "memory:" not in text + assert all("reflect" not in stage.fields for stage in flow_doc.stages) + assert "brainstorm" not in {stage.id for stage in flow_doc.stages} + else: + assert flow_doc.memory is not None + brainstorm = next(stage for stage in flow_doc.stages if stage.id == "brainstorm") + + assert brainstorm.fields["reflect"] == {"file": "a.md", "mode": "rw"} + assert "review" not in {stage.id for stage in flow_doc.stages} + + +class TestPipelineDocumentMirror: + """Output-side only — the ``PipelineDocument`` mirror ignores memory.""" + + @pytest.mark.parametrize( + "workflow_text", + [ + pytest.param(_REFLECT_WORKFLOW, id="reflect"), + pytest.param(_ALIGNMENT_WORKFLOW, id="alignment"), + ], + ) + def test_compile_flow_pipeline_document_unaffected_by_memory( + self, + tmp_path: Path, + workflow_text: str, + ) -> None: + """The memory block and the stage keys never leak into ``PipelineDocument``. + + A participating memory workflow (both methods) compiles, and the + returned ``PipelineDocument`` stays the faithful mirror of the source + pipeline-file: no step body carries the output-side ``reflect`` / + ``memory_use`` keys, and the body equals the body of the same pipeline + compiled WITHOUT a workflow. + """ + pipeline_doc, flow_doc, _text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory is not None + + for step in pipeline_doc.body.steps: + assert "reflect" not in step.body + assert "memory_use" not in step.body + + bare_path = _write(tmp_path, "bare-pipeline.yml", _BASE_STAGES) + bare_doc, _bare_flow_doc = compile_flow(bare_path, tmp_path / "bare-flow.yml") + + assert pipeline_doc.body == bare_doc.body + + +class TestMethodSelectorAbsence: + """The goga-side ``method`` selector never reaches the output.""" + + @pytest.mark.parametrize( + "workflow_text", + [ + pytest.param(_REFLECT_WORKFLOW, id="reflect"), + pytest.param(_ALIGNMENT_WORKFLOW, id="alignment"), + ], + ) + def test_compile_flow_method_selector_never_in_output( + self, + tmp_path: Path, + workflow_text: str, + ) -> None: + """Neither method name nor the selector vocabulary appears in the text. + + Both participating scenarios (reflect and alignment) emit the memory + block, yet the emitted surface carries only the afm vocabulary: no + ``method:`` key, no ``alignment:`` selector value, and no ``reflect`` + or ``memory_use`` at the TOP level (``reflect`` legitimately appears + nested inside stage bodies — the parsed top-level key set pins that). + """ + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory is not None + assert "method:" not in text + assert "alignment:" not in text + + top_level = yaml.safe_load(text) + + assert "reflect" not in top_level + assert "memory_use" not in top_level + assert set(top_level) <= {"name", "description", "memory", "stages"} From 2d412046fa4bf996297a3f77b92b58b0dc737b58 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 22:10:20 +0000 Subject: [PATCH 155/229] fix: address code review findings - Align the reflect-mode domain error with the CODEMANIFEST (6.1.11): name the reflect sub-scope, not the stage, as the domain location - Replace the single-use dispatch wrapper _validate_memory_instruction with the module's approve/notes validator-dispatch idiom - Close compiler test gaps: authored reflect.mode carry-through, the max_rules lower boundary, alignment loop-copy uniformity, alignment skip-of-only-participant, alignment extend-stage participation; strengthen the no-participation unit test to exercise the loop - Document the memory authoring surface in README and the docs site (workflows, pipeline-file, pipelines index, CLI reference) --- README.md | 18 +- docs/cli/pipeline.md | 2 +- docs/pipelines/index.md | 8 +- docs/pipelines/pipeline-file.md | 8 + docs/pipelines/workflows.md | 170 ++++++++++++++++-- goga/pipeline/workflow/parse_workflow.py | 85 ++++----- .../compiler/test_compile_flow_memory.py | 110 +++++++++++- .../workflow/test_parse_workflow_memory.py | 9 +- 8 files changed, 339 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index a39791de..3d532180 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ goga pipeline review # scoped review of code, contracts, docs, then lint goga pipeline sync # sync specifications & tests with the code after changes ``` -Each pipeline is a flat YAML file describing the stages; layer project-specific behavior on top via an optional [workflow](https://qarium.github.io/goga/pipelines/workflows/) file (per-stage agent, additional skills, prompt context, loop expansion, auto-approval, manual stage launch, stage skipping, note buttons, new stages). +Each pipeline is a flat YAML file describing the stages; layer project-specific behavior on top via an optional [workflow](https://qarium.github.io/goga/pipelines/workflows/) file (per-stage agent, additional skills, prompt context, loop expansion, auto-approval, manual stage launch, stage skipping, note buttons, new stages, project-memory participation). **4. Drive the cycle by hand (optional)** — if you want explicit control over each step instead of running a full pipeline, formulate the task and step through each command manually: @@ -226,7 +226,7 @@ A running pipeline executes inside a Docker container, where its flows, run-stat ### Workflows — configure and extend a pipeline -A **workflow-file** (`.goga/workflows/<name>.yml`) configures and extends a compiled pipeline at run time, without touching the pipeline-file. Seven levers, each with a short example. +A **workflow-file** (`.goga/workflows/<name>.yml`) configures and extends a compiled pipeline at run time, without touching the pipeline-file. Eight levers, each with a short example. **`agent` — hire a different agent per stage.** Authoring on `codex`, reviews on `claude`, no pipeline duplication: @@ -299,7 +299,19 @@ stages: fix: Fix the failure and continue ``` -Additionally: `skip: true` removes a stage with transparent reconnection of dependents, and `extend:` adds brand-new stages with `before`/`after` positioning (a new stage's own launch mode is authored in its body via `trigger: manual`). The full model is in the [Workflows](https://qarium.github.io/goga/pipelines/workflows/) documentation. +**`memory` — wire stages into project memory.** A top-level block plus per-stage instructions; the block is emitted only when at least one stage participates: + +```yaml +memory: + method: reflect # or: alignment + max_rules: 40 +stages: + brainstorm: + reflect: # which memory file the stage reflects into + file: shared.md +``` + +Additionally: `skip: true` removes a stage with transparent reconnection of dependents, and `extend:` adds brand-new stages with `before`/`after` positioning (a new stage's own launch mode is authored in its body via `trigger: manual`). The full model is in the [Workflows](https://qarium.github.io/goga/pipelines/workflows/) documentation. Workflow memory requires afm 0.5.60+ (the shipped image carries it). Run with a workflow: diff --git a/docs/cli/pipeline.md b/docs/cli/pipeline.md index ea2e6339..71e7a9f6 100644 --- a/docs/cli/pipeline.md +++ b/docs/cli/pipeline.md @@ -164,7 +164,7 @@ For a run, the decision reaches the container via the env-file (`GOGA_WORKFLOW_N When a workflow will actually be applied to a run (explicit `--workflow`, or an auto-match file that exists), the launcher prints `Pipeline running with workflow "<name>"` to stdout. When no workflow applies, the launcher prints no workflow line. The launcher surfaces only the workflow log line, the `docker` output stream, and any pre-launch version-check warning or refusal on stderr (see [Pre-launch version check](#pre-launch-version-check)). -Inside the container the goga in-container process resolves and parses the workflow-file, then forwards it to the compiler, which reconstructs the parsed body: `extend` entries inject new stages positioned via `before`/`after`, per-stage `agent` overrides compose the in-container wrapper path into the stage's `command` slot, per-stage `prompt` overrides fill its `description` slot, `skip: true` removes the stage and reconnects its dependents' `depends_on`, a `loop: N` (N ≥ 2) expands the stage into `NAME-1`..`NAME-N` copies with chained internal `depends_on` (external references are rewritten to the LAST expanded id), and `manual: true|false` forces or cancels the stage's manual launch mode (compiling to the afm `auto_run` key). +Inside the container the goga in-container process resolves and parses the workflow-file, then forwards it to the compiler, which reconstructs the parsed body: `extend` entries inject new stages positioned via `before`/`after`, per-stage `agent` overrides compose the in-container wrapper path into the stage's `command` slot, per-stage `prompt` overrides fill its `description` slot, `skip: true` removes the stage and reconnects its dependents' `depends_on`, a `loop: N` (N ≥ 2) expands the stage into `NAME-1`..`NAME-N` copies with chained internal `depends_on` (external references are rewritten to the LAST expanded id), `manual: true|false` forces or cancels the stage's manual launch mode (compiling to the afm `auto_run` key), and a `memory` block with per-stage `reflect` / `memory` instructions emits the afm top-level `memory` block and the per-stage `reflect` / `memory_use` keys (only when at least one stage participates — see [Workflows — Project memory](../pipelines/workflows.md#project-memory-memory-reflect)). Example workflow-file: diff --git a/docs/pipelines/index.md b/docs/pipelines/index.md index e8f82a95..3d725d9f 100644 --- a/docs/pipelines/index.md +++ b/docs/pipelines/index.md @@ -38,7 +38,8 @@ The pipelines layer is split into two authoring surfaces: a compiled pipeline at run time with a top-level prompt, per-stage agent / prompt overrides, loop expansion, stage skipping via `skip`, manual launch via `manual`, note buttons via `notes` (compiled to the afm `buttons` - field), and new stages declared via `extend`. + field), new stages declared via `extend`, and project-memory participation + via the `memory` block and the per-stage `reflect` / `memory` instructions. Authored per project; lives in `.goga/workflows/<name>.yml` (project-only). A pipeline-file answers **what** the pipeline does. A workflow answers @@ -85,7 +86,10 @@ the afm auto-approval effects (interactive suppression and/or cancels the stage's manual launch mode (a stage-body `trigger: manual` compiles to the afm `auto_run: false` key — the stage pauses until launched). A per-stage `notes` map compiles verbatim to the afm `buttons` -field (note buttons). The pipeline-file itself can also carry +field (note buttons). A workflow `memory` block plus per-stage `reflect` / +`memory` instructions compile to the afm top-level `memory` block and the +per-stage `reflect` / `memory_use` keys — emitted only when at least one +stage participates. The pipeline-file itself can also carry `before_script` / `script` / `after_script` shell directives on any stage — compiled to the afm `script_*` keys — and a `timeout` directive (Go duration string) that compiles to the afm `script_timeout` key and bounds diff --git a/docs/pipelines/pipeline-file.md b/docs/pipelines/pipeline-file.md index dcb5d0b4..60f702ca 100644 --- a/docs/pipelines/pipeline-file.md +++ b/docs/pipelines/pipeline-file.md @@ -134,6 +134,12 @@ assigned semantics: > key at all: it is assembled by the compiler from a workflow `notes` > instruction (see [Workflows — Note buttons](workflows.md#note-buttons-notes)). > Authoring `buttons` in a stage body is rejected with a structural error. +> +> Symmetrically, the memory keys `reflect` and `memory_use` have no +> pipeline-file authoring key: they are assembled by the compiler from the +> workflow memory instructions (see +> [Workflows — Project memory](workflows.md#project-memory-memory-reflect)). +> Authoring either in a stage body is rejected with a structural error. | Field | Type | Default | Description | |---------------|------------------|-----------------------------|------------------------------------------------------------------------------| @@ -452,6 +458,8 @@ workflow-agent semantics. | Authoring `interactive` in a stage body | `interactive key is forbidden in stage body; use communication` | | Authoring `auto_run` in a stage body | `auto_run key is forbidden in stage body; use trigger: manual` | | Authoring `buttons` in a stage body | `buttons key is forbidden in stage body; use notes in workflow.stages` | +| Authoring `reflect` in a stage body | `reflect key is forbidden in stage body; use reflect in workflow.stages` | +| Authoring `memory_use` in a stage body | `memory_use key is forbidden in stage body; use memory in workflow.stages` | | `trigger` value outside `on_success`/`manual` | `trigger must be one of: on_success, manual` | | `timeout` value is not a string (including YAML-null) | `timeout must be a string in stage <NAME>` | | `timeout` without `script` in the same body | `timeout requires script in stage <NAME>` | diff --git a/docs/pipelines/workflows.md b/docs/pipelines/workflows.md index e6705cd9..30615b77 100644 --- a/docs/pipelines/workflows.md +++ b/docs/pipelines/workflows.md @@ -6,7 +6,10 @@ top-level prompt, override the agent or prompt of specific stages, expand a stage into N chained copies via `loop`, **skip (delete) a stage**, declare per-stage **auto-approval** via `approve`, force or cancel a stage's **manual launch mode** via `manual`, attach **note buttons** to a stage via -`notes`, and **declaratively add new stages** to the pipeline via `extend`. +`notes`, **declaratively add new stages** to the pipeline via `extend`, and +configure **project-memory participation** via the top-level `memory` block +and the per-stage `reflect` / `memory` instructions (see +[Project memory (`memory`, `reflect`)](#project-memory-memory-reflect)). Stage names in `workflow.stages` are matched strictly: a name that does not match any pipeline step or extend-stage is a compile error. Workflows that @@ -24,7 +27,7 @@ traversal via `..` or an absolute prefix is rejected. ## Document shape -A workflow-file is a YAML mapping with up to three top-level keys: +A workflow-file is a YAML mapping with up to four top-level keys: ```yaml prompt: | @@ -40,6 +43,13 @@ stages: approve: auto # optional auto-approval directive: auto | plan | dialog notes: # optional note buttons compiled to the afm `buttons` field fix: Fix the failure and continue + reflect: # optional memory-reflection instruction (reflect method) + file: shared.md + memory: true # optional memory participation (alignment method) + +memory: # optional workflow-memory configuration block + method: reflect # reflect | alignment (the instruction vocabulary selector) + max_rules: 25 # optional rule cap (>= 1) extend: <new-stage-name>: @@ -54,17 +64,18 @@ extend: | `prompt` | string | no* | Top-level prompt emitted as the first key of the output. | | `stages` | map | no* | Per-stage override instructions keyed by stage name. | | `extend` | map | no* | New stages to add to the pipeline, keyed by new stage name. | +| `memory` | map | no* | Workflow-memory configuration (see [Project memory](#project-memory-memory-reflect)). | -\* At least one of `prompt`, a non-empty `stages` block, or a non-empty -`extend` block must be present; an empty workflow is rejected with a -structural error. +\* At least one of `prompt`, a non-empty `stages` block, a non-empty +`extend` block, or a `memory` block must be present; an empty workflow is +rejected with a structural error. Unknown top-level keys are rejected with -`unknown key in workflow: <KEY>; valid keys: prompt, stages, extend`. +`unknown key in workflow: <KEY>; valid keys: prompt, stages, extend, memory`. ## Stage entries -Each entry under `stages` is keyed by stage name and accepts up to eight +Each entry under `stages` is keyed by stage name and accepts up to ten fields: | Field | Type | Default | Description | @@ -77,12 +88,14 @@ fields: | `approve` | string | — | Auto-approval directive. Accepted values are `auto`, `plan`, and `dialog`; any other value (or a non-string) is a structural error. Each value drives a subset of two INDEPENDENT effects the compiler applies to the stage body (see [Auto-approval (`approve: auto/plan/dialog`)](#auto-approval-approve-auto-plan-dialog)): (1) **communication effect** — if the body has `communication: true`, the stage's `interactive` output is SUPPRESSED (omitted, not `false`); (2) **roles effect** — if the body's raw `roles` contain `planner`, the stage emits `auto_approve: true`. `auto` drives BOTH effects; `plan` drives only the communication effect; `dialog` drives only the roles effect. Allowed in both `stages` and `extend` (inline default override; a `stages` entry wins per-field). | | `manual` | bool | — | Manual-launch instruction, `stages` block only. `true` forces the manual launch mode: the compiler emits `auto_run: false` for the stage, overriding any authored `trigger` in its body. `false` cancels a manual state coming from either body source (a pipeline-file `trigger: manual` or an extend body `trigger: manual`) and is a structural error (`manual: false on non-manual stage <NAME>`) when the stage is not manual. An absent key means no instruction — the stage's own `trigger` decides. The three states (`true`/`false`/absent) are distinct; a non-bool value (including `null`) is a structural error. Allowed ONLY in `stages` — it is a structural error under `extend` (a new stage's launch mode is authored in its body via `trigger`). `skip` wins over `manual`: a skipped stage is removed before the manual instruction is applied. See [Manual launch (`manual` and `trigger`)](#manual-launch-manual-and-trigger). | | `notes` | map of str→str | — | Note buttons — a map of "note name → prompt text" compiled verbatim into the stage's afm `buttons` field (canonical slot after `description`). Single-line texts serialize as plain scalars, multi-line texts as block literals. An empty map equals absence (no `buttons` key emitted). Allowed ONLY in `stages` — it is a structural error under `extend` (an extend-stage receives its buttons through the `stages` block by name). Every `loop`-expanded copy carries the same buttons; `skip` wins over `notes`. Interpretation of the buttons belongs to afm — the compiler only assembles and serializes the field. See [Note buttons (`notes`)](#note-buttons-notes). | +| `reflect` | map | — | Memory-reflection instruction (reflect method): `{file: <path>, mode: r\|w\|rw}` telling afm which memory file the stage reflects into. `file` is required and must be a relative, non-escaping path shape; `mode` defaults to `rw`. Allowed ONLY in `stages` — it is a structural error under `extend`. See [Project memory (`memory`, `reflect`)](#project-memory-memory-reflect). | +| `memory` | bool | — | Memory-participation instruction (alignment method): `true` marks the stage as participating in project memory. An explicit `false` equals absence. Allowed ONLY in `stages` — it is a structural error under `extend`. See [Project memory (`memory`, `reflect`)](#project-memory-memory-reflect). | Rules: -- Only `agent`, `prompt`, `loop`, `skills`, `skip`, `approve`, `manual`, `notes` are valid. An +- Only `agent`, `prompt`, `loop`, `skills`, `skip`, `approve`, `manual`, `notes`, `reflect`, `memory` are valid. An unknown key is rejected with `unknown key in workflow.stages.<NAME>: <KEY>; valid keys: - agent, prompt, loop, skills, skip, approve, manual, notes`. + agent, prompt, loop, skills, skip, approve, manual, notes, reflect, memory`. - `loop` must be an int `>= 1`. Zero, negative values, and non-int types raise a structural error. - `skills` must be a `list[str]`. A non-list (or a list with non-string @@ -106,6 +119,15 @@ Rules: workflow.stages.<NAME>.notes.<KEY>`. An empty map is treated as absence. `notes` is allowed only in the `stages` block — it is a structural error under `extend` (see [Note buttons (`notes`)](#note-buttons-notes)). +- `reflect` (when present) must be a mapping with a key set within + `{file, mode}`; `file` is required and must be a valid path shape, `mode` + one of `r`/`w`/`rw`. `reflect` is allowed only in the `stages` block — + it is a structural error under `extend` (see + [Project memory (`memory`, `reflect`)](#project-memory-memory-reflect)). +- `memory` (when present) must be a bool; an explicit `false` equals absence. + `memory` is allowed only in the `stages` block — it is a structural error + under `extend` (see + [Project memory (`memory`, `reflect`)](#project-memory-memory-reflect)). - The stage value must be a mapping. Non-mapping values raise `non-mapping stage <NAME> in workflow.stages`. - Stage names are validated against the target pipeline: a name that does not @@ -345,6 +367,71 @@ stages: - Interpretation of the buttons belongs to afm — the compiler only assembles and serializes the field. +### Project memory (`memory`, `reflect`) + +A workflow can wire its stages into the runner's **project memory**: a +top-level `memory` block selects the method and the block-level settings, +and the per-stage instructions mark which stages participate. + +```yaml +memory: + method: reflect # reflect (default) | alignment + max_rules: 40 # rule cap, >= 1, default 25 + commit: false # whether memory changes are committed + +stages: + brainstorm: + reflect: # reflect method: which file the stage reflects into + file: shared.md + mode: rw # r | w | rw, default rw + build: + memory: true # alignment method: the stage participates +``` + +The top-level block accepts five keys: + +| Key | Type | Default | Description | +|-------------|--------|------------|--------------------------------------------------------------------------------| +| `method` | string | `reflect` | The instruction vocabulary: `reflect` pairs with the per-stage `reflect` instruction, `alignment` with the per-stage `memory` instruction. Never part of any output. | +| `path` | string | — | Suffix inside the fixed memory root `.goga/memory` (the emitted `path` is the root joined with it). Must be a relative, non-escaping path shape. | +| `max_rules` | int | `25` | The maximum number of memory rules (`>= 1`). | +| `commit` | bool | `false` | Whether memory changes are committed. | +| `mode` | string | `rw` under `alignment` | The project-memory access mode (`r`/`w`/`rw`). Authored ONLY under `method: alignment` — an authored `mode` together with `method: reflect` is a structural error. | + +Behavior rules: + +- **The method selects the instruction vocabulary.** Under `reflect` (the + default when no block is authored) a stage participates by carrying a + `reflect: {file, mode?}` instruction; under `alignment` it participates by + carrying `memory: true`. The two vocabularies never mix: a `reflect` + instruction under `alignment`, or a `memory: true` instruction under + `reflect` (including with no block at all), is a structural error. +- **The block is emitted iff at least one stage participates.** A memory + configuration without participation is a silent no-op — no block, no stage + keys, not even an opting-out stage key. A workflow consisting of the + `memory` block alone is still valid (it counts as content). +- Participation is counted over the **working body** — after skip removal + and loop expansion, embedded extend-stages included. A skipped stage's + instructions die with it: skipping the only participating stage disables + the block entirely. Every `loop`-expanded copy carries the same memory + keys as its original. +- In the compiled flow-file the block lands between `description` and + `stages` with the key order `path, mode, memory_use, max_rules, commit`; + the emitted `path` is always the fixed root `.goga/memory` joined with the + authored suffix. Under the reflect method `mode` and `memory_use` are + omitted entirely; under alignment every stage carries an explicit + `memory_use` key (`true` on participants, `false` on everyone else — afm + inherits the global default for an unset key, so the compiler never + leaves one unset). +- Both instructions are allowed ONLY in the `stages` block — under `extend` + they are structural errors. A new stage participates through a + `stages`-block entry authored under its name. The compiled keys (`reflect` + / `memory_use`) are likewise forbidden in any stage body — authoring + either in a pipeline-file stage or an extend body is a structural error. +- The emitted keys are interpreted by afm (the shipped image carries + afm 0.5.60+, which the memory mechanism requires) — the compiler only + assembles and serializes them. + ## Extending the pipeline with new stages The `stages` block only overrides stages that already exist in the target @@ -524,7 +611,8 @@ compiler reconstructs the parsed body in a fixed sequence of passes **before** building the output stages. The ordering is mandatory: extend-stages are embedded first, stage names are strictly validated and skipped stages removed, then per-stage overrides are applied, then loops are expanded, then external -`depends_on` references are rewritten. Embedding first means a per-stage +`depends_on` references are rewritten, and finally memory participation is +computed over the finished working body. Embedding first means a per-stage override (Pass 1) or loop expansion (Pass 2) can also target a stage introduced by `extend`, by name. @@ -683,6 +771,38 @@ the stage's own agent-mode resolution are independent — the override selects which agent binary runs the stage, while the `roles` field selects how the work is organized inside it. +### Pass 4.9 — Memory emission + +After the working body is final (skip removal, loop expansion, and the +external `depends_on` rewrite have all run), the compiler computes memory +participation from the workflow's effective memory configuration — the +authored `memory` block when present, else the materialized defaults +(`method: reflect`, `max_rules: 25`, `commit: false`). + +- Under the **reflect** method a stage participates when it carries a + `reflect` instruction; under the **alignment** method when it carries + `memory: true`. Participation is looked up per base name in the working + body, so every `loop`-expanded copy inherits its base's verdict and a + skipped stage never counts. +- The top-level `memory` block is emitted **iff at least one stage + participates** — a configuration without participants is a silent no-op + (no block, no stage keys). The block lands between `description` and + `stages`, sources `path`/`max_rules`/`commit` from the effective + configuration (the emitted `path` is the fixed root `.goga/memory` + joined with the authored suffix), and carries `mode`/`memory_use` only + under the alignment method (`mode` the materialized value, + `memory_use: true`). +- Per-stage keys land in the canonical slots right after `script_timeout`: + under reflect every participating stage carries + `reflect: {file, mode}` (the authored file verbatim, the materialized + mode); under alignment EVERY stage carries `memory_use` — `true` on + participants, an explicit `false` on everyone else. +- The compiled keys are output-side only — `PipelineDocument` keeps + mirroring the source pipeline-file, and an authoring `reflect` or + `memory_use` key in any stage body is a structural error. A workflow + without memory participation compiles byte-identically to the same + workflow compiled before the mechanism existed. + ## Invocation modes A pipeline run picks up a workflow in one of three mutually exclusive modes. @@ -825,7 +945,7 @@ untouched — `extend` layers new stages on top at run time. | Root is not a mapping | `workflow must be a mapping` | | `prompt` present but not a string | `non-str value in workflow.prompt` | | `stages` present but not a mapping | `non-mapping stages block in workflow` | -| Unknown top-level key | `unknown key in workflow: <KEY>; valid keys: prompt, stages, extend` | +| Unknown top-level key | `unknown key in workflow: <KEY>; valid keys: prompt, stages, extend, memory` | | Stage value is not a mapping | `non-mapping stage <NAME> in workflow.stages` | | `extend` present but not a mapping | `non-mapping extend block in workflow` | | Extend entry value is not a mapping | `non-mapping extend entry <NAME> in workflow.extend` | @@ -838,7 +958,7 @@ untouched — `extend` layers new stages on top at run time. | Inline `approve` in an extend entry not a string | `non-str value in workflow.extend.<NAME>.approve` | | Inline `approve` in an extend entry not one of `auto`/`plan`/`dialog` | `approve must be one of: auto, plan, dialog in workflow.extend.<NAME>` | | Extend entry has neither `before` nor `after` | `extend entry <NAME> requires at least one of before/after` | -| Unknown per-stage key | `unknown key in workflow.stages.<NAME>: <KEY>; valid keys: agent, prompt, loop, skills, skip, approve, manual, notes` | +| Unknown per-stage key | `unknown key in workflow.stages.<NAME>: <KEY>; valid keys: agent, prompt, loop, skills, skip, approve, manual, notes, reflect, memory` | | `agent` present but not a string | `non-str value in workflow.stages.<NAME>.agent` | | `prompt` present but not a string | `non-str value in workflow.stages.<NAME>.prompt` | | `loop` present but not an int | `non-int value in workflow.stages.<NAME>.loop` | @@ -858,7 +978,31 @@ untouched — `extend` layers new stages on top at run time. | Unknown ref in `workflow.extend.<NAME>.before` | `unknown stage name in workflow.extend.<NAME>.before: <REF>` | | Unknown ref in `workflow.extend.<NAME>.after` | `unknown stage name in workflow.extend.<NAME>.after: <REF>` | | All stages skipped (empty reconstructed body) | `empty body` | -| None of `prompt`, `stages`, `extend` entries are present | `empty workflow — provide at least prompt, one stage, or one extend entry` | +| None of `prompt`, `stages`, `extend`, `memory` entries are present | `empty workflow — provide at least prompt, one stage, one extend entry, or the memory block` | +| `memory` present but not a mapping | `non-mapping memory block in workflow` | +| Unknown key in the `memory` block | `unknown key in workflow.memory: <KEY>; valid keys: method, path, max_rules, commit, mode` | +| `memory.method` not a string | `non-str value in workflow.memory.method` | +| `memory.method` not `reflect`/`alignment` | `method must be one of: reflect, alignment in workflow.memory` | +| `memory.path` not a string | `non-str value in workflow.memory.path` | +| `memory.path` empty, absolute, or containing `..` | `invalid path in workflow.memory.path: <VALUE>` | +| `memory.max_rules` not an int (bool counts as non-int) | `non-int value in workflow.memory.max_rules` | +| `memory.max_rules` an int but `< 1` | `max_rules must be >= 1 in workflow.memory` | +| `memory.commit` not a bool | `non-bool value in workflow.memory.commit` | +| `memory.mode` not a string | `non-str value in workflow.memory.mode` | +| `memory.mode` not `r`/`w`/`rw` | `mode must be one of: r, w, rw in workflow.memory` | +| `memory.mode` authored under `method: reflect` | `mode is forbidden in workflow.memory with method: reflect` | +| `reflect` present but not a mapping | `non-mapping reflect in workflow.stages.<NAME>` | +| Unknown key in a `reflect` instruction | `unknown key in workflow.stages.<NAME>.reflect: <KEY>; valid keys: file, mode` | +| `reflect` without `file` | `file is required in workflow.stages.<NAME>.reflect` | +| `reflect.file` not a string | `non-str value in workflow.stages.<NAME>.reflect.file` | +| `reflect.file` empty, absolute, or containing `..` | `invalid path in workflow.stages.<NAME>.reflect.file: <VALUE>` | +| `reflect.mode` not a string | `non-str value in workflow.stages.<NAME>.reflect.mode` | +| `reflect.mode` not `r`/`w`/`rw` | `mode must be one of: r, w, rw in workflow.stages.<NAME>.reflect` | +| `memory` per-stage instruction not a bool | `non-bool value in workflow.stages.<NAME>.memory` | +| `reflect` authored under `method: alignment` | `reflect is forbidden in workflow.stages.<NAME> with method: alignment` | +| `memory: true` authored under `method: reflect` (or no block) | `memory is forbidden in workflow.stages.<NAME> with method: reflect` | +| `reflect` present under `extend` | `reflect is forbidden in workflow.extend.<NAME>` | +| `memory` present under `extend` | `memory is forbidden in workflow.extend.<NAME>` | ## See also diff --git a/goga/pipeline/workflow/parse_workflow.py b/goga/pipeline/workflow/parse_workflow.py index 5c048275..7313c9f9 100644 --- a/goga/pipeline/workflow/parse_workflow.py +++ b/goga/pipeline/workflow/parse_workflow.py @@ -451,48 +451,14 @@ def _validate_stage_field(name: Any, key: Any, field_value: Any) -> Any: validator = _validate_approve if key == "approve" else _validate_notes return validator(scope, field_value) elif key in ("reflect", "memory"): - return _validate_memory_instruction(name, key, field_value) + # Two scoped single-value validators sharing the stage as their + # location; each validator owns its own message shape. + validator = _build_reflect if key == "reflect" else _validate_memory_instruction + return validator(name, field_value) else: raise WorkflowSyntaxError(f"unknown key in workflow.stages.{name}: {key}; valid keys: {', '.join(_STAGE_KEYS)}") -def _validate_memory_instruction(name: Any, key: Any, field_value: Any) -> WorkflowReflect | bool | None: - """Validate one per-stage memory-participation instruction and return it (normalized). - - Dispatches the two participation instructions of the ``stages`` block: - ``reflect`` delegates to ``_build_reflect`` (key set, required ``file``, - path shape, ``mode`` domain — with the mode materialized to ``"rw"``), - and ``memory`` is strictly a bool whose explicit ``False`` is normalized to - ``None`` — absence and an opting-out instruction are the SAME state, so the - compiler's ``is True`` check never distinguishes them. The instruction's - validity does NOT depend on the workflow's method here — that - correspondence is a separate pass - (``_validate_instruction_correspondence``). - - Args: - name: The stage-name map key (used in error messages). - key: The instruction key — ``"reflect"`` or ``"memory"``. - field_value: The raw instruction value. - - Returns: - The built ``WorkflowReflect`` for ``reflect``, or the bool / ``None`` - participation state for ``memory``. - - Raises: - WorkflowSyntaxError: If ``reflect`` is malformed (see - ``_build_reflect``) or ``memory`` is not a bool. - """ - if key == "reflect": - return _build_reflect(name, field_value) - - # ``memory`` — the participation instruction is strictly a bool, but an - # explicit ``False`` equals absence. - if not isinstance(field_value, bool): - raise WorkflowSyntaxError(f"non-bool value in workflow.stages.{name}.memory") - - return field_value if field_value else None - - def _build_extend(extend_raw: dict[str, Any] | None) -> dict[str, WorkflowExtendStage]: """Validate every ``extend`` entry and build the ``WorkflowExtendStage`` map. @@ -756,7 +722,7 @@ def _build_reflect(name: Any, value: Any) -> WorkflowReflect: elif key == "mode": mode_value = _validate_memory_mode( f"workflow.stages.{name}.reflect.mode", - f"workflow.stages.{name}", + f"workflow.stages.{name}.reflect", field_value, ) else: @@ -770,6 +736,33 @@ def _build_reflect(name: Any, value: Any) -> WorkflowReflect: return WorkflowReflect(file=file_value, mode=mode_value or "rw") +def _validate_memory_instruction(name: Any, field_value: Any) -> bool | None: + """Validate a per-stage ``memory`` instruction and return it (normalized). + + The participation instruction is strictly a bool; an explicit ``False`` is + normalized to ``None`` — absence and an opting-out instruction are the + SAME state, so the compiler's ``is True`` check never distinguishes them. + Whether the instruction matches the workflow's method is NOT decided here + — that correspondence is a separate pass + (``_validate_instruction_correspondence``) running after every stage is + built. + + Args: + name: The stage-name map key (used in error messages). + field_value: The raw ``memory`` instruction value. + + Returns: + The instruction when ``True``, or ``None`` (absence) when ``False``. + + Raises: + WorkflowSyntaxError: If ``field_value`` is not a ``bool``. + """ + if not isinstance(field_value, bool): + raise WorkflowSyntaxError(f"non-bool value in workflow.stages.{name}.memory") + + return field_value if field_value else None + + def _validate_instruction_correspondence( stages: dict[str, WorkflowStage], memory: WorkflowMemory | None, @@ -836,13 +829,13 @@ def _validate_memory_mode(value_location: str, domain_location: str, field_value """Validate a memory ``mode`` value and return it. Shared by the ``memory`` block's ``mode`` key and a ``reflect`` - instruction's ``mode`` key. The two messages deliberately carry different - locations (a CODEMANIFEST asymmetry): the type message names the exact key - (``value_location``, e.g. ``"workflow.memory.mode"`` or - ``"workflow.stages.NAME.reflect.mode"``), while the domain message names - the enclosing container (``domain_location`` — for a reflect instruction - that is the STAGE, ``"workflow.stages.NAME"``, not its ``reflect`` - sub-scope). + instruction's ``mode`` key. The two messages carry different locations: + the type message names the exact key (``value_location``, e.g. + ``"workflow.memory.mode"`` or ``"workflow.stages.NAME.reflect.mode"``), + while the domain message names the mapping the key lives in + (``domain_location`` — ``"workflow.memory"`` or + ``"workflow.stages.NAME.reflect"``), matching the container the authored + value was expected to complete. Args: value_location: The dotted location used in the non-str message. diff --git a/tests/pipeline/compiler/test_compile_flow_memory.py b/tests/pipeline/compiler/test_compile_flow_memory.py index e4b372de..620123bc 100644 --- a/tests/pipeline/compiler/test_compile_flow_memory.py +++ b/tests/pipeline/compiler/test_compile_flow_memory.py @@ -194,6 +194,29 @@ def test_compile_flow_no_block_with_reflect_instructions_emits_block(self, tmp_p assert flow_doc.stages[1].fields.get("reflect") is None assert flow_doc.stages[2].fields.get("reflect") is None + def test_compile_flow_reflect_authored_mode_carries_verbatim(self, tmp_path: Path) -> None: + """An authored reflect ``mode`` reaches the stage key verbatim (materialization is the fallback).""" + _pipeline_doc, flow_doc, text = _compile( + tmp_path, + _BASE_STAGES, + "stages:\n build:\n reflect:\n file: a.md\n mode: r\n", + ) + + assert flow_doc.stages[1].fields["reflect"] == {"file": "a.md", "mode": "r"} + assert "mode: r" in text + + def test_compile_flow_max_rules_boundary_reaches_block(self, tmp_path: Path) -> None: + """The inclusive ``max_rules`` lower boundary (1) reaches the emitted block verbatim.""" + _pipeline_doc, flow_doc, text = _compile( + tmp_path, + _BASE_STAGES, + "memory:\n max_rules: 1\nstages:\n build:\n reflect:\n file: a.md\n", + ) + + assert flow_doc.memory is not None + assert flow_doc.memory.max_rules == 1 + assert "max_rules: 1" in text + def test_compile_flow_alignment_emits_block_and_marks_every_stage(self, tmp_path: Path) -> None: """Emission case 4 — alignment marks participating stages and opts the rest out explicitly.""" workflow_text = ( @@ -273,6 +296,29 @@ def test_compile_flow_reflect_uniform_across_loop_copies(self, tmp_path: Path) - for stage in copies: assert stage.fields["reflect"] == {"file": "shared.md", "mode": "rw"} + def test_compile_flow_alignment_uniform_across_loop_copies(self, tmp_path: Path) -> None: + """Every loop-expanded copy carries its base's ``memory_use`` — participants and opt-outs alike.""" + workflow_text = ( + "memory:\n" + " method: alignment\n" + "stages:\n" + " brainstorm:\n" + " loop: 3\n" + " memory: true\n" + ) + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + copies = [stage for stage in flow_doc.stages if stage.id.startswith("brainstorm")] + bystanders = [stage for stage in flow_doc.stages if not stage.id.startswith("brainstorm")] + + assert flow_doc.memory is not None + assert len(copies) == 3 + for stage in copies: + assert stage.fields["memory_use"] is True + for stage in bystanders: + assert stage.fields["memory_use"] is False + assert "memory_use: false" in text + def test_compile_flow_phases_reflect_emits_block_and_stage_keys(self, tmp_path: Path) -> None: """The PHASES list body emits the block and the stage keys identically — format-agnostic.""" workflow_text = "stages:\n brainstorm:\n reflect:\n file: shared.md\n" @@ -308,6 +354,30 @@ def test_compile_flow_reflect_applies_to_extend_stage_by_name(self, tmp_path: Pa others = [stage for stage in flow_doc.stages if stage.id != "extra"] assert all("reflect" not in stage.fields for stage in others) + def test_compile_flow_alignment_applies_to_extend_stage_by_name(self, tmp_path: Path) -> None: + """An embedded extend-stage participates under alignment through its ``stages`` entry.""" + workflow_text = ( + "memory:\n" + " method: alignment\n" + "stages:\n" + " extra:\n" + " memory: true\n" + "extend:\n" + " extra:\n" + " after:\n" + " - build\n" + " title: Extra\n" + " prompt: extra work\n" + ) + _pipeline_doc, flow_doc, _text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + extra_stage = next(stage for stage in flow_doc.stages if stage.id == "extra") + + assert flow_doc.memory is not None + assert extra_stage.fields["memory_use"] is True + others = [stage for stage in flow_doc.stages if stage.id != "extra"] + assert all(stage.fields["memory_use"] is False for stage in others) + def test_compile_flow_block_without_instructions_is_silent_noop(self, tmp_path: Path) -> None: """Emission case 3 — a configuration-only block emits nothing at all.""" _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, "memory:\n max_rules: 40\n") @@ -334,6 +404,24 @@ def test_compile_flow_skip_of_only_participating_stage_emits_no_block(self, tmp_ assert "memory:" not in text assert all("reflect" not in stage.fields and "memory_use" not in stage.fields for stage in flow_doc.stages) + def test_compile_flow_alignment_skip_of_only_participating_stage_emits_no_block( + self, tmp_path: Path + ) -> None: + """Under alignment a skipped participant dies with its instruction — no block, no keys.""" + workflow_text = ( + "memory:\n" + " method: alignment\n" + "stages:\n" + " build:\n" + " memory: true\n" + " skip: true\n" + ) + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory is None + assert "memory:" not in text + assert all("memory_use" not in stage.fields for stage in flow_doc.stages) + def test_compile_flow_reflect_block_omits_mode_and_memory_use(self, tmp_path: Path) -> None: """Emission case 6 — the reflect-method block carries exactly path, max_rules, commit.""" workflow_text = ( @@ -467,13 +555,25 @@ def test_memory_emission_alignment_marks_every_final_id(self) -> None: assert emission.keys_by_id == {"build": {"memory_use": True}, "review": {"memory_use": False}} def test_memory_emission_no_participation_yields_empty_emission(self) -> None: - """A configuration without participants is a silent no-op (cases 3 and 5).""" - config_only = WorkflowDocument(memory=None) + """A configuration over a working body with no participants is a silent no-op (cases 3 and 5).""" + reflect_config = WorkflowDocument( + memory=WorkflowMemory(max_rules=40), + stages={"build": WorkflowStage(), "review": WorkflowStage()}, + ) + alignment_config = WorkflowDocument( + memory=WorkflowMemory(method="alignment"), + stages={"build": WorkflowStage(), "review": WorkflowStage()}, + ) - emission = _memory_emission(config_only, _effective_overrides(config_only), {}) + for document in (reflect_config, alignment_config): + emission = _memory_emission( + document, + _effective_overrides(document), + {"build": ["build"], "review": ["review"]}, + ) - assert emission.block is None - assert emission.keys_by_id == {} + assert emission.block is None + assert emission.keys_by_id == {} def test_assemble_memory_keys_noop_on_none_and_empty(self) -> None: """``None`` and an empty map assemble nothing — a non-participating stage carries no key.""" diff --git a/tests/pipeline/workflow/test_parse_workflow_memory.py b/tests/pipeline/workflow/test_parse_workflow_memory.py index c6945071..6b2958e2 100644 --- a/tests/pipeline/workflow/test_parse_workflow_memory.py +++ b/tests/pipeline/workflow/test_parse_workflow_memory.py @@ -104,6 +104,13 @@ def test_parse_workflow_empty_memory_block_materializes_defaults(self, tmp_path: assert document.memory.commit is False assert document.memory.mode is None + def test_parse_workflow_max_rules_accepts_lower_boundary(self, tmp_path: Path) -> None: + """``max_rules: 1`` — the inclusive lower boundary — parses.""" + document = _parse(tmp_path, "memory:\n max_rules: 1\n") + + assert document.memory is not None + assert document.memory.max_rules == 1 + def test_parse_workflow_memory_block_absent_leaves_memory_none(self, tmp_path: Path) -> None: """A workflow-file without a ``memory`` block yields ``document.memory is None``.""" document = _parse(tmp_path, "prompt: guidance\nstages:\n build:\n agent: codex\n") @@ -272,7 +279,7 @@ class TestParseWorkflowReflectRejections: ( " reflect:\n file: a.md\n mode: x", "", - "mode must be one of: r, w, rw in workflow.stages.brainstorm", + "mode must be one of: r, w, rw in workflow.stages.brainstorm.reflect", ), (" memory: 1", "memory:\n method: alignment\n", "non-bool value in workflow.stages.brainstorm.memory"), ], From 0f3cb1a54e2eb24f3719b9627c1d22b1f9b82e6f Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Mon, 31 Aug 2026 23:50:39 +0000 Subject: [PATCH 156/229] docs: align workflow extend annotation and _build_stages docstring with strict validation The extend annotation of WorkflowDocument and the _build_stages docstring still claimed unknown names are silently ignored with a warning, while the compiler strictly validates them and raises a structural error. Sync both texts with the actual behavior found during acceptance. --- goga/pipeline/workflow/CODEMANIFEST | 6 ++++-- goga/pipeline/workflow/parse_workflow.py | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/goga/pipeline/workflow/CODEMANIFEST b/goga/pipeline/workflow/CODEMANIFEST index 4517221f..2bd43927 100644 --- a/goga/pipeline/workflow/CODEMANIFEST +++ b/goga/pipeline/workflow/CODEMANIFEST @@ -368,8 +368,10 @@ Annotations: | top-level prompt and no per-stage overrides. `extend`: map of new-stage extend-instructions keyed by stage name; an entry is embedded into the target pipeline by the compiler and - positioned via before/after. Stages in `extend` that reference - unknown names are silently ignored with a warning by the compiler. + positioned via before/after. A before/after ref in `extend` + naming no pipeline stage and no extend-stage is a STRUCTURAL + ERROR at compile time — "unknown stage name in + workflow.extend.<name>.before/.after: <ref>". An empty map (default) means the workflow provides no new stages. `memory`: workflow-memory configuration extracted from the optional top-level memory block, or None when the workflow-file carries diff --git a/goga/pipeline/workflow/parse_workflow.py b/goga/pipeline/workflow/parse_workflow.py index 7313c9f9..aa9a9d51 100644 --- a/goga/pipeline/workflow/parse_workflow.py +++ b/goga/pipeline/workflow/parse_workflow.py @@ -309,8 +309,9 @@ def _build_stages(stages_raw: dict[str, Any] | None) -> dict[str, WorkflowStage] An absent ``stages`` block (``None``) yields an empty map; a present mapping is validated entry by entry via ``_build_stage``. The map key is the stage - name and is NOT validated against any pipeline here — the compiler matches - names and silently ignores unknown ones with a warning. + name and is NOT validated against any pipeline here — the compiler strictly + validates it at compile time and raises a structural error on a name absent + from both the pipeline body and the extend-stages. Args: stages_raw: The raw ``stages`` mapping, or ``None`` when absent. From 3f54a209d2f438043aeb64e3838d566625ef6994 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Tue, 1 Sep 2026 11:55:30 +0300 Subject: [PATCH 157/229] feat: delete memory from gitignore --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 63292d85..f9d798c2 100644 --- a/.gitignore +++ b/.gitignore @@ -225,5 +225,4 @@ docs/design/ docs/superpowers/ # Goga -.goga/history -.goga/memory \ No newline at end of file +.goga/history \ No newline at end of file From 518cdcd22796d4bcc95ce7012d3624f74a65d488 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Tue, 1 Sep 2026 12:40:18 +0300 Subject: [PATCH 158/229] feat: add memory for architecture --- .goga/memory/architecture.md | 109 ++++++++++++++++++++++++++++++++ .goga/workflows/development.yml | 7 +- 2 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 .goga/memory/architecture.md diff --git a/.goga/memory/architecture.md b/.goga/memory/architecture.md new file mode 100644 index 00000000..4ce8d9f6 --- /dev/null +++ b/.goga/memory/architecture.md @@ -0,0 +1,109 @@ +# Project rules + +## Dependency edges target the owner's facade and respect fixed direction + +All interaction with a subsystem's capabilities — code dependencies and documentation alike — targets the owning unit's +public surface. Internal sub-units are never direct dependency targets; nested capabilities publish their contracts at +the owner's level, and reuse happens through the owner's re-export, never by linking into the depths. When a unit +accumulates several functional zones (data, registry, dispatch, access to an external system), it is split into leaf +sub-units by zone, with the main API re-exported on the parent facade; consumers import only the facade. On top of +target choice, direction is part of the same law: dependency direction between domains is fixed and one-way, and a +reverse edge is never introduced, whatever reuse it would buy — it creates a cycle that surfaces too late. When the +fixed direction puts a capability out of reach, the fallback is a consumer-side variant, never an edge shortcut. + +## Single access zone per external system + +All operations that reach one external system inside a domain belong to exactly one structural unit. New capabilities +extend that unit's zone instead of spawning a parallel sibling — even when the extension forces an exception to the +zone's established invariants. Extending a zone never rewrites already published contract fragments: their invariants +stay verbatim, and every new allowance is recorded only in the fragments of the new elements. + +## ADR revision instead of silent deviation + +When implementation reveals that a settled ADR is redundant, the ADR's guarantee is restated through another means +rather than obeyed blindly or violated silently: the revision is explicitly recorded in the plan, the original intent is +preserved by a different mechanism (e.g. a checkpoint contract holding the guarantee instead of an explicit build step), +and routines made redundant by the revision are abolished. Neither letter-following against discovered redundancy nor +unrecorded deviation is acceptable. + +## Data-driven action catalog + +The catalog of addressable actions of an event platform is kept as data: records of (domain, name, error class) held in +the platform, emitted by string address. The contract of each action — the shape of its context, the moment of the +event — is defined by the owning domain, never by the platform. + +## Deferred assembly on first use + +A run-scoped registry is created empty and cheap and is assembled at most once, at the first event emission or +inspection. The guarantee that assembly happens before any output or state change is held by the checkpoint contract ( +events are emitted before any output or state mutation), not by requiring an explicit assembly step in every command. + +## Layered responsibility for external inputs + +The dependency on external configuration lives at the boundary layer: it resolves source precedence — explicit argument +over configuration over built-in default — and passes primitive values inward, keeping inner layers free of +configuration coupling and independently testable. The value provider performs structural validation only (type and +shape), stores values verbatim, embeds no defaults, and checks no semantics — semantic interpretation and defaulting +belong to the consumer. + +## Decisions before mutations, with compensating rollback + +Orchestrating algorithms order every read-only check and validation before the first state change. When a started +mutation sequence fails, every performed mutation is rolled back, exactly one clean error with the root cause is +reported, and a repeated invocation stays safe. Rollback mechanisms belong to the access layer; the decision to roll +back belongs to the caller. + +## Mode-based safety of destructive operations + +Protection in destructive operations comes from explicit prior modes, not from value-based exemptions. A no-execution +report mode previews the full effect before anything is removed; execution itself is unconditional — record attributes +never protect a record from removal. An operation is either unconditional or explicitly scoped by the caller; sparing +modes keyed to the data being destroyed are not invented. + +## Specialization lives with the consumer + +When a domain needs its own variant of a shared capability, the variant is created inside the consumer's zone. A +provider's internal units are never extended to serve one specific consumer — misplacement distorts the ownership map, +and moving code after materialization is a full migration. + +## Stage artifact purity + +A process stage produces only its designated artifact type; transformations belonging to later stages never start early. +A planning stage does not modify implementation artifacts — materialization belongs to the next stage. Mixing planning +with materialization destroys the workflow's guarantees: unreviewed code changes without an approved plan. + +## Additive regression-free extension + +New functionality enters as a new unit beside the existing ones, never as a mode inside an existing unit. Existing +observable behavior, its contracts, and its tests are not edited and do not acquire new dependencies — including reads +of new data sources. Data-model extensions arrive as optional fields with a safe default so every existing construction +site stays valid without edits. Migrating existing functionality onto a new platform follows the same spirit as a +near-rename: domain objects move unchanged, and only the source of registrations changes (the cell emits the platform's +action instead of running its own enumeration mechanism). + +## Closed binding of names in a contract + +Every name declared as an imported dependency must be referenced within the contract's own text, and every mention must +resolve within that same contract: either through a declared dependency or through a locally declared practice (when a +direct dependency is impossible — cycles, unreachability). No dangling declarations, no free-floating mentions — +otherwise the contract cannot stand alone and the implementation cannot be rebuilt from it. References use the +contract's own notation, without procedural phrases about where names come from. + +## Declarative contract level + +Contracts describe observable behavior, not implementation mechanics: low-level command chains and protocol details live +in dedicated practice documents the contract points to, and summaries stay at the level of the responsibility zone +without enumerated details. A contract written this way can be rebuilt from itself without reading the implementation +and does not drift when the mechanics change. + +## Names state their scope + +An operation's name states its exact coverage — never broader than what it does (no implying remote-side effects of a +local-only operation), never narrower. Scope inaccuracy in a name is a contract defect; a rename is applied across all +already produced artifacts so that stages never disagree on names. + +## One document — one behavior domain + +Consumer documentation is structured by behavior domain: a new domain gets its own self-contained document, documents of +unchanged behavior are not edited, and cross-references between sibling documents are not introduced. The set of +documents to touch is decided by this rule, not by the task's original list. diff --git a/.goga/workflows/development.yml b/.goga/workflows/development.yml index 9625a53d..992e17f2 100644 --- a/.goga/workflows/development.yml +++ b/.goga/workflows/development.yml @@ -1,13 +1,14 @@ prompt: | Answer (feedbacks, proposes, questions and etc) in Russian language. +memory: + commit: true + stages: brainstorm: prompt: | Architectural design process. - Use the memory file `.goga/memory/development.md` as project rules if exists. The file is read only. - Requirements: - Annotations describe the high-level order of actions - Every usage file is connected through Imports and referenced in annotations @@ -20,6 +21,8 @@ stages: - Annotations does not contains implementation details - Annotations must not use "X from Imports" phrasing - Footer Description does not contains details + reflect: + file: architecture.md architecture-review: approve: auto apply-architecture: From 204cfdd9658401179bf702a5d84a10954eb6bfd7 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Tue, 1 Sep 2026 14:54:30 +0000 Subject: [PATCH 159/229] feat: rename topic title to multi-line todo across topics contracts - CODEMANIFESTs (topics, history/statuses, commands/topics): title.txt -> todo.md, --title/-t -> --todo/-t, board title column -> todo column, 'new' -> 'todo' on the built-in status scale; create_topic/publish_topic signatures and algorithms carry the todo contract (empty todo writes no file, publish requires a non-empty todo) - usages: topics-command, creating, publishing, topic-board, ensuring, topic-paths, topic-statuses, git publishing, refs-and-switching aligned with the todo contract - click practice: add Interactive Multi-Line Entry (prompt cycle, '.' and EOF terminators, non-interactive detection) and Option with an optional value (flag_value with is_flag=False) patterns - memory: add core-anchored invariants rule, drop declarative contract level section, regroup architecture rules --- .goga/memory/architecture.md | 64 ++++---- .goga/usages/cooks/click.md | 72 +++++++++ .../commands/topics/.usages/topics-command.md | 54 ++++--- goga/commands/topics/CODEMANIFEST | 66 +++++--- goga/history/.usages/topic-paths.md | 4 +- goga/history/.usages/topic-statuses.md | 4 +- goga/history/statuses/CODEMANIFEST | 4 +- goga/topics/.usages/creating.md | 26 ++-- goga/topics/.usages/ensuring.md | 4 +- goga/topics/.usages/publishing.md | 18 ++- goga/topics/.usages/topic-board.md | 13 +- goga/topics/CODEMANIFEST | 146 ++++++++++-------- goga/topics/git/.usages/publishing.md | 4 +- goga/topics/git/.usages/refs-and-switching.md | 9 +- 14 files changed, 302 insertions(+), 186 deletions(-) diff --git a/.goga/memory/architecture.md b/.goga/memory/architecture.md index 4ce8d9f6..cce4c445 100644 --- a/.goga/memory/architecture.md +++ b/.goga/memory/architecture.md @@ -1,5 +1,10 @@ # Project rules +## Core-anchored invariants + +Guarantees that must hold for every caller are specified and enforced in the core domain contracts, never at a single +entry point; a rule guarded inside one command counts as unenforced, because every other caller could bypass it. + ## Dependency edges target the owner's facade and respect fixed direction All interaction with a subsystem's capabilities — code dependencies and documentation alike — targets the owning unit's @@ -18,25 +23,20 @@ extend that unit's zone instead of spawning a parallel sibling — even when the zone's established invariants. Extending a zone never rewrites already published contract fragments: their invariants stay verbatim, and every new allowance is recorded only in the fragments of the new elements. -## ADR revision instead of silent deviation - -When implementation reveals that a settled ADR is redundant, the ADR's guarantee is restated through another means -rather than obeyed blindly or violated silently: the revision is explicitly recorded in the plan, the original intent is -preserved by a different mechanism (e.g. a checkpoint contract holding the guarantee instead of an explicit build step), -and routines made redundant by the revision are abolished. Neither letter-following against discovered redundancy nor -unrecorded deviation is acceptable. - -## Data-driven action catalog +## Additive regression-free extension -The catalog of addressable actions of an event platform is kept as data: records of (domain, name, error class) held in -the platform, emitted by string address. The contract of each action — the shape of its context, the moment of the -event — is defined by the owning domain, never by the platform. +New functionality enters as a new unit beside the existing ones, never as a mode inside an existing unit. Existing +observable behavior, its contracts, and its tests are not edited and do not acquire new dependencies — including reads +of new data sources. Data-model extensions arrive as optional fields with a safe default so every existing construction +site stays valid without edits. Migrating existing functionality onto a new platform follows the same spirit as a +near-rename: domain objects move unchanged, and only the source of registrations changes (the cell emits the platform's +action instead of running its own enumeration mechanism). -## Deferred assembly on first use +## Specialization lives with the consumer -A run-scoped registry is created empty and cheap and is assembled at most once, at the first event emission or -inspection. The guarantee that assembly happens before any output or state change is held by the checkpoint contract ( -events are emitted before any output or state mutation), not by requiring an explicit assembly step in every command. +When a domain needs its own variant of a shared capability, the variant is created inside the consumer's zone. A +provider's internal units are never extended to serve one specific consumer — misplacement distorts the ownership map, +and moving code after materialization is a full migration. ## Layered responsibility for external inputs @@ -60,26 +60,25 @@ report mode previews the full effect before anything is removed; execution itsel never protect a record from removal. An operation is either unconditional or explicitly scoped by the caller; sparing modes keyed to the data being destroyed are not invented. -## Specialization lives with the consumer - -When a domain needs its own variant of a shared capability, the variant is created inside the consumer's zone. A -provider's internal units are never extended to serve one specific consumer — misplacement distorts the ownership map, -and moving code after materialization is a full migration. - ## Stage artifact purity A process stage produces only its designated artifact type; transformations belonging to later stages never start early. A planning stage does not modify implementation artifacts — materialization belongs to the next stage. Mixing planning with materialization destroys the workflow's guarantees: unreviewed code changes without an approved plan. -## Additive regression-free extension +## ADR revision instead of silent deviation -New functionality enters as a new unit beside the existing ones, never as a mode inside an existing unit. Existing -observable behavior, its contracts, and its tests are not edited and do not acquire new dependencies — including reads -of new data sources. Data-model extensions arrive as optional fields with a safe default so every existing construction -site stays valid without edits. Migrating existing functionality onto a new platform follows the same spirit as a -near-rename: domain objects move unchanged, and only the source of registrations changes (the cell emits the platform's -action instead of running its own enumeration mechanism). +When implementation reveals that a settled ADR is redundant, the ADR's guarantee is restated through another means +rather than obeyed blindly or violated silently: the revision is explicitly recorded in the plan, the original intent is +preserved by a different mechanism (e.g. a checkpoint contract holding the guarantee instead of an explicit build step), +and routines made redundant by the revision are abolished. Neither letter-following against discovered redundancy nor +unrecorded deviation is acceptable. + +## Deferred assembly on first use + +A run-scoped registry is created empty and cheap and is assembled at most once, at the first event emission or +inspection. The guarantee that assembly happens before any output or state change is held by the checkpoint contract ( +events are emitted before any output or state mutation), not by requiring an explicit assembly step in every command. ## Closed binding of names in a contract @@ -89,13 +88,6 @@ direct dependency is impossible — cycles, unreachability). No dangling declara otherwise the contract cannot stand alone and the implementation cannot be rebuilt from it. References use the contract's own notation, without procedural phrases about where names come from. -## Declarative contract level - -Contracts describe observable behavior, not implementation mechanics: low-level command chains and protocol details live -in dedicated practice documents the contract points to, and summaries stay at the level of the responsibility zone -without enumerated details. A contract written this way can be rebuilt from itself without reading the implementation -and does not drift when the mechanics change. - ## Names state their scope An operation's name states its exact coverage — never broader than what it does (no implying remote-side effects of a diff --git a/.goga/usages/cooks/click.md b/.goga/usages/cooks/click.md index e3af65f6..9d6220ef 100644 --- a/.goga/usages/cooks/click.md +++ b/.goga/usages/cooks/click.md @@ -200,6 +200,78 @@ def test_hello(): - Check `result.exit_code` and `result.output` - For user input: `runner.invoke(cli, input='yes\n')` +## Interactive Multi-Line Entry + +A multi-line text value (paragraphs included) is collected with a prompt +cycle — one input per line; a lone `.` line or EOF finishes: + +```python +import sys + +import click +from click import termui + + +def prompt_multiline(label: str) -> str | None: + if not sys.stdin.isatty(): + raise click.ClickException(f"{label} entry needs an interactive terminal") + click.echo(f"Enter the {label}. Finish with a lone '.' line or Ctrl+D.") + lines: list[str] = [] + while True: + try: + line = termui.visible_prompt_func("") + except EOFError: + break + except KeyboardInterrupt: + raise click.Abort() from None + if line == ".": + break + lines.append(line) + text = "\n".join(lines) + return text if text else None +``` + +- Every entered line continues the text; an empty line is an allowed text + line — paragraphs survive. +- The two terminators are a line consisting of a single `.` and EOF + (Ctrl+D); the rule is stated in the prompt itself. +- No line entered cancels the entry — return None and continue as without + the value; an empty text is never produced. A single blank line joins to + the empty text, so it cancels the entry the same way — the emptiness + check runs on the joined text, not on the line list. +- Detect the non-interactive terminal before the first prompt — a missing + TTY is a clean error without a traceback. +- KeyboardInterrupt aborts the command — it is not a terminator. +- Resolve `visible_prompt_func` through the module attribute at call time + (`termui.visible_prompt_func`) — `CliRunner` patches + `click.termui.visible_prompt_func` per invoke, and a `from`-imported + binding never sees the patched function. +- In tests drive the cycle by a direct call: monkeypatch `sys.stdin` with + `mock.Mock(**{"isatty.return_value": True})` and patch + `click.termui.visible_prompt_func` with a `side_effect` list of lines — + `EOFError` in the list models Ctrl+D, `"."` models the terminator. Under + `CliRunner` the cycle always refuses — its `sys.stdin` is not a TTY — so + `CliRunner` covers the non-interactive error and the flag matrix only. + +### Option with an optional value + +The flag that starts the entry takes an optional value — a bare flag +passes the entry marker, a given value passes the text: + +```python +@click.option("--todo", "-t", "todo", default=None, is_flag=False, flag_value="", + metavar="[TEXT]", + help="Todo of the fresh work; without a value — interactive entry") +``` + +- `is_flag=False` together with `flag_value` is the optional-value form: + without the explicit `is_flag=False` the option turns into a pure flag + that never takes a value. +- No flag -> None; a bare `--todo`/`-t` -> "" (start the entry); + `--todo "text"` -> the text. +- An empty string parameter value is the entry marker, never a written + value — an empty file is never created. + ## Anti-patterns - Do not use `argparse` together with `click` in the same application diff --git a/goga/commands/topics/.usages/topics-command.md b/goga/commands/topics/.usages/topics-command.md index a86e7ebb..40c7e5db 100644 --- a/goga/commands/topics/.usages/topics-command.md +++ b/goga/commands/topics/.usages/topics-command.md @@ -6,7 +6,7 @@ facade that registers the group. The group scopes every subcommand to one year (--year/-y, default the current year); the status subcommand reads remote-tracking refs with ---remote/-r and adds the title column with --info/-i; the create subcommand +--remote/-r and adds the todo column with --info/-i; the create subcommand publishes fresh work without switching under --publish/-p. ## Boarding all work @@ -17,49 +17,61 @@ publishes fresh work without switching under --publish/-p. goga topics status --info Prints a three-column table — topic, branch, statuses — with column and -row separators fitted to the terminal width. `--info/-i` adds the title -column: topic, branch, title, and statuses share the width — each of +row separators fitted to the terminal width. `--info/-i` adds the todo +column: topic, branch, todo, and statuses share the width — each of the first three capped at a quarter of it minus the dividers — and the -title shows the first line of the topic's `title.txt`, read from the +todo cell shows the first line of the topic's `todo.md` that yields text +after leading `#` markers are stripped and the edges trimmed, read from the ref trees without checkout (the current row from the working copy); a -topic without a title file shows an empty cell. Overlong cells are -truncated with an ellipsis. The current branch row carries `*` in its -topic cell; remote hosts keep their remote prefix. A topic's statuses -are all its maximal statuses, wrapped onto continuation lines. -An empty board prints nothing and exits 0. +topic without `todo.md` shows an empty cell. The todo column header is +`todo`. Overlong cells are truncated with an ellipsis. The current branch +row carries `*` in its topic cell; remote hosts keep their remote prefix. +A topic's statuses are all its maximal statuses, wrapped onto continuation +lines. An empty board prints nothing and exits 0. ## Creating fresh work goga topics create Feature/Foo_Bar goga topics --year 2025 create Feature/Foo_Bar goga topics create Feature/Foo_Bar -t "Payment retry" + goga topics create Feature/Foo_Bar --todo "Fix retries. + + Retries ignore the backoff cap." + goga topics create Feature/Foo_Bar -t Creates the branch with the name as entered, switches to it, and creates -the topic directory of the scoped year. An explicit `--title/-t` also -writes the topic title file `title.txt` — the text as entered plus a -trailing newline; on the idempotent re-run (the current branch already -hosts the same slug) the topic directory is ensured and the title file -is created or overwritten — nothing else mutates, no switch happens. -Without `-t` no title file is written. Occupied -names and empty slugs trigger a re-ask on an interactive terminal, or a -clean error with a hint otherwise. +the topic directory of the scoped year. An explicit `--todo/-t` value +writes the topic todo file `todo.md` — the text as entered plus a +trailing newline, UTF-8; the todo may span multiple lines. `-t` without a +value (or with an empty one) starts the interactive multi-line entry: +type the todo line by line — empty lines continue the text as paragraphs — +and finish with a lone `.` line or Ctrl+D; the rule is stated in the +prompt. Entering nothing cancels the entry: no `todo.md` is written and +the command continues as without the flag. Interactive entry without a +terminal is a clean error. On the idempotent re-run (the current branch +already hosts the same slug) the topic directory is ensured and +`todo.md` is created or overwritten — nothing else mutates, no switch +happens. Without `-t` no todo file is written. Occupied names and empty +slugs trigger a re-ask on an interactive terminal, or a clean error with +a hint otherwise. ## Creating and publishing fresh work goga topics create Feature/Foo_Bar --publish -t "Payment retry" + goga topics create Feature/Foo_Bar -p --todo goga topics create Feature/Foo_Bar -p -t "Payment retry" --base-ref origin/release-1.3 goga topics create Feature/Foo_Bar -p -t "Payment retry" -c "chore: new topic {slug}" Creates the branch off the configured base (topics.base_ref in -.goga/config.yml, overridden by --base-ref), commits the topic title file +.goga/config.yml, overridden by --base-ref), commits the topic todo file on it without touching the working copy — the caller stays on their branch, a dirty tree and a detached HEAD are both fine — and pushes the branch to origin with upstream binding. The topic is visible on the -remote board with the new status. The result is one line: created and +remote board with the todo status. The result is one line: created and published on the remote. -- The title is required in this mode — the board reads the topic through - the title file. +- The todo is required in this mode — the value comes from `--todo/-t` or + the interactive entry. - The commit message comes from topics.publish_commit (default `goga: create topic {slug}`), overridden by --commit/-c; the {slug} placeholder takes the topic slug, a template without it is used as is. diff --git a/goga/commands/topics/CODEMANIFEST b/goga/commands/topics/CODEMANIFEST index f2c1bc45..10d2ed70 100644 --- a/goga/commands/topics/CODEMANIFEST +++ b/goga/commands/topics/CODEMANIFEST @@ -32,8 +32,9 @@ Annotations: | Use the `click` practice to build the topics command group: the group decorator with its own option, the subcommand registration, the flag, - arguments, and options of each subcommand, echo, and exit-code - propagation. + arguments, and options of each subcommand, echo, exit-code propagation, + and the interactive multi-line todo entry with its prompt cycle, + terminators, and non-interactive detection. Use the `publishing` practice for the fast creation-and-publication contract of the domain. Use the `project-configuration` practice for the @@ -69,7 +70,7 @@ Annotations: | Subcommand surfaces: - status — a --remote/-r flag, an --info/-i flag - - create — a NAME positional, a --title/-t option, a --publish/-p flag, + - create — a NAME positional, a --todo/-t option, a --publish/-p flag, a --base-ref option, a --commit/-c option - switch — an IDENTIFIER positional @@ -79,11 +80,11 @@ Annotations: | "status(remote: bool = False, info: bool = False) -> exit_code: int": | Subcommand goga topics status: print the board — the cross-branch topic inventory of the scoped year as a three-column table, or a - four-column table with the title column under --info/-i. + four-column table with the todo column under --info/-i. `remote`: the --remote/-r flag — read remote-tracking refs instead of local branches - `info`: the --info/-i flag — add the title column to the table + `info`: the --info/-i flag — add the todo column to the table `exit_code`: 0 on success (an empty board included), 1 on error Apply the `topic-board` practice for the board contract of the @@ -103,17 +104,18 @@ Annotations: | Constraints: - Do not print the year or the artifacts, and no heading line - outside the table — the table carries topic, branch, the title + outside the table — the table carries topic, branch, the todo column under `info`, and statuses only - "create(branch_name: str, title: str | None = None, publish: bool = False, base_ref: str | None = None, commit_message: str | None = None) -> exit_code: int": | + "create(branch_name: str, todo: str | None = None, publish: bool = False, base_ref: str | None = None, commit_message: str | None = None) -> exit_code: int": | Subcommand goga topics create: create fresh work — a branch with the name as entered, its topic directory of the scoped year, and an - optional topic title; under --publish the work is created off an + optional multi-line todo; under --publish the work is created off an explicit base and published to origin without switching. `branch_name`: NAME positional — the branch name as entered - `title`: the --title/-t value — the topic title; required under - --publish, optional otherwise + `todo`: the --todo/-t value — the multi-line todo of the fresh work; + a flag given without a value or with an empty value starts + the interactive entry; None when the flag is absent `publish`: the --publish/-p flag — the fast creation-and-publication mode `base_ref`: the --base-ref value — the base of the published branch; @@ -127,27 +129,40 @@ Annotations: | contract of the domain. Apply the `project-configuration` practice for the topics section schema. - Apply the `click` practice for exit-code propagation. + Apply the `click` practice for the flag with an optional value, the + interactive multi-line todo entry, and exit-code propagation. Algorithm: 1. `base_ref` or `commit_message` without `publish` -> clean error: the publication-only options never act silently - 2. `publish` with no `title` -> clean error asking for the title - 3. The default path delegates to `create_topic` with `branch_name`, - the scoped year, and `title` - 4. The publish path resolves the base — `base_ref`, otherwise the + 2. Resolve the todo — a non-empty `todo` value is the todo; a flag + given without a value or with an empty value starts the + interactive multi-line entry: each entered line continues the + text, empty lines inside the text stay as entered, a line + consisting of a single dot or the end of input finishes the entry, + the terminator rule is + stated in the prompt itself; no line entered cancels the entry — + execution continues as without the flag; the entry on a + non-interactive terminal is a clean error before any mutation + 3. `publish` with no resolved todo -> clean error asking for the todo + 4. The default path delegates to `create_topic` with `branch_name`, + the scoped year, and the resolved todo — None when neither the + flag nor the entry produced one + 5. The publish path resolves the base — `base_ref`, otherwise the topics section of the configuration loaded via `load_project_config`, otherwise a clean error naming the configuration line and the flag — and the message template — `commit_message`, otherwise the topics section, otherwise the built-in default `goga: create topic {slug}` - 5. The publish path delegates to `publish_topic` with `branch_name`, - `title`, the resolved base, the resolved template, and the scoped - year - 6. Echo the single result line - 7. Propagate the exit code + 6. The publish path delegates to `publish_topic` with `branch_name`, + the resolved todo, the resolved base, the resolved template, and + the scoped year + 7. Echo the single result line + 8. Propagate the exit code Requirements: + - The resolved todo is either non-empty text or absent — an empty + todo.md never exists - The configuration is read on the publish path only, and only for values no flag provided — the default path never reads it - A missing configuration file counts as an unset value; a present @@ -182,11 +197,11 @@ Annotations: | location: render.py annotations: | Render the board as a table: topic, branch, and statuses — under - `info` the title column sits between branch and statuses. + `info` the todo column sits between branch and statuses. `records`: the collected board records — already sorted by the domain `width`: the measured terminal width in columns - `info`: True adds the title column and switches to the four-column + `info`: True adds the todo column and switches to the four-column width rule Apply the `click` practice for echo. @@ -197,7 +212,7 @@ Annotations: | four-column rule with it; the grid is fixed and independent of the record content 2. Print one header row and one separator row with column and row - dividers — the column order is topic, branch, title, statuses + dividers — the column order is topic, branch, todo, statuses under `info` 3. Print each record: every text column truncated with an ellipsis when it exceeds its column, the statuses wrapped onto continuation lines @@ -212,11 +227,12 @@ Annotations: | one third of `width` minus the dividers, statuses receives what is left, and every column keeps a minimum of 8 columns before truncation applies - - Four-column widths under `info`: topic, branch, and title get an + - Four-column widths under `info`: topic, branch, and todo get an equal share — each capped at one quarter of `width` minus the dividers, statuses receives the non-negative remainder, and every column keeps a minimum of 8 columns before truncation applies - - A title of None or an empty string renders an empty cell + - The todo column header is the word todo + - A todo of None or an empty string renders an empty cell - The truncation marker is a single ellipsis character - An overlong status is truncated like the other columns - The table never exceeds `width`, with one documented exception: when diff --git a/goga/history/.usages/topic-paths.md b/goga/history/.usages/topic-paths.md index 8fb19d49..b5580cfb 100644 --- a/goga/history/.usages/topic-paths.md +++ b/goga/history/.usages/topic-paths.md @@ -31,8 +31,8 @@ from goga.history import resolve_topic_file plan = resolve_topic_file("history-commands", "plan.md") # -> .goga/history/2026/history-commands/plan.md -title = resolve_topic_file("feature-foo", "title.txt") -# -> .goga/history/2026/feature-foo/title.txt +todo_path = resolve_topic_file("feature-foo", "todo.md") +# -> .goga/history/2026/feature-foo/todo.md ``` - The filename is arbitrary but must carry an extension (`plan.md`); diff --git a/goga/history/.usages/topic-statuses.md b/goga/history/.usages/topic-statuses.md index 64ddd8d2..d6222785 100644 --- a/goga/history/.usages/topic-statuses.md +++ b/goga/history/.usages/topic-statuses.md @@ -5,10 +5,10 @@ For consumers that report progress: CLI status output, boards, reviews, dashboards. A topic's status is the set of its maximal present statuses on the topic -status scale. The built-in axis is fixed — empty, new, defined, discovered, +status scale. The built-in axis is fixed — empty, todo, defined, discovered, backlog, designed, specified, planned, done. `empty` is the floor for a topic with no artifact at all; each of the other eight is marked by one -artifact inside the topic directory, in axis order — new by title.txt, +artifact inside the topic directory, in axis order — todo by todo.md, defined by prd.md, discovered by adr.md, backlog by task.md, designed by arch.md, specified by design.md, planned by plan.md, done by completed/plan.md. Tool packages extend the scale diff --git a/goga/history/statuses/CODEMANIFEST b/goga/history/statuses/CODEMANIFEST index 4911a127..be464532 100644 --- a/goga/history/statuses/CODEMANIFEST +++ b/goga/history/statuses/CODEMANIFEST @@ -46,9 +46,9 @@ Annotations: | intra-package imports. Requirements: - - The built-in axis is ordered empty, new, defined, discovered, + - The built-in axis is ordered empty, todo, defined, discovered, backlog, designed, specified, planned, done by the artifacts - title.txt, prd.md, adr.md, task.md, arch.md, design.md, plan.md, + todo.md, prd.md, adr.md, task.md, arch.md, design.md, plan.md, completed/plan.md - The built-in empty entry carries the empty artifact path — one name more than the artifact list: it is never markable and surfaces as diff --git a/goga/topics/.usages/creating.md b/goga/topics/.usages/creating.md index 6c371b72..f93dc74d 100644 --- a/goga/topics/.usages/creating.md +++ b/goga/topics/.usages/creating.md @@ -29,21 +29,25 @@ print(result) # one line describing what was created - No artifact files are written inside the topic directory — artifacts belong to their producers. -## Creating with a title +## Creating with a todo ```python from goga.topics import create_topic -result = create_topic("Feature/Foo_Bar", title="Payment retry") +result = create_topic( + "Feature/Foo_Bar", + todo="Fix payment retries.\n\nRetries ignore the backoff cap.", +) ``` -- Fresh work: the branch, the switch, the topic directory, and the - title file `title.txt` — the text as entered plus a trailing newline, - UTF-8. -- The current branch already hosting the same slug with an explicit - title: the topic directory is ensured and `title.txt` is created or - overwritten — nothing else mutates, no switch happens. -- Without a title no title file is written — an existing one is left +- Fresh work: the branch, the switch, the topic directory, and the todo + file `todo.md` — the text as entered plus a trailing newline, UTF-8. +- The todo is multi-line: empty lines inside the text stay as entered, so + paragraphs survive. +- `todo` empty or omitted writes no `todo.md` — an existing file is left untouched. -- `title.txt` marks the `new` status on the topic status scale; no - other artifact is written — artifacts belong to their producers. +- The current branch already hosting the same slug with an explicit todo: + the topic directory is ensured and `todo.md` is created or overwritten — + nothing else mutates, no switch happens. +- `todo.md` marks the `todo` status on the topic status scale; no other + artifact is written — artifacts belong to their producers. diff --git a/goga/topics/.usages/ensuring.md b/goga/topics/.usages/ensuring.md index c487d699..073da623 100644 --- a/goga/topics/.usages/ensuring.md +++ b/goga/topics/.usages/ensuring.md @@ -25,8 +25,8 @@ print(result) # one line — the outcome - Nothing hosts the identifier -> fresh work: the branch is created with the name as entered, the repository switches to it, and the topic directory of the year is created from its slug — - `Created branch <name> and topic <year>/<slug>`. No title file is - written: the creation fallback takes no title — titled fresh work is + `Created branch <name> and topic <year>/<slug>`. No `todo.md` is + written: the creation fallback takes no todo — fresh work with a todo is `create_topic` alone. - A hosted identifier -> the plain switch outcome: `Switched to branch <name>`, `Created branch <name> from <remote>/<name>`, or `Already on diff --git a/goga/topics/.usages/publishing.md b/goga/topics/.usages/publishing.md index f94e9515..63f26c0f 100644 --- a/goga/topics/.usages/publishing.md +++ b/goga/topics/.usages/publishing.md @@ -5,10 +5,10 @@ with the `goga.topics` facade. For consumers that register new work on the remote board while the user keeps working: the topics command group, higher-level orchestration. -`publish_topic` takes the branch name as entered, a required title, an -explicit base, and a commit message template. The branch keeps the name -verbatim; the topic directory takes the normalized slug of the year — the -two may deliberately differ. +`publish_topic` takes the branch name as entered, a required multi-line +todo, an explicit base, and a commit message template. The branch keeps the +name verbatim; the topic directory takes the normalized slug of the year — +the two may deliberately differ. ## Publishing fresh work @@ -17,7 +17,7 @@ from goga.topics import publish_topic result = publish_topic( "Feature/Foo_Bar", - "Payment retry", + "Fix payment retries.\n\nRetries ignore the backoff cap.", "origin/main", "goga: create topic {slug}", ) @@ -26,9 +26,11 @@ print(result) # one line: created and published on the remote - The caller stays on their branch: the working copy, the index, and HEAD are untouched — a dirty tree and a detached HEAD do not interfere. -- The branch carries exactly one commit on top of the base: the title file - `title.txt` — the text as entered plus a trailing newline, UTF-8 — in the - topic directory of the year; the topic shows the `new` status. +- The branch carries exactly one commit on top of the base: the todo file + `todo.md` — the text as entered plus a trailing newline, UTF-8 — in the + topic directory of the year; the topic shows the `todo` status. +- The todo is required and non-empty — an empty todo is a clean error + before any mutation. - The message template replaces {slug} with the topic slug; a template without the placeholder is used as is. - A failed publication rolls back fully — the branch is deleted and one diff --git a/goga/topics/.usages/topic-board.md b/goga/topics/.usages/topic-board.md index 0a2af0b4..19c1fbaa 100644 --- a/goga/topics/.usages/topic-board.md +++ b/goga/topics/.usages/topic-board.md @@ -19,15 +19,18 @@ from goga.topics import collect_topic_board records = collect_topic_board() # current year, local records = collect_topic_board(year="2025", remote=True) # remote-tracking refs for record in records: - print(record.topic, record.branch, record.statuses, record.current, record.title) + print(record.topic, record.branch, record.statuses, record.current, record.todo) ``` - One `BoardRecord` per hosted topic: the slug, the hosting branch display name, the maximal status names in scale order, the current and remote - markers, and the title — the first line of the topic's `title.txt`, or - None when the topic has none. Rows hosted by other branches read their - titles from the ref trees without checkout; the current branch's row - reads the working copy, so an uncommitted title edit shows at once. + markers, and the todo summary — the first line of the topic's `todo.md` + that yields text after leading `#` markers are stripped and the edges + trimmed, or None when the topic has none. Rows hosted by other branches + read their summaries from the ref trees without checkout; the current + branch's row reads the working copy, so an uncommitted todo edit shows at + once. A `todo.md` whose every line is only `#` markers yields the empty + summary. The file is never modified — the stripping is for display. - A local branch and its remote twin collapse to one row — the local branch wins. Two different branches hosting one slug stay two rows. - Sorting: scale order of the first maximal status, then topic alphabet. diff --git a/goga/topics/CODEMANIFEST b/goga/topics/CODEMANIFEST index 490b6feb..3a587b24 100644 --- a/goga/topics/CODEMANIFEST +++ b/goga/topics/CODEMANIFEST @@ -53,7 +53,7 @@ Annotations: | Use the `topic-paths` practice for the consumer patterns of the history facade — the topic slug, the topic directory of a year, the existence - oracle, the topic title file path, and the current branch. + oracle, the topic todo file path, and the current branch. Use the `topic-statuses` practice for the status scale patterns of the history facade — scale assembly and maximal-status computation. Use the `refs-and-switching` practice for the git patterns of the topics @@ -65,23 +65,23 @@ Annotations: | This cell owns the topics domain — the work-tracker view of the history tree: the cross-branch topic inventory of one year with per-topic - statuses and titles, the switch-identifier resolution and switching - orchestration, the fresh-work creation procedure with its optional topic - title, the fast creation-and-publication procedure — a committed branch - off an explicit base without switching, pushed to origin, rolled back - fully on a failed publication — and the combined ensure orchestration - that switches onto hosted work or creates it when nothing hosts the - identifier. Topic identity, addressing, and statuses belong to the - history facade; git access belongs to the topics git cell. Git - infrastructure failures and the fatal scale-assembly ImportError surface - as click.ClickException. Mutations are local-only and happen strictly - after every decision is made — the publication push of the fast + statuses and todo summaries, the switch-identifier resolution and + switching orchestration, the fresh-work creation procedure with its + optional multi-line todo, the fast creation-and-publication procedure — + a committed branch off an explicit base without switching, pushed to + origin, rolled back fully on a failed publication — and the combined + ensure orchestration that switches onto hosted work or creates it when + nothing hosts the identifier. Topic identity, addressing, and statuses + belong to the history facade; git access belongs to the topics git cell. + Git infrastructure failures and the fatal scale-assembly ImportError + surface as click.ClickException. Mutations are local-only and happen + strictly after every decision is made — the publication push of the fast procedure is the single network exception; no fetch ever happens. Use relative imports. --- -"BoardRecord(topic: str, branch: str, statuses: list[str], current: bool, remote: bool, title: str | None = None)": +"BoardRecord(topic: str, branch: str, statuses: list[str], current: bool, remote: bool, todo: str | None = None)": location: board.py annotations: | One row of the topic board — a topic hosted by one branch. @@ -92,8 +92,9 @@ Annotations: | scale order `current`: True when the row hosts the current working branch `remote`: True when the hosting ref is remote-tracking - `title`: the first line of the topic title file, or None when the topic - has none + `todo`: the todo summary of the topic — the first line of todo.md that + yields a non-empty result after leading # markers are stripped + and the edges trimmed — or None when the topic has no todo.md Apply the `convention` practice for the data-model rules and intra-package imports. @@ -108,15 +109,17 @@ Annotations: | True when the row hosts the current working branch. "remote -> bool": | True when the hosting ref is remote-tracking. - "title -> str | None": | - The first line of the topic title file, or None when the topic has - no title file. + "todo -> str | None": | + The todo summary of the topic, or None when the topic has no + todo.md. The summary is computed for display — the file itself is + never modified; a todo.md whose every line reduces to emptiness + yields the empty summary. "collect_topic_board(year: str | None = None, remote: bool = False) -> records: list[BoardRecord]": location: board.py annotations: | Collect the cross-branch topic inventory of one year — every topic with - its hosting branch, statuses, and title. + its hosting branch, statuses, and todo summary. `year`: optional year as four digits; None means the current year `remote`: True reads remote-tracking refs instead of local branches @@ -146,10 +149,13 @@ Annotations: | artifact paths and compute the maximal statuses — the working copy over the directory composed by `resolve_topic_dir` via `resolve_topic_status`, every other ref via the `StatusScale` - 6. Read the title of every hosted topic — the working copy from the - title file title.txt of its directory, every other ref from the - title file of its ref tree via `read_ref_file`; the value is the - first line of the file, None when it is absent + 6. Read the todo summary of every hosted topic — the working copy from + todo.md of its directory, every other ref from the todo.md of its + ref tree via `read_ref_file`; the summary is the first line that + yields a non-empty result after leading # markers are stripped and + the edges trimmed — the normalization decides the choice, a line of + # markers alone never qualifies; None when the file is absent, the + empty string when no line qualifies; the file is never modified 7. Collapse a local branch and its remote twin into one row — the local branch wins; different branches hosting one slug stay separate rows 8. Mark the row hosting the current branch @@ -161,9 +167,9 @@ Annotations: | progress is visible; remote mode shows it through its remote twin - Read-only — no checkout, no worktree, no mutation of any kind - A year without topics yields an empty list — not an error - - A multi-line title file yields its first line; an empty title file - yields an empty string - - The title never affects the sort order + - A multi-line todo.md yields its first qualifying line; a todo.md + whose every line reduces to emptiness yields the empty summary + - The todo summary never affects the sort order Constraints: - Do not render — output shaping belongs to the consumer @@ -322,21 +328,22 @@ Annotations: | - Do not manage the stages of the hosting pipeline — continuation belongs to the pipeline itself -"create_topic(branch_name: str, year: str | None = None, title: str | None = None) -> result: str": +"create_topic(branch_name: str, year: str | None = None, todo: str | None = None) -> result: str": location: creation.py annotations: | Create fresh work — a branch with the name as entered, its topic - directory of the year, and an optional topic title. + directory of the year, and an optional multi-line todo. `branch_name`: the branch name as entered by the user `year`: optional year as four digits; None means the current year - `title`: optional topic title; None writes no title file + `todo`: optional multi-line todo of the fresh work; None or an empty + string writes no todo.md `result`: one line describing the outcome Apply the `click` practice for the re-ask prompt and the non-interactive detection. Apply the `topic-paths` practice for the slug, existence, directory - creation, and title-file path patterns. + creation, and todo-file path patterns. Apply the `refs-and-switching` practice for the create-and-switch pattern. @@ -346,42 +353,41 @@ Annotations: | on an interactive terminal and restart, or fail with the reason otherwise 3. The current branch — read via `resolve_current_branch_name` — hosts - the same slug -> the idempotent path: a `title` given writes the - topic title file title.txt — the path resolved via - `resolve_topic_file` — of the ensured topic directory; no `title` - is a success without mutation; no occupancy check, no switch + the same slug -> the idempotent path: a non-empty `todo` writes the + topic todo file todo.md — the path resolved via `resolve_topic_file` + — of the ensured topic directory; no `todo` is a success without + mutation; no occupancy check, no switch 4. `check_branch_occupancy` reports a conflict -> print the reason with a hint to the board, prompt for a new name on an interactive terminal and restart, or fail otherwise 5. Free name -> create the branch named exactly as entered and switch to it via `create_and_switch_branch`, create the topic directory - via `ensure_topic_dir` of the year, and a `title` given writes the - title file title.txt — the path resolved via `resolve_topic_file` + via `ensure_topic_dir` of the year, and a non-empty `todo` writes + the todo file todo.md — the path resolved via `resolve_topic_file` — of the topic directory 6. Return the single result line Requirements: - The branch keeps the name as entered; the topic directory takes the slug — the two may deliberately differ - - The title file carries `title` as entered plus a single trailing - newline, encoded UTF-8; an empty string writes the bare newline, and - the first-line read of the board yields the empty title - - The title file is written only when `title` is given — None never - creates and never overwrites it; an explicit `title` creates the - file or overwrites it - - The topic directory exists before the title file is written + - The todo.md file carries `todo` as entered plus a single trailing + newline, encoded UTF-8 — empty lines inside the text stay as entered + - The todo.md file is written only when a non-empty `todo` is given — + None or an empty string never creates and never overwrites it; an + explicit `todo` creates the file or overwrites it + - The topic directory exists before the todo.md file is written - An aborted re-ask leaves the repository untouched - On the fresh path the branch is created and switched to before the - topic directory and the title file are written — a filesystem + topic directory and the todo.md file are written — a filesystem failure of the writes leaves the caller on the new branch with the - directory or the title missing, reported as a clean error + directory or the todo missing, reported as a clean error - The caller stays on the new branch Constraints: - Do not validate branch-name characters — git owns name validity - Do not auto-pick suffixed names on a conflict — the user re-asks or aborts - - Do not write artifact files other than the topic title file inside + - Do not write artifact files other than the topic todo file inside the topic directory "check_branch_occupancy(branch_name: str, slug: str, year: str | None = None) -> conflict: str | None": @@ -418,16 +424,17 @@ Annotations: | - Do not resolve remote state over the network — the local inventory only -"publish_topic(branch_name: str, title: str, base_ref: str, commit_message: str, year: str | None = None) -> result: str": +"publish_topic(branch_name: str, todo: str, base_ref: str, commit_message: str, year: str | None = None) -> result: str": location: publishing.py annotations: | Create fresh work and publish it — a branch off an explicit base carrying - one commit with the topic title, pushed to origin, while the caller stays + one commit with the topic todo, pushed to origin, while the caller stays on their branch. `branch_name`: the branch name as entered by the user - `title`: the topic title — written to the title file as entered plus a - single trailing newline + `todo`: the multi-line todo of the fresh work — written to todo.md as + entered plus a single trailing newline; required and non-empty, + an empty todo is a clean error asking for it `base_ref`: the base revision the branch starts from — any revision string, resolved as git resolves it `commit_message`: the commit message template — the {slug} placeholder @@ -439,7 +446,7 @@ Annotations: | Apply the `click` practice for the re-ask prompt and the non-interactive detection. Apply the `topic-paths` practice for the slug, current-branch, and - title-file path patterns. + todo-file path patterns. Apply the `refs-and-switching` practice for the occupancy inventory and tree-reading patterns. Apply the `publishing` practice for the quarantined commit building, @@ -450,28 +457,30 @@ Annotations: | 2. Empty slug -> input error: print the reason, prompt for a new name on an interactive terminal and restart the fast cycle, or fail with the reason otherwise - 3. The current branch — read via `resolve_current_branch_name` — hosts + 3. An empty `todo` -> clean error asking for the todo, before any + mutation + 4. The current branch — read via `resolve_current_branch_name` — hosts the same slug -> clean error without mutations: the fast path is only for fresh work - 4. Probe the occupancy oracles in order — `check_branch_occupancy` + 5. Probe the occupancy oracles in order — `check_branch_occupancy` first, then `check_slug_occupancy`; the first conflict wins -> print the reason with a hint to the board, prompt for a new name on an interactive terminal and restart the fast cycle, or fail otherwise - 5. `origin_configured` reads False -> clean error with the reason - 6. Resolve `base_ref` into its commit via `resolve_ref_commit` — an + 6. `origin_configured` reads False -> clean error with the reason + 7. Resolve `base_ref` into its commit via `resolve_ref_commit` — an unresolvable base is a clean error with the reason, before any mutation - 7. Build the publication commit via `commit_file_on_base` — the parent - commit, the title file path resolved via `resolve_topic_file` as a - repository-root-relative posix string, the title content, and the + 8. Build the publication commit via `commit_file_on_base` — the parent + commit, the todo.md path resolved via `resolve_topic_file` as a + repository-root-relative posix string, the todo content, and the applied `commit_message` - 8. Plant the branch named exactly as entered via + 9. Plant the branch named exactly as entered via `create_branch_at_commit` - 9. Publish via `push_branch`; a failed publication deletes the branch - via `delete_local_branch` and surfaces one clean error carrying the - reason - 10. Return the single result line + 10. Publish via `push_branch`; a failed publication deletes the branch + via `delete_local_branch` and surfaces one clean error carrying the + reason + 11. Return the single result line Requirements: - The working copy, the index, and HEAD stay untouched — the caller @@ -484,7 +493,7 @@ Annotations: | - A failed publication rolls back fully — the planted branch is deleted and nothing else was ever mutated; a re-run after the cause is resolved succeeds - - The title file carries `title` as entered plus a single trailing + - The todo.md file carries `todo` as entered plus a single trailing newline, encoded UTF-8 — the sole artifact of the topic directory - The result is exactly one line @@ -492,7 +501,7 @@ Annotations: | - Do not validate branch-name characters — git owns name validity - Do not auto-pick suffixed names on a conflict — the user re-asks or aborts - - Do not write artifact files other than the topic title file inside + - Do not write artifact files other than the topic todo file inside the topic directory - Do not switch the caller's branch — the caller keeps their working state @@ -542,6 +551,7 @@ Annotations: | Author: Goga CreatedAt: 29/08/26 Description: | - The topics domain — the cross-branch topic inventory with titles, switch - resolution and orchestration, fresh-work creation with an optional title, - fast creation with publication, and the combined ensure orchestration. + The topics domain — the cross-branch topic inventory with todo summaries, + switch resolution and orchestration, fresh-work creation with an optional + todo, fast creation with publication, and the combined ensure + orchestration. diff --git a/goga/topics/git/.usages/publishing.md b/goga/topics/git/.usages/publishing.md index b51fcb0e..6ffa916b 100644 --- a/goga/topics/git/.usages/publishing.md +++ b/goga/topics/git/.usages/publishing.md @@ -33,8 +33,8 @@ from goga.topics.git import commit_file_on_base commit = commit_file_on_base( base, # from resolve_ref_commit - ".goga/history/2026/feature-foo/title.txt", # repo-root-relative - "Payment retry\n", # content as text + ".goga/history/2026/feature-foo/todo.md", # repo-root-relative + "Fix payment retries.\n\nRetries ignore the cap.\n", # content as text "goga: create topic feature-foo", # final message ) ``` diff --git a/goga/topics/git/.usages/refs-and-switching.md b/goga/topics/git/.usages/refs-and-switching.md index f7400dbb..1a4d8362 100644 --- a/goga/topics/git/.usages/refs-and-switching.md +++ b/goga/topics/git/.usages/refs-and-switching.md @@ -43,10 +43,15 @@ paths = read_ref_tree_paths("feature-foo", prefix) from goga.history import resolve_history_root from goga.topics.git import read_ref_file -path = f"{resolve_history_root().as_posix()}/2026/feature-foo/title.txt" +path = f"{resolve_history_root().as_posix()}/2026/feature-foo/todo.md" content = read_ref_file("feature-foo", path) if content is not None: - print(content.splitlines()[0] if content else "") + first = next( + (line.lstrip("#").strip() for line in content.splitlines() + if line.lstrip("#").strip()), + "", + ) + print(first) ``` - Returns the file content as text, or None when the file cannot be read From d00b085970617e5e2756807c873cbd75ade6c83f Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Tue, 1 Sep 2026 17:25:25 +0000 Subject: [PATCH 160/229] feat: rename status axis new/title.txt to todo/todo.md in statuses cell --- goga/history/statuses/assembly.py | 2 +- goga/history/statuses/scale.py | 4 +-- tests/history/statuses/conftest.py | 4 +-- tests/history/statuses/test_assembly.py | 35 +++++++++++++++++++------ tests/history/statuses/test_scale.py | 18 ++++++------- 5 files changed, 41 insertions(+), 22 deletions(-) diff --git a/goga/history/statuses/assembly.py b/goga/history/statuses/assembly.py index 9128590b..87b65b77 100644 --- a/goga/history/statuses/assembly.py +++ b/goga/history/statuses/assembly.py @@ -19,7 +19,7 @@ _BUILTIN_AXIS: list[Stage] = [ Stage(name="empty", filepath=""), - Stage(name="new", filepath="title.txt"), + Stage(name="todo", filepath="todo.md"), Stage(name="defined", filepath="prd.md"), Stage(name="discovered", filepath="adr.md"), Stage(name="backlog", filepath="task.md"), diff --git a/goga/history/statuses/scale.py b/goga/history/statuses/scale.py index 1af526d5..5b5c05cd 100644 --- a/goga/history/statuses/scale.py +++ b/goga/history/statuses/scale.py @@ -50,9 +50,9 @@ class StatusScale: stages: The scale content in scale order. Requirements: - The built-in axis is ordered empty, new, defined, discovered, + The built-in axis is ordered empty, todo, defined, discovered, backlog, designed, specified, planned, done by the artifacts - title.txt, prd.md, adr.md, task.md, arch.md, design.md, plan.md, + todo.md, prd.md, adr.md, task.md, arch.md, design.md, plan.md, completed/plan.md; a tool status never reorders or replaces a built-in one. """ diff --git a/tests/history/statuses/conftest.py b/tests/history/statuses/conftest.py index 42533e60..c860f91f 100644 --- a/tests/history/statuses/conftest.py +++ b/tests/history/statuses/conftest.py @@ -10,13 +10,13 @@ def builtin_scale() -> StatusScale: """Deterministic built-in scale — nine entries with the contract artifacts. - The deepening order is the contract: empty, new, defined, discovered, + The deepening order is the contract: empty, todo, defined, discovered, backlog, designed, specified, planned, done. """ return StatusScale( stages=[ Stage(name="empty", filepath=""), - Stage(name="new", filepath="title.txt"), + Stage(name="todo", filepath="todo.md"), Stage(name="defined", filepath="prd.md"), Stage(name="discovered", filepath="adr.md"), Stage(name="backlog", filepath="task.md"), diff --git a/tests/history/statuses/test_assembly.py b/tests/history/statuses/test_assembly.py index 22fd2032..a6c37610 100644 --- a/tests/history/statuses/test_assembly.py +++ b/tests/history/statuses/test_assembly.py @@ -37,7 +37,7 @@ _BUILTIN_NAMES = [ "empty", - "new", + "todo", "defined", "discovered", "backlog", @@ -255,14 +255,33 @@ def test_assemble_two_tools_same_anchor_form_registration_order_block( class TestAssembleBuiltinAxis: + def test_assemble_status_scale_axis_carries_todo(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The built-in axis carries ``todo``/``todo.md`` second — the rename of the fresh-work marker.""" + _fake_emission(monkeypatch, []) + + scale = assemble_status_scale() + + assert [stage.name for stage in scale.stages] == [ + "empty", + "todo", + "defined", + "discovered", + "backlog", + "designed", + "specified", + "planned", + "done", + ] + assert scale.stages[1].filepath == "todo.md" + def test_assemble_status_scale_builds_nine_entry_axis(self, monkeypatch: pytest.MonkeyPatch) -> None: - """The built-in axis counts nine entries — ``new``/``title.txt`` second, no regress to eight.""" + """The built-in axis counts nine entries — ``todo``/``todo.md`` second, no regress to eight.""" _fake_emission(monkeypatch, []) scale = assemble_status_scale() assert _names(scale)[:9] == _BUILTIN_NAMES - assert scale.stages[1].filepath == "title.txt" + assert scale.stages[1].filepath == "todo.md" assert len(scale.stages) == 9 def test_assemble_builtin_axis_order(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -274,7 +293,7 @@ def test_assemble_builtin_axis_order(self, monkeypatch: pytest.MonkeyPatch) -> N assert _names(scale) == _BUILTIN_NAMES assert [stage.filepath for stage in scale.stages] == [ "", - "title.txt", + "todo.md", "prd.md", "adr.md", "task.md", @@ -314,10 +333,10 @@ def test_assemble_both_anchors_range(self, monkeypatch: pytest.MonkeyPatch) -> N names = _names(scale) assert names.index("discovered") < names.index("a.x") < names.index("backlog") - def test_assembly_anchors_around_new_axis( + def test_assembly_anchors_around_todo_axis( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - """Anchors around ``empty``/``new``/``defined`` stay resolvable on the nine-entry axis.""" + """Anchors around ``empty``/``todo``/``defined`` stay resolvable on the nine-entry axis.""" _fake_emission( monkeypatch, [ @@ -325,7 +344,7 @@ def test_assembly_anchors_around_new_axis( "x", _hook( {"name": "ranged", "filepath": "x/ranged.md", "after": "empty", "before": "defined"}, - {"name": "afternew", "filepath": "x/afternew.md", "after": "new"}, + {"name": "aftertodo", "filepath": "x/aftertodo.md", "after": "todo"}, ), ) ], @@ -335,7 +354,7 @@ def test_assembly_anchors_around_new_axis( names = _names(scale) assert names.index("empty") < names.index("x.ranged") < names.index("defined") - assert names.index("new") < names.index("x.afternew") < names.index("defined") + assert names.index("todo") < names.index("x.aftertodo") < names.index("defined") assert capsys.readouterr().err == "" def test_assemble_invalid_anchor_range_skips_with_warning( diff --git a/tests/history/statuses/test_scale.py b/tests/history/statuses/test_scale.py index b7fbb99a..97f3eabc 100644 --- a/tests/history/statuses/test_scale.py +++ b/tests/history/statuses/test_scale.py @@ -118,19 +118,19 @@ def test_maximal_present_empty_when_no_artifacts(self, builtin_scale: StatusScal assert builtin_scale.maximal_present([]) == ["empty"] assert builtin_scale.maximal_present(["notes.txt"]) == ["empty"] - def test_maximal_present_title_only_is_new(self, builtin_scale: StatusScale) -> None: - """The title artifact alone marks the built-in ``new`` entry.""" - assert builtin_scale.maximal_present(["title.txt"]) == ["new"] + def test_maximal_present_todo_mark_only(self, builtin_scale: StatusScale) -> None: + """The todo artifact alone marks the built-in ``todo`` entry.""" + assert builtin_scale.maximal_present(["todo.md"]) == ["todo"] - def test_maximal_present_title_with_prd_is_defined(self, builtin_scale: StatusScale) -> None: - """``title.txt`` below ``prd.md`` — the maximal entry wins, ``new`` is not duplicated.""" - assert builtin_scale.maximal_present(["title.txt", "prd.md"]) == ["defined"] + def test_maximal_present_todo_below_prd(self, builtin_scale: StatusScale) -> None: + """``todo.md`` below ``prd.md`` — the maximal entry wins, ``todo`` is not duplicated.""" + assert builtin_scale.maximal_present(["todo.md", "prd.md"]) == ["defined"] - def test_maximal_present_empty_and_title_interplay(self, builtin_scale: StatusScale) -> None: - """``empty`` against ``new``: no artifact and an off-scale artifact stay ``empty``.""" + def test_maximal_present_empty_and_todo_interplay(self, builtin_scale: StatusScale) -> None: + """``empty`` against ``todo``: no artifact and an off-scale artifact stay ``empty``.""" assert builtin_scale.maximal_present([]) == ["empty"] assert builtin_scale.maximal_present(["notes.txt"]) == ["empty"] - assert builtin_scale.maximal_present(["title.txt"]) == ["new"] + assert builtin_scale.maximal_present(["todo.md"]) == ["todo"] def test_maximal_present_two_incomparable_tool_statuses(self, builtin_scale: StatusScale) -> None: """Two tool entries sharing an anchor are incomparable — both stay maximal.""" From 230c4b4a45930befeaadc75d2c0b3650e22abb38 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Tue, 1 Sep 2026 17:31:01 +0000 Subject: [PATCH 161/229] feat: rename board title to todo summary with normalization --- goga/topics/board.py | 92 ++++++++++-------- goga/topics/switching.py | 2 +- tests/topics/conftest.py | 4 +- tests/topics/test_board.py | 190 ++++++++++++++++++++++++++++--------- 4 files changed, 198 insertions(+), 90 deletions(-) diff --git a/goga/topics/board.py b/goga/topics/board.py index 021e10fc..7ed0a35a 100644 --- a/goga/topics/board.py +++ b/goga/topics/board.py @@ -1,14 +1,14 @@ """The topic board of the topics domain. The entities declared in the cell CODEMANIFEST with ``location: board.py``: -one row of the board — a topic hosted by one branch with its title — and the -read-only collector that merges the branch inventory, the ref trees of one -year, and the working copy of the current branch into the sorted inventory -of statuses and titles. Git access follows the ``refs-and-switching`` -patterns of the nested git cell; topic identity, addressing, and statuses -belong to the history facade. Git infrastructure failures and the fatal -scale-assembly import failure surface as ``click.ClickException`` — the -clean-error boundary of the domain. +one row of the board — a topic hosted by one branch with its todo summary — +and the read-only collector that merges the branch inventory, the ref trees +of one year, and the working copy of the current branch into the sorted +inventory of statuses and todo summaries. Git access follows the +``refs-and-switching`` patterns of the nested git cell; topic identity, +addressing, and statuses belong to the history facade. Git infrastructure +failures and the fatal scale-assembly import failure surface as +``click.ClickException`` — the clean-error boundary of the domain. """ from __future__ import annotations @@ -33,11 +33,11 @@ from .git import BranchRef, list_branch_refs, read_ref_file, read_ref_tree_paths # One board row under construction — whether the hosting ref is -# remote-tracking, the row's maximal statuses, and the row's title. +# remote-tracking, the row's maximal statuses, and the row's todo summary. _Row = tuple[bool, list[str], str | None] -# The topic title file — the artifact of the ``new`` status entry. -_TITLE_FILE = "title.txt" +# The topic todo file — the artifact of the ``todo`` status entry. +_TODO_FILE = "todo.md" # The minimum part count of a topic path — ``.goga/history/<year>/<slug>/<artifact>``. _TOPIC_PATH_PARTS = 5 @@ -54,8 +54,10 @@ class BoardRecord: scale order. current: ``True`` when the row hosts the current working branch. remote: ``True`` when the hosting ref is remote-tracking. - title: The first line of the topic title file, or ``None`` when the - topic has no title file. + todo: The todo summary of the topic — the first line of todo.md + that yields a non-empty result after leading # markers are + stripped and the edges trimmed — or ``None`` when the topic has + no todo.md. """ topic: str @@ -63,13 +65,13 @@ class BoardRecord: statuses: list[str] current: bool remote: bool - title: str | None = None + todo: str | None = None def collect_topic_board( year: str | None = None, remote: bool = False ) -> list[BoardRecord]: - """Collect the cross-branch topic inventory of one year with titles. + """Collect the cross-branch topic inventory of one year with todo summaries. Args: year: Optional year as four digits; ``None`` means the current year. @@ -99,10 +101,14 @@ def collect_topic_board( artifact paths and compute the maximal statuses — the working copy via ``resolve_topic_status``, every other ref via the ``StatusScale`` - 6. Read the title of every hosted topic — the working copy from the - title file ``title.txt`` of its directory, every other ref from - the title file of its ref tree via ``read_ref_file``; the value is - the first line of the file, ``None`` when it is absent + 6. Read the todo summary of every hosted topic — the working copy + from the todo file ``todo.md`` of its directory, every other ref + from the todo.md of its ref tree via ``read_ref_file``; the + summary is the first line that yields a non-empty result after + leading # markers are stripped and the edges trimmed — the + normalization decides the choice, a line of # markers alone never + qualifies; ``None`` when the file is absent, the empty string + when no line qualifies; the file is never modified 7. Collapse a local branch and its remote twin into one row — the local branch wins; different branches hosting one slug stay separate rows @@ -114,9 +120,9 @@ def collect_topic_board( The current branch is read from the working copy — uncommitted progress is visible; remote mode shows it through its remote twin. - A multi-line title file yields its first line; an empty title file - yields an empty string — presence differs from absence. The title - never affects the sort order. + A multi-line todo.md yields its first qualifying line; a todo.md + whose every line reduces to emptiness yields the empty summary. The + todo summary never affects the sort order. Constraints: Do not render — output shaping belongs to the consumer. @@ -161,18 +167,18 @@ def _board_records(year: str | None, remote: bool) -> list[BoardRecord]: for ref in refs: if remote or current is None or ref.name != current: for slug, artifacts in topics_by_ref[ref.name].items(): - title_path = f"{prefix}{resolved_year}/{slug}/{_TITLE_FILE}" + todo_path = f"{prefix}{resolved_year}/{slug}/{_TODO_FILE}" rows[(slug, ref.name)] = ( ref.remote, scale.maximal_present(artifacts), - _first_line(read_ref_file(ref.name, title_path)), + _todo_summary(read_ref_file(ref.name, todo_path)), ) continue hosted = _current_branch_topic(current, resolved_year, scale) if hosted is None: continue - slug, statuses, title = hosted - rows[(slug, ref.name)] = (False, statuses, title) + slug, statuses, todo = hosted + rows[(slug, ref.name)] = (False, statuses, todo) records = [ BoardRecord( @@ -181,9 +187,9 @@ def _board_records(year: str | None, remote: bool) -> list[BoardRecord]: statuses=statuses, current=_marks_current(branch, current, remote), remote=is_remote, - title=title, + todo=todo, ) - for (slug, branch), (is_remote, statuses, title) in _collapse_remote_twins(rows).items() + for (slug, branch), (is_remote, statuses, todo) in _collapse_remote_twins(rows).items() ] scale_order = {stage.name: index for index, stage in enumerate(scale.stages)} records.sort(key=lambda record: (scale_order[record.statuses[0]], record.topic)) @@ -258,8 +264,8 @@ def _current_branch_topic( scale: The assembled status scale. Returns: - The current branch's slug with its maximal statuses and its title, - or ``None`` when the branch hosts no topic of the year. + The current branch's slug with its maximal statuses and its todo + summary, or ``None`` when the branch hosts no topic of the year. """ slug = normalize_topic_slug(current) if slug == "": @@ -267,25 +273,29 @@ def _current_branch_topic( if not topic_exists(current, year): return None topic_dir = resolve_topic_dir(current, year) - title = _first_line(_read_working(topic_dir / _TITLE_FILE)) - return slug, resolve_topic_status(topic_dir, scale), title + todo = _todo_summary(_read_working(topic_dir / _TODO_FILE)) + return slug, resolve_topic_status(topic_dir, scale), todo -def _first_line(content: str | None) -> str | None: - """Take the first line of a title file's content. +def _todo_summary(content: str | None) -> str | None: + """Take the todo summary of a todo file's content. Args: - content: The title file content, or ``None`` when the file is + content: The todo file content, or ``None`` when the file is absent. Returns: - The first line, ``""`` for an empty file, ``None`` for an absent - file — presence differs from absence. + The first line that yields a non-empty result after the leading # + markers are stripped and the edges trimmed, ``""`` when no line + qualifies, ``None`` for an absent file — presence differs from + absence. """ if content is None: return None - lines = content.splitlines() - return lines[0] if lines else "" + return next( + (line.lstrip("#").strip() for line in content.splitlines() if line.lstrip("#").strip()), + "", + ) def _read_working(path: Path) -> str | None: @@ -298,8 +308,8 @@ def _read_working(path: Path) -> str | None: The UTF-8 file content, or ``None`` when the file is absent — uncommitted progress is visible, a missing file is not an error. A file a hand edit left outside UTF-8 decodes with the replacement - character instead of raising — the title is display data, never a - reason to fail the board. + character instead of raising — the todo summary is display data, + never a reason to fail the board. """ return path.read_text(encoding="utf-8", errors="replace") if path.is_file() else None diff --git a/goga/topics/switching.py b/goga/topics/switching.py index 9b02df09..f96e0b0b 100644 --- a/goga/topics/switching.py +++ b/goga/topics/switching.py @@ -244,7 +244,7 @@ def _hosted_candidates( if working_copy is None: hosted.append((ref, None, [])) else: - slug, statuses, _title = working_copy + slug, statuses, _todo = working_copy hosted.append((ref, slug, statuses)) continue topics = topics_by_ref[ref.name] diff --git a/tests/topics/conftest.py b/tests/topics/conftest.py index 37abb308..12964615 100644 --- a/tests/topics/conftest.py +++ b/tests/topics/conftest.py @@ -10,13 +10,13 @@ def builtin_scale() -> StatusScale: """Deterministic built-in scale — nine entries with the contract artifacts. - The deepening order is the contract: empty, new, defined, discovered, + The deepening order is the contract: empty, todo, defined, discovered, backlog, designed, specified, planned, done. """ return StatusScale( stages=[ Stage(name="empty", filepath=""), - Stage(name="new", filepath="title.txt"), + Stage(name="todo", filepath="todo.md"), Stage(name="defined", filepath="prd.md"), Stage(name="discovered", filepath="adr.md"), Stage(name="backlog", filepath="task.md"), diff --git a/tests/topics/test_board.py b/tests/topics/test_board.py index c2c4a94a..1088717b 100644 --- a/tests/topics/test_board.py +++ b/tests/topics/test_board.py @@ -1,8 +1,8 @@ """Contract and logic tests for the entities declared in ``goga/topics/CODEMANIFEST`` with ``location: board.py``: -- ``BoardRecord(topic, branch, statuses, current, remote, title)`` — one row - of the topic board, a topic hosted by one branch with its title +- ``BoardRecord(topic, branch, statuses, current, remote, todo)`` — one row + of the topic board, a topic hosted by one branch with its todo summary - ``collect_topic_board(year, remote)`` — the read-only cross-branch topic inventory of one year @@ -65,7 +65,7 @@ def _wire_board( # noqa: PLR0913, PLR0917 — the five board patch points plus ) -> None: """Patch the board's import points: scale, git, trees, branch, files. - Without ``files`` every ref title reads as ``None`` — no title file at + Without ``files`` every ref todo reads as ``None`` — no todo.md at any ref. """ monkeypatch.setattr(board, "assemble_status_scale", lambda: scale) @@ -83,9 +83,9 @@ def _working_copy_topic(cwd: Path, year: str, slug: str, artifacts: list[str]) - path.write_text("artifact", encoding="utf-8") -def _working_title(cwd: Path, year: str, slug: str, content: str) -> None: - """Write the working-copy title file of a topic with the given content.""" - path = cwd / ".goga" / "history" / year / slug / "title.txt" +def _working_todo(cwd: Path, year: str, slug: str, content: str) -> None: + """Write the working-copy todo file of a topic with the given content.""" + path = cwd / ".goga" / "history" / year / slug / "todo.md" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") @@ -111,7 +111,7 @@ def _base_trees() -> dict[str, list[str]]: def _rows(records: list[BoardRecord]) -> list[tuple[str, str, list[str], bool, bool, str | None]]: - """The records as plain tuples — topic, branch, statuses, current, remote, title.""" + """The records as plain tuples — topic, branch, statuses, current, remote, todo.""" return [ ( record.topic, @@ -119,7 +119,7 @@ def _rows(records: list[BoardRecord]) -> list[tuple[str, str, list[str], bool, b record.statuses, record.current, record.remote, - record.title, + record.todo, ) for record in records ] @@ -149,7 +149,7 @@ def test_board_record_is_a_frozen_kw_only_dataclass(self) -> None: "statuses": list[str], "current": bool, "remote": bool, - "title": str | None, + "todo": str | None, } record = BoardRecord( topic="feat-a", branch="feat/a", statuses=["planned"], current=True, remote=False @@ -159,31 +159,31 @@ def test_board_record_is_a_frozen_kw_only_dataclass(self) -> None: assert record.statuses == ["planned"] assert record.current is True assert record.remote is False - assert record.title is None + assert record.todo is None with pytest.raises(dataclasses.FrozenInstanceError): record.topic = "other" # type: ignore[misc] with pytest.raises(TypeError): BoardRecord("feat-a", "feat/a", ["planned"], True, False) # type: ignore[misc] - def test_board_record_declares_title_field(self) -> None: - """The title field: ``str | None``, sixth, defaulting to ``None``.""" + def test_board_record_declares_todo_field(self) -> None: + """The todo field: ``str | None``, sixth, defaulting to ``None``.""" hints = typing.get_type_hints(BoardRecord) - assert hints["title"] == str | None + assert hints["todo"] == str | None assert [field.name for field in dataclasses.fields(BoardRecord)] == [ "topic", "branch", "statuses", "current", "remote", - "title", + "todo", ] - # The default keeps every pre-title constructor valid. + # The default keeps every pre-todo constructor valid. record = BoardRecord(topic="a", branch="b", statuses=[], current=False, remote=False) - assert record.title is None - titled = BoardRecord( - topic="a", branch="b", statuses=[], current=False, remote=False, title="Payment retry" + assert record.todo is None + with_todo = BoardRecord( + topic="a", branch="b", statuses=[], current=False, remote=False, todo="Payment retry" ) - assert titled.title == "Payment retry" + assert with_todo.todo == "Payment retry" def test_collect_topic_board_signature(self) -> None: """``collect_topic_board(year=None, remote=False) -> list[BoardRecord]``.""" @@ -263,43 +263,43 @@ def test_collect_topic_board_year_without_topics_empty( assert collect_topic_board("2030") == [] - def test_collect_topic_board_reads_titles_local_and_ref( + def test_collect_topic_board_reads_todo_summaries_local_and_ref( self, builtin_scale: StatusScale, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Titles: the working copy for the current branch, ref trees for the rest.""" + """Todos: the working copy for the current branch, ref trees for the rest.""" monkeypatch.chdir(tmp_path) _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) - _working_title(tmp_path, "2026", "feat-a", "Local title\nsecond\n") + _working_todo(tmp_path, "2026", "feat-a", "Local summary\nsecond\n") trees = { **_base_trees(), # Without a year topic on main there is no main row — and the - # absent-title case stays unmeasured. + # absent-todo case stays unmeasured. "main": [".goga/history/2026/main-only/prd.md", "README.md"], } - files = {("origin/feat/b", ".goga/history/2026/feat-b/title.txt"): "Remote title\n"} + files = {("origin/feat/b", ".goga/history/2026/feat-b/todo.md"): "Remote summary\n"} _wire_board(monkeypatch, builtin_scale, _base_inventory(), trees, "feat/a", files) records = collect_topic_board("2026") assert _rows(records) == [ - ("feat-b", "origin/feat/b", ["defined"], False, True, "Remote title"), + ("feat-b", "origin/feat/b", ["defined"], False, True, "Remote summary"), ("main-only", "main", ["defined"], False, False, None), - ("feat-a", "feat/a", ["planned"], True, False, "Local title"), + ("feat-a", "feat/a", ["planned"], True, False, "Local summary"), ] - def test_collect_topic_board_title_first_line_and_empty( + def test_collect_topic_board_todo_first_qualifying_line_and_empty( self, builtin_scale: StatusScale, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """A multi-line title yields its first line; empty stays empty; absent is None.""" + """A multi-line todo yields its first qualifying line; empty stays empty; absent is None.""" monkeypatch.chdir(tmp_path) _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) - _working_title(tmp_path, "2026", "feat-a", "A\nB\n") + _working_todo(tmp_path, "2026", "feat-a", "# A\nB\n") inventory = [ BranchRef(name="feat/a", remote=False), BranchRef(name="feat/b", remote=False), @@ -310,61 +310,61 @@ def test_collect_topic_board_title_first_line_and_empty( "feat/b": [".goga/history/2026/feat-b/plan.md"], "feat/c": [".goga/history/2026/feat-c/plan.md"], } - files = {("feat/b", ".goga/history/2026/feat-b/title.txt"): ""} + files = {("feat/b", ".goga/history/2026/feat-b/todo.md"): ""} _wire_board(monkeypatch, builtin_scale, inventory, trees, "feat/a", files) records = collect_topic_board("2026") - # All planned — the order is the slug alphabet, never the titles. + # All planned — the order is the slug alphabet, never the todos. assert _rows(records) == [ ("feat-a", "feat/a", ["planned"], True, False, "A"), ("feat-b", "feat/b", ["planned"], False, False, ""), ("feat-c", "feat/c", ["planned"], False, False, None), ] - def test_collect_topic_board_title_only_topic_is_new( + def test_collect_topic_board_todo_only_topic_is_todo( self, builtin_scale: StatusScale, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """A topic whose only artifact is the title file carries ``new``.""" + """A topic whose only artifact is the todo file carries ``todo``.""" monkeypatch.chdir(tmp_path) - _working_title(tmp_path, "2026", "feat-a", "Local title\n") + _working_todo(tmp_path, "2026", "feat-a", "Local summary\n") trees = { **_base_trees(), - "feat/a": [".goga/history/2026/feat-a/title.txt"], - "origin/feat/b": [".goga/history/2026/feat-b/title.txt"], + "feat/a": [".goga/history/2026/feat-a/todo.md"], + "origin/feat/b": [".goga/history/2026/feat-b/todo.md"], } - files = {("origin/feat/b", ".goga/history/2026/feat-b/title.txt"): "Remote title\n"} + files = {("origin/feat/b", ".goga/history/2026/feat-b/todo.md"): "Remote summary\n"} _wire_board(monkeypatch, builtin_scale, _base_inventory(), trees, "feat/a", files) records = collect_topic_board("2026") - # title.txt is the artifact of new — on the working-copy path and on - # the ref-tree path alike; the titles ride along. + # todo.md is the artifact of todo — on the working-copy path and on + # the ref-tree path alike; the summaries ride along. assert _rows(records) == [ - ("feat-a", "feat/a", ["new"], True, False, "Local title"), - ("feat-b", "origin/feat/b", ["new"], False, True, "Remote title"), + ("feat-a", "feat/a", ["todo"], True, False, "Local summary"), + ("feat-b", "origin/feat/b", ["todo"], False, True, "Remote summary"), ] - def test_collect_topic_board_survives_undecodable_working_title( + def test_collect_topic_board_survives_undecodable_working_todo( self, builtin_scale: StatusScale, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """A hand-edited non-UTF-8 working title degrades — the board lives.""" + """A hand-edited non-UTF-8 working todo degrades — the board lives.""" monkeypatch.chdir(tmp_path) _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) - title_path = tmp_path / ".goga" / "history" / "2026" / "feat-a" / "title.txt" - title_path.write_bytes(b"Pay\xffment\n") + todo_path = tmp_path / ".goga" / "history" / "2026" / "feat-a" / "todo.md" + todo_path.write_bytes(b"Pay\xffment\n") _wire_board(monkeypatch, builtin_scale, _base_inventory(), _base_trees(), "feat/a") records = collect_topic_board("2026") # The read replaces the undecodable byte instead of raising through - # the clean-error boundary — the title is display data. + # the clean-error boundary — the todo summary is display data. assert _rows(records) == [ ("feat-b", "origin/feat/b", ["defined"], False, True, None), ("feat-a", "feat/a", ["planned"], True, False, "Pay�ment"), @@ -442,6 +442,104 @@ def test_board_sees_only_committed_artifacts_on_other_refs( # uncommitted artifact is invisible there. assert remote_rows == [("feat-a", "origin/feat/a", ["empty"], True, True, None)] + def test_collect_board_todo_summary_from_ref_tree( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A ref-hosted topic reads its todo summary from the tree — no checkout.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="feat/a", remote=False)] + trees = {"feat/a": [".goga/history/2026/feat-a/todo.md", ".goga/history/2026/feat-a/prd.md"]} + files = {("feat/a", ".goga/history/2026/feat-a/todo.md"): "###\n## Pay retry cap\nbody\n"} + _wire_board(monkeypatch, builtin_scale, inventory, trees, None, files) + + records = collect_topic_board(year="2026") + + assert records[0].statuses == ["defined"] + # The normalization decides the choice of line — the markers-only + # first line never qualifies. + assert records[0].todo == "Pay retry cap" + + def test_collect_board_todo_summary_working_copy( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The current branch's row reads the working copy — and never edits it.""" + monkeypatch.chdir(tmp_path) + todo_path = tmp_path / ".goga" / "history" / "2026" / "feat-a" / "todo.md" + todo_path.parent.mkdir(parents=True, exist_ok=True) + todo_path.write_text("# WIP summary\n", encoding="utf-8") + before = todo_path.read_bytes() + inventory = [BranchRef(name="feat/a", remote=False)] + trees = {"feat/a": [".goga/history/2026/feat-a/todo.md"]} + monkeypatch.setattr(board, "assemble_status_scale", lambda: builtin_scale) + monkeypatch.setattr(board, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(board, "resolve_current_branch_name", lambda: "feat/a") + monkeypatch.setattr(board, "read_ref_tree_paths", _trees_reader(trees)) + read_ref_file = mock.Mock(return_value=None) + monkeypatch.setattr(board, "read_ref_file", read_ref_file) + + records = collect_topic_board(year="2026") + + assert records[0].todo == "WIP summary" + assert records[0].current is True + # The working ref never goes through read_ref_file — the summary + # comes from the working copy, uncommitted progress included. + read_ref_file.assert_not_called() + # The stripping is for display — the file itself is untouched. + assert todo_path.read_bytes() == before + + def test_todo_summary_absent_file_yields_none( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A topic with an artifact but no todo.md reads ``None`` — absence.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="feat/a", remote=False)] + trees = {"feat/a": [".goga/history/2026/feat-a/prd.md"]} + _wire_board(monkeypatch, builtin_scale, inventory, trees, None) + + records = collect_topic_board(year="2026") + + assert records[0].statuses == ["defined"] + assert records[0].todo is None + + def test_board_old_title_txt_only_is_empty_status( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A topic carrying only the retired title.txt is ``empty`` — a clean break.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="feat/a", remote=False)] + trees = {"feat/a": [".goga/history/2026/feat-a/title.txt"]} + files = {("feat/a", ".goga/history/2026/feat-a/title.txt"): "Retired\n"} + _wire_board(monkeypatch, builtin_scale, inventory, trees, None, files) + + records = collect_topic_board(year="2026") + + # title.txt stopped being an artifact — the topic has nothing the + # scale recognizes, and no todo.md to summarize. + assert records[0].statuses == ["empty"] + assert records[0].todo is None + + +class TestTodoSummaryNormalization: + def test_todo_summary_markers_only_yields_empty(self) -> None: + """Lines of # markers alone never qualify — the file exists, the summary is empty.""" + assert board._todo_summary("###\n##\n#\n") == "" + + def test_todo_summary_marker_not_at_line_start(self) -> None: + """A marker after leading blanks is text — only line-start markers strip.""" + assert board._todo_summary(" # indented marker\n") == "# indented marker" + class TestBoardInfrastructureBoundary: def test_git_failure_surfaces_as_clean_error( From 55de7e3f03fc67b72975043cf7f7386631823b52 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Tue, 1 Sep 2026 17:36:24 +0000 Subject: [PATCH 162/229] feat: rename create_topic title param to todo with truthy write gate --- goga/topics/creation.py | 71 ++++++++------- tests/topics/test_creation.py | 162 +++++++++++++++++++--------------- 2 files changed, 127 insertions(+), 106 deletions(-) diff --git a/goga/topics/creation.py b/goga/topics/creation.py index b1d5aeae..a6cc1295 100644 --- a/goga/topics/creation.py +++ b/goga/topics/creation.py @@ -6,9 +6,10 @@ across every branch tree of the inventory — without checkout, so a topic hosted only on a branch (or only on ``origin``) is visible — and the orchestrator that creates the branch — named exactly as entered -— together with its topic directory of the year and, when a title is given, -its topic title file. Topic identity and addressing belong to the history -facade; the bounded git mutation belongs to the nested git cell. Git +— together with its topic directory of the year and, when a non-empty +todo is given, its topic todo file. Topic identity and addressing belong +to the history facade; the bounded git mutation belongs to the nested git +cell. Git infrastructure failures surface as ``click.ClickException`` — the clean-error boundary of the domain; the interactive moments follow the ``click`` practice. The status scale is never assembled here — creation is @@ -126,15 +127,16 @@ def check_slug_occupancy(slug: str, year: str | None = None) -> str | None: def create_topic( - branch_name: str, year: str | None = None, title: str | None = None + branch_name: str, year: str | None = None, todo: str | None = None ) -> str: """Create fresh work — a branch with the name as entered, its topic - directory of the year, and an optional topic title. + directory of the year, and an optional multi-line todo. Args: branch_name: Branch name as entered by the user. year: Optional year as four digits; ``None`` means the current year. - title: Optional topic title; ``None`` writes no title file. + todo: Optional multi-line todo of the fresh work; ``None`` or an + empty string writes no todo.md. Returns: One line describing the outcome — the created work, or the @@ -146,28 +148,30 @@ def create_topic( interactive terminal and restart, or fail with the reason otherwise 3. The current branch — read via ``resolve_current_branch_name`` — - hosts the same slug -> the idempotent path: a ``title`` given - writes the topic title file ``title.txt`` of the ensured topic - directory; no ``title`` is a success without mutation; no + hosts the same slug -> the idempotent path: a non-empty ``todo`` + writes the topic todo file ``todo.md`` of the ensured topic + directory; no ``todo`` is a success without mutation; no occupancy check, no switch 4. ``check_branch_occupancy`` reports a conflict -> print the reason with a hint to the board, prompt for a new name on an interactive terminal and restart, or fail otherwise 5. Free name -> create the branch named exactly as entered and switch to it via ``create_and_switch_branch``, create the topic - directory via ``ensure_topic_dir`` of the year, and a ``title`` - given writes the title file ``title.txt`` of the topic directory + directory via ``ensure_topic_dir`` of the year, and a non-empty + ``todo`` writes the todo file ``todo.md`` of the topic directory 6. Return the single result line Requirements: The branch keeps the name as entered; the topic directory takes the slug — the two may deliberately differ. - The title file carries ``title`` as entered plus a single trailing - newline, encoded UTF-8. - The title file is written only when ``title`` is given — ``None`` - never creates and never overwrites it; an explicit ``title`` creates - the file or overwrites it. - The topic directory exists before the title file is written. + The todo.md file carries ``todo`` as entered plus a single trailing + newline, encoded UTF-8 — empty lines inside the text stay as + entered. + The todo.md file is written only when a non-empty ``todo`` is + given — ``None`` or an empty string never creates and never + overwrites it; an explicit ``todo`` creates the file or overwrites + it. + The topic directory exists before the todo.md file is written. An aborted re-ask leaves the repository untouched. The caller stays on the new branch. @@ -175,7 +179,7 @@ def create_topic( Do not validate branch-name characters — git owns name validity. Do not auto-pick suffixed names on a conflict — the user re-asks or aborts. - Do not write artifact files other than the topic title file inside + Do not write artifact files other than the topic todo file inside the topic directory. Raises: @@ -186,7 +190,7 @@ def create_topic( left untouched. """ try: - return _create_topic(branch_name, year, title) + return _create_topic(branch_name, year, todo) except subprocess.CalledProcessError as exc: detail = (exc.stderr or "").strip() or str(exc) raise click.ClickException(f"git failed: {detail}") from exc @@ -196,9 +200,9 @@ def create_topic( # ``ensure_topic_dir`` propagates the mkdir failures — a stray file # named like the slug occupies no topic for the oracle, so the # failure can only surface here, after the branch was created. The - # title write shares the boundary: one clean error for both. + # todo write shares the boundary: one clean error for both. raise click.ClickException( - f"cannot create the topic directory or write the title file: {exc}" + f"cannot create the topic directory or write the todo file: {exc}" ) from exc @@ -250,13 +254,14 @@ def _slug_conflict(slug: str, year: str | None) -> str | None: return None -def _create_topic(branch_name: str, year: str | None, title: str | None) -> str: +def _create_topic(branch_name: str, year: str | None, todo: str | None) -> str: """Run the traced creation procedure — the unwrapped orchestration. Args: branch_name: Branch name as entered by the user. year: Optional year as four digits; ``None`` means the current year. - title: Optional topic title; ``None`` writes no title file. + todo: Optional multi-line todo of the fresh work; ``None`` or an + empty string writes no todo.md. Returns: The single result line of the outcome. @@ -272,9 +277,9 @@ def _create_topic(branch_name: str, year: str | None, title: str | None) -> str: current = resolve_current_branch_name() if current is not None and normalize_topic_slug(current) == slug: - if title is not None: + if todo: ensure_topic_dir(branch_name, resolved_year) - _write_title(branch_name, resolved_year, title) + _write_todo(branch_name, resolved_year, todo) return f"Branch {current} already hosts topic {resolved_year}/{slug}" conflict = check_branch_occupancy(branch_name, slug, resolved_year) @@ -284,25 +289,25 @@ def _create_topic(branch_name: str, year: str | None, title: str | None) -> str: create_and_switch_branch(branch_name) ensure_topic_dir(branch_name, resolved_year) - if title is not None: - _write_title(branch_name, resolved_year, title) + if todo: + _write_todo(branch_name, resolved_year, todo) return f"Created branch {branch_name} and topic {resolved_year}/{slug}" -def _write_title(name: str, year: str, title: str) -> None: - """Write the topic title file of a topic directory. +def _write_todo(name: str, year: str, todo: str) -> None: + """Write the topic todo file of a topic directory. - The file carries the title as entered plus a single trailing newline, + The file carries the todo as entered plus a single trailing newline, encoded UTF-8 — created when absent, overwritten when present. The topic directory must already exist; only directories are created here. Args: name: Topic input — a branch name or an already-normalized slug. year: Year as four digits. - title: Topic title as entered by the user. + todo: Multi-line todo of the fresh work as entered by the user. """ - resolve_topic_file(name, "title.txt", year).write_text( - f"{title}\n", encoding="utf-8" + resolve_topic_file(name, "todo.md", year).write_text( + f"{todo}\n", encoding="utf-8" ) diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index e6d927cc..e645cad8 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -5,8 +5,8 @@ occupancy check of a fresh-work name - ``check_slug_occupancy(slug, year)`` — the branch-tree occupancy oracle of a topic slug -- ``create_topic(branch_name, year, title)`` — the fresh-work creation - procedure with its optional topic title file +- ``create_topic(branch_name, year, todo)`` — the fresh-work creation + procedure with its optional topic todo file The git boundary is mocked at the import point per the ``convention`` practice — no git binary and no repository are touched. The filesystem @@ -154,20 +154,20 @@ def test_check_branch_occupancy_signature(self) -> None: } def test_create_topic_signature(self) -> None: - """``create_topic(branch_name, year=None, title=None) -> str``.""" + """``create_topic(branch_name, year=None, todo=None) -> str``.""" signature = inspect.signature(create_topic) - assert list(signature.parameters) == ["branch_name", "year", "title"] + assert list(signature.parameters) == ["branch_name", "year", "todo"] assert all( parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None - assert signature.parameters["title"].default is None + assert signature.parameters["todo"].default is None hints = typing.get_type_hints(create_topic) assert hints == { "branch_name": str, "year": str | None, - "title": str | None, + "todo": str | None, "return": str, } @@ -269,7 +269,7 @@ def test_check_slug_occupancy_returns_first_hosting_branch( BranchRef(name="beta", remote=True), ] reader = mock.Mock( - side_effect=[[], [".goga/history/2026/feature-foo/title.txt"]] + side_effect=[[], [".goga/history/2026/feature-foo/todo.md"]] ) listing = _wire_slug_oracle(monkeypatch, inventory, reader) @@ -293,9 +293,9 @@ def test_check_slug_occupancy_stops_at_first_hit( ] reader = mock.Mock( side_effect=[ - [".goga/history/2026/feature-foo/title.txt"], - [".goga/history/2026/feature-foo/title.txt"], - [".goga/history/2026/feature-foo/title.txt"], + [".goga/history/2026/feature-foo/todo.md"], + [".goga/history/2026/feature-foo/todo.md"], + [".goga/history/2026/feature-foo/todo.md"], ] ) _wire_slug_oracle(monkeypatch, inventory, reader) @@ -334,7 +334,7 @@ def test_check_slug_occupancy_does_not_match_sibling_slug_prefix( """ monkeypatch.chdir(tmp_path) inventory = [BranchRef(name="alpha", remote=False)] - paths = [".goga/history/2026/feature-foo-bar/title.txt"] + paths = [".goga/history/2026/feature-foo-bar/todo.md"] received: list[str] = [] def emulate_reader(ref: str, prefix: str) -> list[str]: @@ -352,7 +352,7 @@ def test_check_slug_occupancy_ignores_disk_only_topics( """A topic living only in the working copy is invisible to this oracle.""" monkeypatch.chdir(tmp_path) topic_dir = _topic_dir(tmp_path, "2026", "feature-foo") - (topic_dir / "title.txt").write_text("On disk only\n", encoding="utf-8") + (topic_dir / "todo.md").write_text("On disk only\n", encoding="utf-8") inventory = [BranchRef(name="alpha", remote=False)] reader = mock.Mock(return_value=[]) _wire_slug_oracle(monkeypatch, inventory, reader) @@ -365,7 +365,7 @@ def test_check_slug_occupancy_default_year_is_current( """``year=None`` resolves to the current year — the probe is year-scoped.""" monkeypatch.chdir(tmp_path) inventory = [BranchRef(name="alpha", remote=False)] - reader = mock.Mock(return_value=[".goga/history/2026/feature-foo/title.txt"]) + reader = mock.Mock(return_value=[".goga/history/2026/feature-foo/todo.md"]) _wire_slug_oracle(monkeypatch, inventory, reader) monkeypatch.setattr(creation, "current_year", lambda: "2026") @@ -408,10 +408,10 @@ def test_create_topic_default_year_is_current( create_and_switch.assert_called_once_with("Feature/Foo_Bar") assert (tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar").is_dir() - def test_create_topic_with_title_fresh_path( + def test_create_topic_with_todo_fresh_path( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A free name with a title: the branch, the directory, the title file.""" + """A free name with a todo: the branch, the directory, the todo file.""" monkeypatch.chdir(tmp_path) create_and_switch = _wire_inventory(monkeypatch, [], current="main") @@ -419,10 +419,33 @@ def test_create_topic_with_title_fresh_path( assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" create_and_switch.assert_called_once_with("Feature/Foo_Bar") - title_file = ( - tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" / "title.txt" + todo_file = ( + tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" / "todo.md" ) - assert title_file.read_bytes() == b"Payment retry\n" + assert todo_file.read_bytes() == b"Payment retry\n" + + def test_create_topic_writes_multiline_todo( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A multi-line todo: the file carries the text verbatim plus one newline.""" + monkeypatch.chdir(tmp_path) + create_and_switch = _wire_inventory(monkeypatch, [], current="main") + + result = create_topic( + "Feature/Foo_Bar", + year="2026", + todo="Fix payment retries.\n\nRetries ignore the cap.", + ) + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + create_and_switch.assert_called_once_with("Feature/Foo_Bar") + topic_dir = tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" + # Empty lines inside the text stay as entered; one trailing newline. + assert (topic_dir / "todo.md").read_bytes() == ( + b"Fix payment retries.\n\nRetries ignore the cap.\n" + ) + # The todo file is the single artifact of the topic directory. + assert [path.name for path in topic_dir.iterdir()] == ["todo.md"] def test_create_topic_idempotent_current_host( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -444,10 +467,10 @@ def test_create_topic_idempotent_current_host( create_and_switch.assert_not_called() ensure_dir.assert_not_called() - def test_create_topic_without_title_writes_no_title_file( + def test_create_topic_without_todo_writes_no_todo_file( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A free name without a title: the topic directory carries no title file.""" + """A free name without a todo: the topic directory carries no todo file.""" monkeypatch.chdir(tmp_path) _wire_inventory(monkeypatch, [], current="main") monkeypatch.setattr(creation, "current_year", lambda: "2026") @@ -457,68 +480,61 @@ def test_create_topic_without_title_writes_no_title_file( assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" topic_dir = tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" assert topic_dir.is_dir() - assert not (topic_dir / "title.txt").exists() + assert not (topic_dir / "todo.md").exists() - def test_create_topic_without_title_leaves_existing_title_file( + def test_create_topic_empty_string_todo_writes_nothing( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """The idempotent path without a title: an existing title file stays verbatim.""" + """An empty todo string writes no file — truthiness, not ``is not None``.""" monkeypatch.chdir(tmp_path) - topic_dir = _topic_dir(tmp_path, "2026", "feature-foo") - (topic_dir / "title.txt").write_text("Old\n", encoding="utf-8") - create_and_switch = _wire_inventory(monkeypatch, [], current="feature-foo") + # A genuinely free name over tmp_path: the inventory is empty and the + # real topic oracle finds no directory. + create_and_switch = _wire_inventory(monkeypatch, [], current="main") - result = create_topic("feature-foo") + result = create_topic("feat-a", year="2026", todo="") - assert result == "Branch feature-foo already hosts topic 2026/feature-foo" - create_and_switch.assert_not_called() - assert (topic_dir / "title.txt").read_text(encoding="utf-8") == "Old\n" + assert result == "Created branch feat-a and topic 2026/feat-a" + create_and_switch.assert_called_once_with("feat-a") + topic_dir = tmp_path / ".goga" / "history" / "2026" / "feat-a" + assert topic_dir.is_dir() + # The empty string never creates the file — no bare-newline todo.md. + assert not (topic_dir / "todo.md").exists() - def test_create_topic_with_title_idempotent_path( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + @pytest.mark.parametrize("todo", [None, ""]) + def test_create_topic_idempotent_without_todo_leaves_file( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, todo: str | None ) -> None: - """The current host with an explicit title: ensure, overwrite, no switch.""" - monkeypatch.chdir(tmp_path) - topic_dir = _topic_dir(tmp_path, "2026", "feature-foo") - (topic_dir / "title.txt").write_text("Old\n", encoding="utf-8") - create_and_switch = _wire_inventory(monkeypatch, [], current="feature-foo") - - result = create_topic("feature-foo", "2026", "New title") - - assert result == "Branch feature-foo already hosts topic 2026/feature-foo" - create_and_switch.assert_not_called() - assert (topic_dir / "title.txt").read_text(encoding="utf-8") == "New title\n" + """The idempotent path without a todo: an existing todo file stays verbatim. - def test_create_topic_empty_title_writes_bare_newline( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """An explicit empty title writes the file — the empty string is not None.""" + ``None`` and the empty string behave alike — neither creates nor + overwrites; the regression guard against the old + ``if title is not None`` condition, where ``""`` wiped the file. + """ monkeypatch.chdir(tmp_path) - create_and_switch = _wire_inventory(monkeypatch, [], current="main") + topic_dir = _topic_dir(tmp_path, "2026", "feat-a") + (topic_dir / "todo.md").write_text("Old\n", encoding="utf-8") + create_and_switch = _wire_inventory(monkeypatch, [], current="feat-a") - result = create_topic("feat-a", "2026", "") + result = create_topic("feat-a", year="2026", todo=todo) - assert result == "Created branch feat-a and topic 2026/feat-a" - create_and_switch.assert_called_once_with("feat-a") - title_file = tmp_path / ".goga" / "history" / "2026" / "feat-a" / "title.txt" - # The explicit empty title creates the file — one bare newline, which - # earns the new status and renders as an empty title cell. - assert title_file.read_bytes() == b"\n" + assert result == "Branch feat-a already hosts topic 2026/feat-a" + create_and_switch.assert_not_called() + assert (topic_dir / "todo.md").read_text(encoding="utf-8") == "Old\n" - def test_create_topic_empty_title_overwrites_on_idempotent_path( + def test_create_topic_idempotent_overwrites_todo( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """An explicit empty title overwrites an existing title on the current host.""" + """The current host with an explicit todo: ensure, overwrite, no switch.""" monkeypatch.chdir(tmp_path) - topic_dir = _topic_dir(tmp_path, "2026", "feature-foo") - (topic_dir / "title.txt").write_text("Old\n", encoding="utf-8") - create_and_switch = _wire_inventory(monkeypatch, [], current="feature-foo") + topic_dir = _topic_dir(tmp_path, "2026", "feat-a") + (topic_dir / "todo.md").write_text("Old\n", encoding="utf-8") + create_and_switch = _wire_inventory(monkeypatch, [], current="feat-a") - result = create_topic("feature-foo", "2026", "") + result = create_topic("feat-a", year="2026", todo="New summary") - assert result == "Branch feature-foo already hosts topic 2026/feature-foo" + assert result == "Branch feat-a already hosts topic 2026/feat-a" create_and_switch.assert_not_called() - assert (topic_dir / "title.txt").read_bytes() == b"\n" + assert (topic_dir / "todo.md").read_text(encoding="utf-8") == "New summary\n" def test_create_topic_occupied_non_interactive_clean_error( self, @@ -622,10 +638,10 @@ def test_create_topic_reask_abort_leaves_repository_untouched( create_and_switch.assert_not_called() assert not (tmp_path / ".goga").exists() - def test_create_topic_title_write_failure_is_clean_error( + def test_create_topic_todo_write_failure_is_clean_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A failing title write becomes the generalized clean error.""" + """A failing todo write becomes the generalized clean error.""" monkeypatch.chdir(tmp_path) create_and_switch = _wire_inventory(monkeypatch, [], current="main") monkeypatch.setattr( @@ -638,16 +654,16 @@ def test_create_topic_title_write_failure_is_clean_error( create_topic("Feature/Foo_Bar", "2026", "T") assert ( - "cannot create the topic directory or write the title file" + "cannot create the topic directory or write the todo file" in raised.value.message ) - # The traced order — the branch mutation runs before the title write. + # The traced order — the branch mutation runs before the todo write. create_and_switch.assert_called_once_with("Feature/Foo_Bar") - def test_create_topic_title_survives_reask( + def test_create_topic_todo_survives_reask( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """The title is a procedure parameter — a re-asked name keeps it.""" + """The todo is a procedure parameter — a re-asked name keeps it.""" monkeypatch.chdir(tmp_path) prompt = _interactive(monkeypatch, ["Feature/Foo_Bar"]) create_and_switch = _wire_inventory(monkeypatch, [], current="main") @@ -657,10 +673,10 @@ def test_create_topic_title_survives_reask( assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" create_and_switch.assert_called_once_with("Feature/Foo_Bar") assert prompt.call_count == 1 - title_file = ( - tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" / "title.txt" + todo_file = ( + tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" / "todo.md" ) - assert title_file.read_text(encoding="utf-8") == "T\n" + assert todo_file.read_text(encoding="utf-8") == "T\n" # --- Infrastructure boundary --- @@ -792,7 +808,7 @@ def test_stray_file_at_topic_path_surfaces_as_clean_error( create_topic("feat-x", year="2026") assert ( - "cannot create the topic directory or write the title file" + "cannot create the topic directory or write the todo file" in raised.value.message ) assert "feat-x" in raised.value.message From 92371e1de1de03bbd3983d679a6a2dc2e193d7b0 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Tue, 1 Sep 2026 17:39:41 +0000 Subject: [PATCH 163/229] feat: rename publish_topic title param to todo with empty-todo gate --- goga/topics/publishing.py | 37 ++++++---- tests/topics/git/test_publish.py | 20 ++--- tests/topics/git/test_trees.py | 10 +-- tests/topics/test_publishing.py | 123 ++++++++++++++++++++----------- 4 files changed, 120 insertions(+), 70 deletions(-) diff --git a/goga/topics/publishing.py b/goga/topics/publishing.py index 09f274be..6dfc84ba 100644 --- a/goga/topics/publishing.py +++ b/goga/topics/publishing.py @@ -3,7 +3,7 @@ The entity declared in the cell CODEMANIFEST with ``location: publishing.py``: the fast cycle that creates fresh work and publishes it in one go — a branch off an explicit base carrying exactly one -commit with the topic title file, pushed to origin, while the caller stays +commit with the topic todo file, pushed to origin, while the caller stays on their branch. Every decision is made before the first mutation; the mutation sequence is the quarantined commit build, the branch plant, and the push, and a failed publication rolls back fully — the planted branch @@ -39,19 +39,21 @@ def publish_topic( branch_name: str, - title: str, + todo: str, base_ref: str, commit_message: str, year: str | None = None, ) -> str: """Create fresh work and publish it — a branch off an explicit base - carrying one commit with the topic title, pushed to origin, while the + carrying one commit with the topic todo, pushed to origin, while the caller stays on their branch. Args: branch_name: Branch name as entered by the user. - title: Topic title — written to the title file as entered plus a - single trailing newline. + todo: The multi-line todo of the fresh work — written to the topic + todo file todo.md as entered plus a single trailing newline; + required and non-empty, an empty todo is a clean error asking + for it. base_ref: Base revision the branch starts from — any revision string, resolved as git resolves it. commit_message: Commit message template — the ``{slug}`` @@ -63,15 +65,16 @@ def publish_topic( One line describing the created and published work. Raises: - click.ClickException: the current branch already hosting the slug, - a missing origin remote, an unresolved occupancy conflict - without a terminal, a git infrastructure failure (its stderr - when git reports one, or a missing git binary). + click.ClickException: an empty todo, the current branch already + hosting the slug, a missing origin remote, an unresolved + occupancy conflict without a terminal, a git infrastructure + failure (its stderr when git reports one, or a missing git + binary). click.Abort: Ctrl-C or EOF at the re-ask prompt — nothing was mutated. """ try: - return _publish_topic(branch_name, title, base_ref, commit_message, year) + return _publish_topic(branch_name, todo, base_ref, commit_message, year) except subprocess.CalledProcessError as exc: detail = (exc.stderr or "").strip() or str(exc) raise click.ClickException(f"git failed: {detail}") from exc @@ -88,7 +91,7 @@ def publish_topic( def _publish_topic( branch_name: str, - title: str, + todo: str, base_ref: str, commit_message: str, year: str | None, @@ -97,7 +100,7 @@ def _publish_topic( Args: branch_name: Branch name as entered by the user. - title: Topic title as entered by the user. + todo: The multi-line todo of the fresh work as entered by the user. base_ref: Base revision the branch starts from. commit_message: Commit message template with ``{slug}`` optional. year: Optional year as four digits; ``None`` means the current year. @@ -114,6 +117,12 @@ def _publish_topic( branch_name = _reask(reason) continue + if not todo: + raise click.ClickException( + "the fast path needs a non-empty todo" + " — pass the text or enter it interactively" + ) + current = resolve_current_branch_name() if current is not None and normalize_topic_slug(current) == slug: raise click.ClickException( @@ -135,11 +144,11 @@ def _publish_topic( base_commit = resolve_ref_commit(base_ref) - path = resolve_topic_file(slug, "title.txt", resolved_year).as_posix() + path = resolve_topic_file(slug, "todo.md", resolved_year).as_posix() commit = commit_file_on_base( base_commit, path, - f"{title}\n", + f"{todo}\n", commit_message.replace("{slug}", slug), ) diff --git a/tests/topics/git/test_publish.py b/tests/topics/git/test_publish.py index 81dd0a58..943d8b97 100644 --- a/tests/topics/git/test_publish.py +++ b/tests/topics/git/test_publish.py @@ -37,9 +37,9 @@ resolve_ref_commit, ) -_TITLE_PATH = ".goga/history/2026/feature-foo/title.txt" -_TITLE_CONTENT = "Payment retry\n" -_TITLE_MESSAGE = "goga: create topic feature-foo" +_TODO_PATH = ".goga/history/2026/feature-foo/todo.md" +_TODO_CONTENT = "Payment retry\n" +_TODO_MESSAGE = "goga: create topic feature-foo" def _git_answer(stdout: str = "") -> subprocess.CompletedProcess[str]: @@ -140,22 +140,22 @@ def answer_by_argv(command: list[str], **_kwargs: object) -> subprocess.Complete ("rev-parse", "--git-dir"): str(git_dir), ("hash-object", "-w", "--stdin"): "<blob>", ("write-tree",): "<tree>", - ("commit-tree", "<tree>", "-p", "<base>", "-m", _TITLE_MESSAGE): "<commit>", + ("commit-tree", "<tree>", "-p", "<base>", "-m", _TODO_MESSAGE): "<commit>", }.get(tuple(command[1:]), "") return subprocess.CompletedProcess(args=command, returncode=0, stdout=stdout, stderr="") run = mock.Mock(side_effect=answer_by_argv) with mock.patch("goga.topics.git.publish.subprocess.run", run): - commit = commit_file_on_base("<base>", _TITLE_PATH, _TITLE_CONTENT, _TITLE_MESSAGE) + commit = commit_file_on_base("<base>", _TODO_PATH, _TODO_CONTENT, _TODO_MESSAGE) assert commit == "<commit>" assert _commands_of(run) == [ ["git", "rev-parse", "--git-dir"], ["git", "read-tree", "<base>"], ["git", "hash-object", "-w", "--stdin"], - ["git", "update-index", "--add", "--cacheinfo", f"100644,<blob>,{_TITLE_PATH}"], + ["git", "update-index", "--add", "--cacheinfo", f"100644,<blob>,{_TODO_PATH}"], ["git", "write-tree"], - ["git", "commit-tree", "<tree>", "-p", "<base>", "-m", _TITLE_MESSAGE], + ["git", "commit-tree", "<tree>", "-p", "<base>", "-m", _TODO_MESSAGE], ] quarantined = {"read-tree", "update-index", "write-tree"} @@ -173,7 +173,7 @@ def answer_by_argv(command: list[str], **_kwargs: object) -> subprocess.Complete assert index.name.startswith("goga-publish-index-") assert not index.exists() - assert run.call_args_list[2].kwargs["input"] == _TITLE_CONTENT + assert run.call_args_list[2].kwargs["input"] == _TODO_CONTENT def test_commit_file_on_base_removes_temporary_index_on_failure(self, tmp_path: Path) -> None: """A failed chain still leaves no index behind — the ``finally`` unlink.""" @@ -192,7 +192,7 @@ def answer_by_argv(command: list[str], **_kwargs: object) -> subprocess.Complete mock.patch("goga.topics.git.publish.subprocess.run", run), pytest.raises(subprocess.CalledProcessError), ): - commit_file_on_base("<base>", _TITLE_PATH, _TITLE_CONTENT, _TITLE_MESSAGE) + commit_file_on_base("<base>", _TODO_PATH, _TODO_CONTENT, _TODO_MESSAGE) index = Path(run.call_args_list[1].kwargs["env"]["GIT_INDEX_FILE"]) assert index.parent == git_dir @@ -223,7 +223,7 @@ def answer_by_argv(command: list[str], **_kwargs: object) -> subprocess.Complete run = mock.Mock(side_effect=answer_by_argv) with mock.patch("goga.topics.git.publish.subprocess.run", run): - commit = commit_file_on_base("<base>", _TITLE_PATH, _TITLE_CONTENT, "") + commit = commit_file_on_base("<base>", _TODO_PATH, _TODO_CONTENT, "") assert commit == "<commit>" for call in run.call_args_list: diff --git a/tests/topics/git/test_trees.py b/tests/topics/git/test_trees.py index aae62d7b..376458ae 100644 --- a/tests/topics/git/test_trees.py +++ b/tests/topics/git/test_trees.py @@ -175,12 +175,12 @@ def test_read_ref_file_returns_content_as_is(self) -> None: """The content returns as-is — one ``git show``, UTF-8, muted prompt.""" run = mock.Mock(return_value=_git_answer("Payment retry\n")) with mock.patch("goga.topics.git.trees.subprocess.run", run): - content = read_ref_file("feat/a", ".goga/history/2026/feat-a/title.txt") + content = read_ref_file("feat/a", ".goga/history/2026/feat-a/todo.md") assert content == "Payment retry\n" assert run.call_count == 1 command = run.call_args.args[0] - assert command == ["git", "show", "feat/a:.goga/history/2026/feat-a/title.txt"] + assert command == ["git", "show", "feat/a:.goga/history/2026/feat-a/todo.md"] kwargs = run.call_args.kwargs assert kwargs["check"] is True assert kwargs["capture_output"] is True @@ -201,12 +201,12 @@ def test_read_ref_file_decodes_invalid_bytes_with_replacement(self) -> None: """A hand-edited non-UTF-8 file never crashes the read. The invocation decodes with the replacement policy — the content is - display data (the title column), so an undecodable byte degrades to + display data (the todo column), so an undecodable byte degrades to U+FFFD instead of raising through the board's clean-error boundary. """ run = mock.Mock(return_value=_git_answer("Pay�ment\n")) with mock.patch("goga.topics.git.trees.subprocess.run", run): - content = read_ref_file("feat/a", ".goga/history/2026/feat-a/title.txt") + content = read_ref_file("feat/a", ".goga/history/2026/feat-a/todo.md") assert content == "Pay�ment\n" assert run.call_args.kwargs["errors"] == "replace" @@ -215,7 +215,7 @@ def test_read_ref_file_empty_file_returns_empty_string(self) -> None: """An empty file is present — ``""`` differs from absence (``None``).""" run = mock.Mock(return_value=_git_answer("")) with mock.patch("goga.topics.git.trees.subprocess.run", run): - content = read_ref_file("feat/a", ".goga/history/2026/feat-a/title.txt") + content = read_ref_file("feat/a", ".goga/history/2026/feat-a/todo.md") assert content == "" assert run.call_count == 1 diff --git a/tests/topics/test_publishing.py b/tests/topics/test_publishing.py index 3c1851d6..5aadbb80 100644 --- a/tests/topics/test_publishing.py +++ b/tests/topics/test_publishing.py @@ -1,7 +1,7 @@ """Contract and logic tests for the entities declared in ``goga/topics/CODEMANIFEST`` with ``location: publishing.py``: -- ``publish_topic(branch_name, title, base_ref, commit_message, year)`` — +- ``publish_topic(branch_name, todo, base_ref, commit_message, year)`` — the fast creation-and-publication cycle Every git touchpoint is mocked at the import point per the ``convention`` @@ -120,15 +120,17 @@ def test_publish_topic_is_importable_from_the_cell_facade(self) -> None: assert "publish_topic" in cell.__all__ def test_publish_topic_signature(self) -> None: - """``publish_topic(branch_name, title, base_ref, commit_message, year=None)``. + """``publish_topic(branch_name, todo, base_ref, commit_message, year=None)``. ``commit_message`` carries no default — the design-review pin: the - template is always an explicit argument. + template is always an explicit argument. ``todo`` is required and + non-empty at the call site; an empty todo is a clean error asking + for it. """ signature = inspect.signature(publish_topic) assert list(signature.parameters) == [ "branch_name", - "title", + "todo", "base_ref", "commit_message", "year", @@ -144,7 +146,7 @@ def test_publish_topic_signature(self) -> None: hints = typing.get_type_hints(publish_topic) assert hints == { "branch_name": str, - "title": str, + "todo": str, "base_ref": str, "commit_message": str, "year": str | None, @@ -185,17 +187,62 @@ def test_publishing_never_switches(self) -> None: class TestPublishTopic: - def test_publish_topic_happy_path_builds_plants_and_pushes( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + @pytest.mark.parametrize( + ("todo", "template", "expected_content", "expected_message"), + [ + pytest.param( + "Fix retries.", + "goga: create topic {slug}", + "Fix retries.\n", + "goga: create topic feature-foo-bar", + id="basic", + ), + pytest.param( + "Fix retries.\n\nRetries ignore the cap.", + "goga: create topic {slug}", + "Fix retries.\n\nRetries ignore the cap.\n", + "goga: create topic feature-foo-bar", + id="multiline", + ), + pytest.param( + "Fix retries.", + "chore: fresh topic", + "Fix retries.\n", + "chore: fresh topic", + id="no-placeholder", + ), + ], + ) + def test_publish_topic_commits_todo_file( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + todo: str, + template: str, + expected_content: str, + expected_message: str, ) -> None: - """A free name: resolve, build, plant, push — in that exact order.""" + """The cycle commits exactly one todo.md artifact — verbatim content. + + A multi-line todo reaches the commit verbatim plus one trailing + newline; a template without the ``{slug}`` placeholder is used as + is (plain ``str.replace`` — no format grammar). + """ monkeypatch.chdir(tmp_path) cycle = _wire_cycle(monkeypatch) + cycle.resolve_ref_commit.return_value = "abc123" result = publish_topic( - "Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}" + "Feature/Foo_Bar", todo, "origin/main", template, year="2026" ) + assert ( + cycle.commit_file_on_base.call_args.args[1] + == ".goga/history/2026/feature-foo-bar/todo.md" + ) + assert cycle.commit_file_on_base.call_args.args[2] == expected_content + assert cycle.commit_file_on_base.call_args.args[3] == expected_message + assert cycle.create_branch_at_commit.call_args.args[0] == "Feature/Foo_Bar" cycle.resolve_current_branch_name.assert_called_once_with() cycle.check_branch_occupancy.assert_called_once_with( "Feature/Foo_Bar", "feature-foo-bar", "2026" @@ -203,22 +250,12 @@ def test_publish_topic_happy_path_builds_plants_and_pushes( cycle.check_slug_occupancy.assert_called_once_with("feature-foo-bar", "2026") cycle.origin_configured.assert_called_once_with() cycle.resolve_ref_commit.assert_called_once_with("origin/main") - cycle.commit_file_on_base.assert_called_once_with( - "<base>", - ".goga/history/2026/feature-foo-bar/title.txt", - "Payment retry\n", - "goga: create topic feature-foo-bar", - ) - cycle.create_branch_at_commit.assert_called_once_with( - "Feature/Foo_Bar", "<commit>" - ) cycle.push_branch.assert_called_once_with("Feature/Foo_Bar") cycle.delete_local_branch.assert_not_called() # The parent recorder pins the cross-touchpoint order: every # decision precedes the first mutation, and the mutations run # build -> plant -> push. assert cycle.recorder.mock_calls == [ - mock.call.current_year(), mock.call.resolve_current_branch_name(), mock.call.check_branch_occupancy( "Feature/Foo_Bar", "feature-foo-bar", "2026" @@ -227,10 +264,10 @@ def test_publish_topic_happy_path_builds_plants_and_pushes( mock.call.origin_configured(), mock.call.resolve_ref_commit("origin/main"), mock.call.commit_file_on_base( - "<base>", - ".goga/history/2026/feature-foo-bar/title.txt", - "Payment retry\n", - "goga: create topic feature-foo-bar", + "abc123", + ".goga/history/2026/feature-foo-bar/todo.md", + expected_content, + expected_message, ), mock.call.create_branch_at_commit("Feature/Foo_Bar", "<commit>"), mock.call.push_branch("Feature/Foo_Bar"), @@ -240,27 +277,31 @@ def test_publish_topic_happy_path_builds_plants_and_pushes( ) assert "\n" not in result - @pytest.mark.parametrize( - ("template", "expected"), - [ - ("chore: new topic", "chore: new topic"), - ("chore: new topic {slug}", "chore: new topic feature-foo-bar"), - ], - ) - def test_publish_topic_template_without_placeholder_used_as_is( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - template: str, - expected: str, + def test_publish_topic_empty_todo_clean_error_before_mutations( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """The template applies via plain ``str.replace`` — no format grammar.""" + """An empty todo is one clean error — before every decision and mutation. + + The gate sits between the slug normalization and the current-branch + check, so not a single git mutation and not even the origin probe + of the decision chain runs — an empty todo means the caller never + meant to publish anything. + """ monkeypatch.chdir(tmp_path) + _non_interactive(monkeypatch) cycle = _wire_cycle(monkeypatch) - publish_topic("Feature/Foo_Bar", "T", "origin/main", template) + with pytest.raises(click.ClickException) as raised: + publish_topic("X", "", "origin/main", "tmpl") - assert cycle.commit_file_on_base.call_args.args[3] == expected + assert raised.value.message == ( + "the fast path needs a non-empty todo" + " — pass the text or enter it interactively" + ) + assert "non-empty todo" in raised.value.message + cycle.commit_file_on_base.assert_not_called() + cycle.origin_configured.assert_not_called() + _assert_no_mutation(cycle) def test_publish_topic_current_branch_hosting_slug_is_clean_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -453,7 +494,7 @@ def test_publish_topic_reask_restarts_the_fast_cycle( cycle.create_branch_at_commit.assert_called_once_with("Feature/Baz", "<commit>") assert ( cycle.commit_file_on_base.call_args.args[1] - == ".goga/history/2026/feature-baz/title.txt" + == ".goga/history/2026/feature-baz/todo.md" ) assert cycle.commit_file_on_base.call_args.args[3] == "m" cycle.push_branch.assert_called_once_with("Feature/Baz") @@ -473,7 +514,7 @@ def test_publish_topic_empty_slug_reasks( cycle.create_branch_at_commit.assert_called_once_with("Feature/Baz", "<commit>") assert ( cycle.commit_file_on_base.call_args.args[1] - == ".goga/history/2026/feature-baz/title.txt" + == ".goga/history/2026/feature-baz/todo.md" ) assert cycle.commit_file_on_base.call_args.args[2] == "T\n" cycle.push_branch.assert_called_once_with("Feature/Baz") From 21bf9b9ac1821379102120b2e34e763ce401364f Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Tue, 1 Sep 2026 17:45:15 +0000 Subject: [PATCH 164/229] feat: rename create option to --todo with interactive multiline entry --- goga/commands/topics/render.py | 2 +- goga/commands/topics/topics.py | 104 +++++++--- tests/commands/topics/test_topics_command.py | 204 ++++++++++++++----- 3 files changed, 234 insertions(+), 76 deletions(-) diff --git a/goga/commands/topics/render.py b/goga/commands/topics/render.py index ed2c92b6..e4d3f1c2 100644 --- a/goga/commands/topics/render.py +++ b/goga/commands/topics/render.py @@ -80,7 +80,7 @@ def render_topic_board(records: list[BoardRecord], width: int, info: bool = Fals for record in records: topic_text = f"{_CURRENT_MARKER}{record.topic}" if record.current else record.topic leading = ( - (topic_text, record.branch, record.title or "") if info else (topic_text, record.branch) + (topic_text, record.branch, record.todo or "") if info else (topic_text, record.branch) ) segments = [f"[{status}]" for status in record.statuses] for index, statuses_line in enumerate(_wrap_segments(segments, caps[-1])): diff --git a/goga/commands/topics/topics.py b/goga/commands/topics/topics.py index a7810e36..3bf6f536 100644 --- a/goga/commands/topics/topics.py +++ b/goga/commands/topics/topics.py @@ -5,7 +5,9 @@ topics domain. The group carries the year scope every subcommand shares and is a thin wrapper — it resolves the inputs, delegates every computation to the domain routines of ``goga.topics``, and renders the board through the -``render`` module. The fast creation-and-publication mode of ``create`` +``render`` module. The todo of the fresh work is resolved at this layer: +the ``--todo/-t`` value acts as given, a bare flag starts the interactive +multi-line entry. The fast creation-and-publication mode of ``create`` resolves its own inputs at this layer: a flag beats the ``topics`` section of the project configuration, which beats the built-in default. No inventory walking, no switch resolution, and no git access live here; @@ -15,10 +17,12 @@ from __future__ import annotations import shutil +import sys from dataclasses import dataclass import click import yaml +from click import termui from ...config import TopicsConfig, load_project_config from ...topics import collect_topic_board, create_topic, publish_topic, switch_topic @@ -52,6 +56,46 @@ def _topics_section() -> TopicsConfig | None: raise click.ClickException(str(exc)) from exc +def _prompt_multiline(label: str) -> str | None: + """Collect a multi-line text interactively — one input per line. + + The prompt states the rule itself: a lone ``.`` line or Ctrl+D (EOF) + finishes the entry, every entered line continues the text, and an empty + line is an allowed text line — paragraphs survive. No line entered + cancels the entry and returns None — an empty text is never produced; + the emptiness check runs on the joined text, so a single blank line + cancels the entry the same way. A non-interactive terminal is a clean + error raised before the first prompt; a Ctrl+C aborts the command — + it is not a terminator. + + Args: + label: the human name of the collected value — used in the prompt + and in the non-interactive error. + + Returns: + The joined text — paragraphs separated by single newlines — or None + when the entry was cancelled. + """ + if not sys.stdin.isatty(): + raise click.ClickException(f"{label} entry needs an interactive terminal") + click.echo(f"Enter the {label}. Finish with a lone '.' line or Ctrl+D.") + lines: list[str] = [] + while True: + try: + # Resolved through the module attribute at call time — a + # from-imported binding would never see the CliRunner patch. + line = termui.visible_prompt_func("") + except EOFError: + break + except KeyboardInterrupt: + raise click.Abort() from None + if line == ".": + break + lines.append(line) + text = "\n".join(lines) + return text if text else None + + @click.group() @click.option( "--year", @@ -79,7 +123,7 @@ def topics(ctx: click.Context, year: str | None = None) -> None: "-i", is_flag=True, default=False, - help="Add the title column to the table.", + help="Add the todo column to the table.", ) @click.pass_obj def status(scope: _TopicsScope, remote: bool = False, info: bool = False) -> None: @@ -87,11 +131,11 @@ def status(scope: _TopicsScope, remote: bool = False, info: bool = False) -> Non One three-column table row per topic: topic, branch, statuses — the row of the current branch carries an asterisk and the statuses wrap onto - continuation lines when they overflow. --info/-i adds the title column - — the first line of the topic's title file — between branch and - statuses. --remote/-r reads remote-tracking refs instead of local - branches. An empty board prints nothing and exits 0 — it is not an - error. The year defaults to the current one and is never printed. + continuation lines when they overflow. --info/-i adds the todo column + — the todo summary of the topic — between branch and statuses. + --remote/-r reads remote-tracking refs instead of local branches. An + empty board prints nothing and exits 0 — it is not an error. The year + defaults to the current one and is never printed. """ records = collect_topic_board(scope.year, remote) render_topic_board(records, shutil.get_terminal_size().columns, info) @@ -101,10 +145,14 @@ def status(scope: _TopicsScope, remote: bool = False, info: bool = False) -> Non @topics.command("create") @click.argument("branch_name") @click.option( - "--title", + "--todo", "-t", + "todo", default=None, - help="Topic title — writes title.txt in the topic directory.", + is_flag=False, + flag_value="", + metavar="[TEXT]", + help="Todo of the fresh work; without a value — interactive entry", ) @click.option( "--publish", @@ -129,7 +177,7 @@ def status(scope: _TopicsScope, remote: bool = False, info: bool = False) -> Non def create( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared CLI surface scope: _TopicsScope, branch_name: str, - title: str | None = None, + todo: str | None = None, publish: bool = False, base_ref: str | None = None, commit_message: str | None = None, @@ -137,32 +185,40 @@ def create( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared CLI surface """Create fresh work — a branch with the name as entered and its topic directory. The branch name is taken verbatim; the topic directory of the scoped - year is created from its slug. An explicit --title/-t also writes the - topic title file title.txt — the text as entered plus one trailing - newline; without it no title file is written. The current branch - already hosting the same slug is an idempotent success. Occupied names - and empty slugs re-ask on an interactive terminal and fail with a clean - error otherwise. One result line on stdout. + year is created from its slug. An explicit --todo/-t also writes the + topic todo file todo.md — the multi-line text as entered plus one + trailing newline; the flag given without a value starts the + interactive multi-line entry on a terminal (a lone '.' line or Ctrl+D + finishes, nothing entered cancels). Without a todo no todo file is + written. The current branch already hosting the same slug is an + idempotent success. Occupied names and empty slugs re-ask on an + interactive terminal and fail with a clean error otherwise. One + result line on stdout. --publish/-p is the fast mode: the branch is created off an explicit base — --base-ref, otherwise topics.base_ref of .goga/config.yml — - carrying one commit with the topic title file — the message template + carrying one commit with the topic todo file — the message template from --commit/-c, otherwise topics.publish_commit, otherwise the built-in default — and is pushed to origin without switching. The - title is required in this mode — the board reads the topic through - the title file — and a failed publication rolls back fully: the - planted branch is deleted and one clean error names the reason. + todo is required in this mode — the board reads the topic through + todo.md — and a failed publication rolls back fully: the planted + branch is deleted and one clean error names the reason. """ if not publish and (base_ref is not None or commit_message is not None): raise click.ClickException("--base-ref and --commit act only together with --publish") - if publish and title is None: + # The empty string is the entry marker of the bare flag, never a + # written value; a cancelled entry continues as without the flag. + if todo == "": + todo = _prompt_multiline("todo") + + if publish and todo is None: raise click.ClickException( - "--publish needs a topic title — pass --title/-t; the board reads the topic through the title file" + "--publish needs a todo — pass --todo/-t; the board reads the topic through todo.md" ) if not publish: - line = create_topic(branch_name, scope.year, title) + line = create_topic(branch_name, scope.year, todo) click.echo(line) click.get_current_context().exit(0) @@ -187,7 +243,7 @@ def create( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared CLI surface ) ) - line = publish_topic(branch_name, title, base, template, scope.year) + line = publish_topic(branch_name, todo, base, template, scope.year) click.echo(line) click.get_current_context().exit(0) diff --git a/tests/commands/topics/test_topics_command.py b/tests/commands/topics/test_topics_command.py index 71ab313e..f23e997f 100644 --- a/tests/commands/topics/test_topics_command.py +++ b/tests/commands/topics/test_topics_command.py @@ -6,15 +6,18 @@ The group is a thin wrapper: the ``--year/-y`` option builds the scope every subcommand shares, and each subcommand delegates its computation to the ``goga.topics`` domain — the board collection and rendering for ``status`` -(the ``--info/-i`` flag adds the title column to the rendered table), the -creation (``--title/-t`` writes the topic title file) and switching -procedures for ``create``/``switch``. ``create`` also carries the fast +(the ``--info/-i`` flag adds the todo column to the rendered table), the +creation (``--todo/-t`` writes the topic todo file, a bare flag starting +the interactive multi-line entry) and switching procedures for +``create``/``switch``. ``create`` also carries the fast creation-and-publication mode — ``--publish/-p`` with ``--base-ref`` and ``--commit/-c`` — whose values resolve as flag beats the ``topics`` section of ``.goga/config.yml`` beats the built-in default, the configuration being read on the publish path only. The logic tests mock the domain at its import site in the command module and drive the CLI surface through -``CliRunner``; a pinned ``COLUMNS`` keeps the measured terminal width +``CliRunner``; the interactive entry cycle — ``_prompt_multiline`` — is +driven by direct calls with a TTY-mocked stdin, since the CliRunner stdin +is never a TTY; a pinned ``COLUMNS`` keeps the measured terminal width deterministic. """ @@ -115,14 +118,17 @@ def test_create_carries_the_name_positional(self) -> None: argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "branch_name") assert argument.required is True - def test_create_carries_the_title_option(self) -> None: - """create: --title/-t option, defaulting to None.""" + def test_create_todo_option_surface(self) -> None: + """create: --todo/-t takes an optional value — a bare flag passes the entry marker.""" command = topics.commands["create"] - title_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "title") - assert "-t" in title_option.opts - assert "--title" in title_option.opts - assert title_option.is_flag is False - assert title_option.default is None + todo_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "todo") + assert "-t" in todo_option.opts + assert "--todo" in todo_option.opts + assert todo_option.is_flag is False + assert todo_option.default is None + assert todo_option.flag_value == "" + option_flags = {opt for p in command.params if isinstance(p, click.Option) for opt in p.opts} + assert "--title" not in option_flags def test_create_carries_the_publish_flag(self) -> None: """create: --publish/-p flag, defaulting to False.""" @@ -150,18 +156,18 @@ def test_create_carries_the_commit_option_with_the_explicit_param_name(self) -> assert commit_option.default is None def test_create_callback_signature(self) -> None: - """``create(scope, branch_name, title=None, publish=False, base_ref=None, commit_message=None)``.""" + """``create(scope, branch_name, todo=None, publish=False, base_ref=None, commit_message=None)``.""" callback = topics.commands["create"].callback signature = inspect.signature(callback) assert list(signature.parameters) == [ "scope", "branch_name", - "title", + "todo", "publish", "base_ref", "commit_message", ] - assert signature.parameters["title"].default is None + assert signature.parameters["todo"].default is None assert signature.parameters["publish"].default is False assert signature.parameters["base_ref"].default is None assert signature.parameters["commit_message"].default is None @@ -209,9 +215,11 @@ def test_subcommand_help_follows_the_cli_docstring_rule(self, subcommand: str) - assert section not in result.output def test_create_help_lists_the_new_flags(self) -> None: - """create --help lists --publish/-p, --base-ref, and --commit/-c.""" + """create --help lists --todo/-t, --publish/-p, --base-ref, and --commit/-c.""" result = CliRunner().invoke(topics, ["create", "--help"]) assert result.exit_code == 0 + assert "--todo" in result.output + assert "-t" in result.output assert "--publish" in result.output assert "-p" in result.output assert "--base-ref" in result.output @@ -266,7 +274,7 @@ def test_status_short_forms_bind_the_same_values(self) -> None: mock_collect.assert_called_once_with("2024", True) def test_topics_status_info_flag_reaches_renderer(self, monkeypatch: pytest.MonkeyPatch) -> None: - """--info reaches the renderer — the table gains the Title column.""" + """--info reaches the renderer — the table gains the todo column.""" records = [ BoardRecord( topic="feat-a", @@ -274,7 +282,7 @@ def test_topics_status_info_flag_reaches_renderer(self, monkeypatch: pytest.Monk statuses=["planned"], current=True, remote=False, - title="Payment retry", + todo="Payment retry", ), ] # The lambda tolerates any caller signature: pytest's own terminal @@ -296,7 +304,7 @@ def test_topics_status_info_flag_reaches_renderer(self, monkeypatch: pytest.Monk def test_topics_status_info_short_form_binds_the_same_table(self) -> None: """-i renders the same four-column table as --info.""" records = [ - BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=False, remote=False, title="T"), + BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=False, remote=False, todo="T"), ] with ( mock.patch.object(_topics_module, "collect_topic_board", return_value=records), @@ -364,22 +372,63 @@ def test_create_echoes_the_domain_result_line(self) -> None: mock_create.assert_called_once_with("Feature/Foo_Bar", None, None) assert result.output.splitlines() == ["Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar"] - def test_topics_create_title_option_reaches_domain(self) -> None: - """-t hands the domain (name, scoped year, title) verbatim.""" + def test_topics_create_todo_option_reaches_domain(self) -> None: + """-t hands the domain (name, scoped year, todo) verbatim.""" with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "-t", "Payment retry"]) assert result.exit_code == 0 mock_create.assert_called_once_with("Feature/Foo_Bar", None, "Payment retry") assert result.output == "line\n" - def test_topics_create_title_long_form_binds_the_same_value(self) -> None: - """--title behaves exactly like -t.""" + def test_topics_create_todo_long_form_binds_the_same_value(self) -> None: + """--todo behaves exactly like -t.""" with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: - result = CliRunner().invoke(topics, ["create", "feat-a", "--title", "T"]) + result = CliRunner().invoke(topics, ["create", "feat-a", "--todo", "T"]) assert result.exit_code == 0 mock_create.assert_called_once_with("feat-a", None, "T") assert result.output == "line\n" + @pytest.mark.parametrize( + "flag_form", + [["--todo", "Payment retry"], ["--todo=Payment retry"], ["-t", "Payment retry"], ["-tPayment retry"]], + ) + def test_create_flag_with_value_passes_todo(self, flag_form: list[str]) -> None: + """Every flag form carrying a value hands the domain the todo verbatim.""" + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + result = CliRunner().invoke(topics, ["create", "feat-a", *flag_form]) + assert result.exit_code == 0 + assert mock_create.call_args == mock.call("feat-a", None, "Payment retry") + + def test_create_bare_flag_resolves_todo_through_entry(self) -> None: + """A bare -t resolves to the entry marker and its text reaches the domain.""" + with ( + mock.patch.object(_topics_module, "_prompt_multiline", return_value="line one\n\nline two") as mock_entry, + mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create, + ): + result = CliRunner().invoke(topics, ["create", "feat-a", "-t"]) + assert result.exit_code == 0 + mock_entry.assert_called_once_with("todo") + assert mock_create.call_args.args[2] == "line one\n\nline two" + + def test_create_bare_flag_entry_cancel_continues_without_todo(self) -> None: + """A cancelled entry continues as without the flag — the domain gets None.""" + with ( + mock.patch.object(_topics_module, "_prompt_multiline", return_value=None), + mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create, + ): + result = CliRunner().invoke(topics, ["create", "feat-a", "-t"]) + assert result.exit_code == 0 + assert mock_create.call_args.args[2] is None + + def test_create_bare_flag_non_interactive_clean_error(self) -> None: + """A bare -t without a TTY is a clean error before any delegation.""" + with mock.patch.object(_topics_module, "create_topic") as mock_create: + result = CliRunner().invoke(topics, ["create", "feat-a", "-t"], input="irrelevant\n") + assert result.exit_code == 1 + assert "todo entry needs an interactive terminal" in result.stderr + assert "Traceback" not in result.stderr + mock_create.assert_not_called() + def test_switch_echoes_the_domain_result_line(self) -> None: """switch echoes the single result line and exits 0.""" with mock.patch.object( @@ -454,7 +503,7 @@ def test_create_publish_flag_beats_config_section(self, tmp_path: Path, monkeypa "create", "Feature/Foo_Bar", "--publish", - "--title", + "--todo", "T", "--base-ref", "origin/flag-base", @@ -473,7 +522,7 @@ def test_create_publish_resolves_config_and_default(self, tmp_path: Path, monkey monkeypatch.chdir(tmp_path) _write_config(tmp_path, "language: python\ntopics:\n base_ref: origin/config-base\n") with mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish: - result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "--publish", "--title", "T"]) + result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "--publish", "--todo", "T"]) assert result.exit_code == 0 mock_publish.assert_called_once_with( "Feature/Foo_Bar", "T", "origin/config-base", "goga: create topic {slug}", None @@ -490,7 +539,7 @@ def test_create_publish_config_template_beats_default( "language: python\ntopics:\n base_ref: origin/config-base\n publish_commit: 'config: {slug}'\n", ) with mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish: - result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "--publish", "--title", "T"]) + result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "--publish", "--todo", "T"]) assert result.exit_code == 0 mock_publish.assert_called_once_with( "Feature/Foo_Bar", "T", "origin/config-base", "config: {slug}", None @@ -513,7 +562,7 @@ def test_create_publish_flag_base_with_config_template( ): result = CliRunner().invoke( topics, - ["create", "Feature/Foo_Bar", "--publish", "--title", "T", "--base-ref", "origin/flag-base"], + ["create", "Feature/Foo_Bar", "--publish", "--todo", "T", "--base-ref", "origin/flag-base"], ) assert result.exit_code == 0 mock_publish.assert_called_once_with( @@ -536,7 +585,7 @@ def test_create_publish_flag_template_with_config_base( ): result = CliRunner().invoke( topics, - ["create", "Feature/Foo_Bar", "--publish", "--title", "T", "--commit", "flag: {slug}"], + ["create", "Feature/Foo_Bar", "--publish", "--todo", "T", "--commit", "flag: {slug}"], ) assert result.exit_code == 0 mock_publish.assert_called_once_with( @@ -560,16 +609,25 @@ def test_create_publication_flags_without_publish_are_clean_error(self, extra: l mock_create.assert_not_called() mock_publish.assert_not_called() - def test_create_publish_without_title_is_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """--publish without a title is a clean error asking for it; the domain is untouched.""" + def test_create_publish_without_todo_is_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """--publish without a todo is a clean error asking for it; the domain is untouched.""" monkeypatch.chdir(tmp_path) with mock.patch.object(_topics_module, "publish_topic") as mock_publish: result = CliRunner().invoke(topics, ["create", "X", "--publish"]) assert result.exit_code == 1 - assert "--publish needs a topic title" in result.stderr - assert "--title" in result.stderr + assert "--publish needs a todo" in result.stderr + assert "--todo/-t" in result.stderr + assert "todo.md" in result.stderr mock_publish.assert_not_called() + def test_create_publish_delegates_resolved_todo(self) -> None: + """The publish path hands publish_topic the resolved todo and the resolved template.""" + with mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish: + result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t", "T", "--base-ref", "origin/main"]) + assert result.exit_code == 0 + assert mock_publish.call_args == mock.call("X", "T", "origin/main", "goga: create topic {slug}", None) + assert result.output == "line\n" + def test_create_publish_without_base_names_config_and_flag( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -577,7 +635,7 @@ def test_create_publish_without_base_names_config_and_flag( monkeypatch.chdir(tmp_path) _write_config(tmp_path, "language: python\n") with mock.patch.object(_topics_module, "publish_topic") as mock_publish: - result = CliRunner().invoke(topics, ["create", "X", "--publish", "--title", "T"]) + result = CliRunner().invoke(topics, ["create", "X", "--publish", "--todo", "T"]) assert result.exit_code == 1 assert "topics.base_ref" in result.stderr assert "--base-ref" in result.stderr @@ -592,7 +650,7 @@ def test_create_publish_invalid_config_surfaces_its_own_error( monkeypatch.chdir(tmp_path) _write_config(tmp_path, "language: python\ntopics: 5\n") with mock.patch.object(_topics_module, "publish_topic") as mock_publish: - result = CliRunner().invoke(topics, ["create", "X", "--publish", "--title", "T"]) + result = CliRunner().invoke(topics, ["create", "X", "--publish", "--todo", "T"]) assert result.exit_code == 1 assert "'topics' must be a mapping in .goga/config.yml" in result.stderr mock_publish.assert_not_called() @@ -608,12 +666,71 @@ def test_create_publish_unreadable_config_surfaces_clean_error( (tmp_path / ".goga").mkdir() (tmp_path / ".goga" / "config.yml").mkdir() with mock.patch.object(_topics_module, "publish_topic") as mock_publish: - result = CliRunner().invoke(topics, ["create", "X", "--publish", "--title", "T"]) + result = CliRunner().invoke(topics, ["create", "X", "--publish", "--todo", "T"]) assert result.exit_code == 1 assert "Is a directory" in result.stderr assert not isinstance(result.exception, IsADirectoryError) mock_publish.assert_not_called() + +class TestMultilineEntry: + """The interactive entry cycle — driven by direct calls, never CliRunner. + + Under CliRunner ``sys.stdin`` is an isolated stream whose ``isatty()`` is + always False, so the cycle itself is exercised through the two seams of + the practice: a TTY-mocked stdin and a ``visible_prompt_func`` with a + ``side_effect`` list of lines — ``EOFError`` in the list models Ctrl+D, + a ``"."`` line the terminator. + """ + + def _tty(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Mock sys.stdin as an interactive terminal.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + + def test_entry_collects_paragraphs(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Entered lines join with single newlines — an empty line stays a text line.""" + self._tty(monkeypatch) + with mock.patch.object( + click.termui, "visible_prompt_func", side_effect=["line one", "", "line two", "."] + ): + assert _topics_module._prompt_multiline("todo") == "line one\n\nline two" + + def test_entry_eof_terminator_returns_collected_text(self, monkeypatch: pytest.MonkeyPatch) -> None: + """EOF (Ctrl+D) finishes the entry exactly like the dot terminator.""" + self._tty(monkeypatch) + with mock.patch.object(click.termui, "visible_prompt_func", side_effect=["a", EOFError()]): + assert _topics_module._prompt_multiline("todo") == "a" + + def test_entry_keyboard_interrupt_aborts(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Ctrl+C aborts the command — it is not a terminator.""" + self._tty(monkeypatch) + with ( + mock.patch.object(click.termui, "visible_prompt_func", side_effect=["a", KeyboardInterrupt()]), + pytest.raises(click.Abort), + ): + _topics_module._prompt_multiline("todo") + + @pytest.mark.parametrize("side_effect", [["."], ["", "."]]) + def test_entry_single_blank_line_cancels(self, side_effect: list[str], monkeypatch: pytest.MonkeyPatch) -> None: + """An empty assembly cancels the entry — None, never an empty text. + + The emptiness check runs on the joined text, so a single blank line + cancels the entry the same way as entering nothing at all. + """ + self._tty(monkeypatch) + with mock.patch.object(click.termui, "visible_prompt_func", side_effect=side_effect): + assert _topics_module._prompt_multiline("todo") is None + + def test_entry_non_interactive_terminal_is_clean_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A stdin without a TTY is a clean error before the first prompt.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + with ( + mock.patch.object(click.termui, "visible_prompt_func") as mock_prompt, + pytest.raises(click.ClickException, match="todo entry needs an interactive terminal"), + ): + _topics_module._prompt_multiline("todo") + mock_prompt.assert_not_called() + def test_create_default_path_never_reads_configuration( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -637,22 +754,7 @@ def test_create_publish_missing_config_counts_as_unset( monkeypatch.chdir(tmp_path) with mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish: result = CliRunner().invoke( - topics, ["create", "X", "--publish", "--title", "T", "--base-ref", "origin/main"] + topics, ["create", "X", "--publish", "--todo", "T", "--base-ref", "origin/main"] ) assert result.exit_code == 0 mock_publish.assert_called_once_with("X", "T", "origin/main", "goga: create topic {slug}", None) - - def test_create_publish_explicit_empty_title_is_not_missing( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """--title '' is a deliberate empty title, not a missing one — the gate checks None.""" - monkeypatch.chdir(tmp_path) - _write_config(tmp_path, "language: python\ntopics:\n base_ref: origin/main\n") - with ( - mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish, - mock.patch.object(_topics_module, "create_topic") as mock_create, - ): - result = CliRunner().invoke(topics, ["create", "X", "--publish", "--title", ""]) - assert result.exit_code == 0 - assert mock_publish.call_args.args[1] == "" - mock_create.assert_not_called() From e7e7ef212a30a34aac61a21afd612d1745a0567a Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Tue, 1 Sep 2026 17:49:14 +0000 Subject: [PATCH 165/229] feat: rename board render info column to todo --- goga/commands/topics/render.py | 17 +-- tests/commands/topics/test_render.py | 104 ++++++++++++------- tests/commands/topics/test_topics_command.py | 4 +- 3 files changed, 80 insertions(+), 45 deletions(-) diff --git a/goga/commands/topics/render.py b/goga/commands/topics/render.py index e4d3f1c2..0000b3f3 100644 --- a/goga/commands/topics/render.py +++ b/goga/commands/topics/render.py @@ -2,7 +2,7 @@ The entity declared in the cell CODEMANIFEST with ``location: render.py``: the board renderer — the collected board records as a three-column table of -topic, branch, and statuses, or as a four-column table with the title +topic, branch, and statuses, or as a four-column table with the todo column between branch and statuses under ``info``. Pure output: the records print as given, never sorted, filtered, or recomputed; the domain owns the collection and the ordering. @@ -27,12 +27,12 @@ def render_topic_board(records: list[BoardRecord], width: int, info: bool = False) -> None: """Render the board as a table: topic, branch, and statuses — under - ``info`` the title column sits between branch and statuses. + ``info`` the todo column sits between branch and statuses. Args: records: The collected board records — already sorted by the domain. width: The measured terminal width in columns. - info: ``True`` adds the title column and switches to the + info: ``True`` adds the todo column and switches to the four-column width rule. Algorithm: @@ -41,7 +41,7 @@ def render_topic_board(records: list[BoardRecord], width: int, info: bool = Fals ``info``, the four-column rule with it; the grid is fixed and independent of the record content 2. Print one header row and one separator row with column and row - dividers — the column order is topic, branch, title, statuses + dividers — the column order is topic, branch, todo, statuses under ``info`` 3. Print each record: every text column truncated with an ellipsis when it exceeds its column, and the statuses wrapped onto @@ -54,11 +54,12 @@ def render_topic_board(records: list[BoardRecord], width: int, info: bool = Fals The three-column rule gives topic and branch an equal share first — each capped at one third of ``width`` minus the dividers — and statuses the remainder; the four-column rule under ``info`` gives - topic, branch, and title an equal share — each capped at one quarter + topic, branch, and todo an equal share — each capped at one quarter of ``width`` minus the dividers — and statuses the non-negative remainder. Every column keeps a minimum of 8 columns before - truncation applies. A title of ``None`` or an empty string renders - an empty cell. The truncation marker is a single ellipsis character; + truncation applies. The todo column header is the word todo. A todo + of ``None`` or an empty string renders an empty cell. The truncation + marker is a single ellipsis character; an overlong status segment is truncated like the other columns. The table never exceeds ``width``, with one documented exception: below the narrow threshold of the active column rule — 33 columns for the @@ -74,7 +75,7 @@ def render_topic_board(records: list[BoardRecord], width: int, info: bool = Fals return columns_count = 4 if info else 3 caps = _column_widths(width, columns_count) - header = ("Topic", "Branch", "Title", "Statuses") if info else ("Topic", "Branch", "Statuses") + header = ("Topic", "Branch", "todo", "Statuses") if info else ("Topic", "Branch", "Statuses") click.echo(_row_line(header, caps)) click.echo(_separator(caps)) for record in records: diff --git a/tests/commands/topics/test_render.py b/tests/commands/topics/test_render.py index d10766b8..4d6b791e 100644 --- a/tests/commands/topics/test_render.py +++ b/tests/commands/topics/test_render.py @@ -5,7 +5,7 @@ The board renderer is pure output: the collected records print as given — no sorting, no filtering, no mutation — as a three-column table of topic, -branch, and statuses, or a four-column table with the title column between +branch, and statuses, or a four-column table with the todo column between branch and statuses under ``info``; the widths follow the thirds or the quarters arithmetic of the active column rule. Output is captured with ``capsys``. @@ -14,6 +14,7 @@ from __future__ import annotations import inspect +import re import typing import pytest @@ -45,6 +46,27 @@ def test_render_topic_board_empty_input_prints_nothing(self, capsys: pytest.Capt render_topic_board([], 80) assert capsys.readouterr().out == "" + def test_render_topic_board_info_header_is_the_word_todo(self, capsys: pytest.CaptureFixture[str]) -> None: + """Under ``info`` the third column header is the word ``todo``. + + The header cell pads to its column cap, so the assertion matches the + literal ``| todo`` run followed by padding spaces and the divider — + a bare substring check would miss the padded cell. + """ + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["todo"], + current=False, + remote=False, + todo="Pay retry cap", + ) + ] + render_topic_board(records, 100, info=True) + header_line = capsys.readouterr().out.splitlines()[0] + assert re.search(r"\| todo\s+\|", header_line) + # --- Logic tests --- @@ -171,7 +193,7 @@ def test_render_topic_board_three_columns_unchanged_without_info( topic="feat-a", branch="feat/a", statuses=["defined", "planned"], - title="Payment retry", + todo="Pay retry cap", current=False, remote=False, ), @@ -179,7 +201,7 @@ def test_render_topic_board_three_columns_unchanged_without_info( topic="a-very-long-topic-name", branch="feat/x", statuses=["done"], - title=None, + todo=None, current=False, remote=False, ), @@ -192,12 +214,12 @@ def test_render_topic_board_three_columns_unchanged_without_info( assert first == second lines = first.splitlines() # usable = 91, so topic_cap = branch_cap = 30 and statuses_w = 31; - # the header keeps the three columns — the title stays invisible. + # the header keeps the three columns — the todo stays invisible. assert lines[0].startswith("| Topic") - assert "Title" not in lines[0] + assert not re.search(r"\| todo\s+\|", lines[0]) assert "Statuses" in lines[0] assert all(len(line) <= 100 for line in lines) - assert "Payment retry" not in first + assert "Pay retry cap" not in first @pytest.mark.parametrize("width", [33, 32]) def test_render_topic_board_three_column_narrow_threshold( @@ -205,7 +227,7 @@ def test_render_topic_board_three_column_narrow_threshold( ) -> None: """Widths 33 and 32 without ``info`` stay on the 8/8/8 minimum thirds.""" records = [ - BoardRecord(topic="feat-a", branch="feat/a", statuses=["done"], title="T", current=False, remote=False) + BoardRecord(topic="feat-a", branch="feat/a", statuses=["done"], todo="T", current=False, remote=False) ] render_topic_board(records, width) lines = capsys.readouterr().out.splitlines() @@ -213,18 +235,18 @@ def test_render_topic_board_three_column_narrow_threshold( # both boundaries resolve to the 8/8/8 minimum layout. assert all(len(line) == 33 for line in lines) assert lines[0].startswith("| Topic") - assert "Title" not in lines[0] + assert not re.search(r"\| todo\s+\|", lines[0]) class TestRenderTopicBoardInfo: def test_render_topic_board_info_four_columns(self, capsys: pytest.CaptureFixture[str]) -> None: - """Width 100 under ``info`` — quarters of 22, the title column visible.""" + """Width 100 under ``info`` — quarters of 22, the todo column visible.""" records = [ BoardRecord( topic="feat-a", branch="feat/a", statuses=["planned"], - title="Short title", + todo="Pay retry cap", current=False, remote=False, ), @@ -232,7 +254,7 @@ def test_render_topic_board_info_four_columns(self, capsys: pytest.CaptureFixtur topic="feat-b", branch="feat/b", statuses=["done"], - title="an-overlong-title-that-exceeds-the-cap", + todo="an-overlong-todo-summary-exceeding-cap", current=False, remote=False, ), @@ -242,47 +264,59 @@ def test_render_topic_board_info_four_columns(self, capsys: pytest.CaptureFixtur # usable = 88, so every column takes a quarter — 22/22/22/22. assert lines[0].startswith("| Topic") assert "Branch" in lines[0] - assert "Title" in lines[0] + assert re.search(r"\| todo\s+\|", lines[0]) assert "Statuses" in lines[0] for line in lines: assert line.count("|") == 4 assert all(len(line) <= 100 for line in lines) - assert "Short title" in lines[2] - # The 38-column title exceeds its cap of 22 — truncated with the ellipsis. + assert "Pay retry cap" in lines[2] + # The 37-column summary exceeds its cap of 22 — truncated with the ellipsis. assert "…" in lines[3] assert "[planned]" in lines[2] assert "[done]" in lines[3] - def test_render_topic_board_info_none_and_empty_title_cells( - self, capsys: pytest.CaptureFixture[str] - ) -> None: - """Titles of None and of the empty string render an empty padded cell.""" + def test_render_info_column_carries_todo_header(self, capsys: pytest.CaptureFixture[str]) -> None: + """The ``info`` header carries the word todo and the record's todo summary.""" records = [ BoardRecord( topic="feat-a", branch="feat/a", - statuses=["planned"], - title=None, + statuses=["todo"], current=False, remote=False, - ), + todo="Pay retry cap", + ) + ] + render_topic_board(records, 100, info=True) + lines = capsys.readouterr().out.splitlines() + # usable = 88, so every text column takes a quarter — (100 - 12) // 4 = 22. + header_line = lines[0] + assert re.search(r"\| todo\s+\|", header_line) + assert "Pay retry cap" in lines[2] + + @pytest.mark.parametrize("todo", [None, ""]) + def test_render_todo_none_renders_empty_cell( + self, capsys: pytest.CaptureFixture[str], todo: str | None + ) -> None: + """A todo of None or of the empty string renders an empty padded cell.""" + records = [ BoardRecord( - topic="feat-b", - branch="feat/b", + topic="feat-a", + branch="feat/a", statuses=["planned"], - title="", + todo=todo, current=False, remote=False, - ), + ) ] render_topic_board(records, 100, info=True) lines = capsys.readouterr().out.splitlines() - # Both empty titles render the whitespace padding of a 22-column cell. - for line in lines[2:4]: - cells = line[2:-1].split(" | ") - assert len(cells) == 4 - assert cells[2] == " " * 22 - assert len(line) == 100 + # The cell between branch and statuses is the whitespace padding of a + # 22-column cell — no text. + cells = lines[2][2:-1].split(" | ") + assert len(cells) == 4 + assert cells[2] == " " * 22 + assert len(lines[2]) == 100 @pytest.mark.parametrize("width", [44, 43]) def test_render_topic_board_info_boundary_widths_44_43( @@ -290,7 +324,7 @@ def test_render_topic_board_info_boundary_widths_44_43( ) -> None: """Widths 44 and 43 under ``info`` — the narrow threshold of the quarters.""" records = [ - BoardRecord(topic="feat-a", branch="feat/a", statuses=["done"], title="T", current=False, remote=False) + BoardRecord(topic="feat-a", branch="feat/a", statuses=["done"], todo="T", current=False, remote=False) ] render_topic_board(records, width, info=True) lines = capsys.readouterr().out.splitlines() @@ -298,7 +332,7 @@ def test_render_topic_board_info_boundary_widths_44_43( # width; 43 gives 31 < 32 — the documented one-column overflow. assert all(len(line) == 44 for line in lines) assert lines[0].startswith("| Topic") - assert "Title" in lines[0] + assert re.search(r"\| todo\s+\|", lines[0]) assert "[done]" in lines[2] def test_render_topic_board_info_wraps_statuses_with_empty_leading_cells( @@ -310,7 +344,7 @@ def test_render_topic_board_info_wraps_statuses_with_empty_leading_cells( topic="feat-a", branch="feat/a", statuses=["done", "planned"], - title="T", + todo="T", current=False, remote=False, ) @@ -322,7 +356,7 @@ def test_render_topic_board_info_wraps_statuses_with_empty_leading_cells( assert len(lines) == 4 assert "[done]" in lines[2] assert "[planne…" in lines[3] - # The continuation row keeps the grid: topic, branch, and title are + # The continuation row keeps the grid: topic, branch, and todo are # the empty padding of their columns — 10 columns per leading cell. assert lines[3].startswith(f"|{' ' * 10}|{' ' * 10}|{' ' * 10}|") assert len(lines[3]) == 44 diff --git a/tests/commands/topics/test_topics_command.py b/tests/commands/topics/test_topics_command.py index f23e997f..7ac80b30 100644 --- a/tests/commands/topics/test_topics_command.py +++ b/tests/commands/topics/test_topics_command.py @@ -295,7 +295,7 @@ def test_topics_status_info_flag_reaches_renderer(self, monkeypatch: pytest.Monk result = CliRunner().invoke(topics, ["status", "--info"]) assert result.exit_code == 0 header = result.output.splitlines()[0] - assert "Title" in header + assert "todo" in header assert "Topic" in header assert "Branch" in header assert "Statuses" in header @@ -315,7 +315,7 @@ def test_topics_status_info_short_form_binds_the_same_table(self) -> None: assert short.exit_code == 0 assert long.exit_code == 0 assert short.output == long.output - assert "Title" in short.output.splitlines()[0] + assert "todo" in short.output.splitlines()[0] def test_status_empty_board_prints_nothing_exit_zero(self) -> None: """An empty board is not an error — nothing on stdout, exit 0.""" From 69bb30a91d1f79f870ed15b6707a0cebe294f184 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Tue, 1 Sep 2026 17:50:56 +0000 Subject: [PATCH 166/229] feat: rename history status filter tests to todo vocabulary --- .../commands/history/test_history_command.py | 30 ++++++++++++------- tests/history/test_status.py | 4 +-- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/tests/commands/history/test_history_command.py b/tests/commands/history/test_history_command.py index 352a17bd..d97ea3b4 100644 --- a/tests/commands/history/test_history_command.py +++ b/tests/commands/history/test_history_command.py @@ -204,40 +204,50 @@ def test_history_status_repeatable_status_filter( assert result.output.splitlines() == ["done-topic [done]", "planned-topic [planned]"] assert "defined-topic" not in result.output - def test_history_status_filter_new_selects_titled_topics( + def test_history_status_filter_todo_selects_todo_topics( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """-s new selects the topics a title file puts into the built-in new status.""" + """-s todo selects the topics a todo file puts into the built-in todo status.""" year_dir = tmp_path / ".goga" / "history" / "2026" (year_dir / "feat-a").mkdir(parents=True) - (year_dir / "feat-a" / "title.txt").write_text("Payment retry\n", encoding="utf-8") + (year_dir / "feat-a" / "todo.md").write_text("Payment retry\n", encoding="utf-8") (year_dir / "feat-b").mkdir() (year_dir / "feat-b" / "prd.md").write_text("prd\n", encoding="utf-8") monkeypatch.chdir(tmp_path) monkeypatch.setattr("goga.hooks.tools.packages.packages_distributions", lambda: {}) - result = CliRunner().invoke(history, ["status", "2026", "-s", "new"]) + result = CliRunner().invoke(history, ["status", "2026", "-s", "todo"]) assert result.exit_code == 0 - assert result.output.splitlines() == ["feat-a [new]"] + assert result.output.splitlines() == ["feat-a [todo]"] assert "feat-b" not in result.output - def test_history_status_filter_new_skips_defined_topics( + def test_history_status_filter_todo_skips_defined_topics( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A topic with prd.md is defined, not new — the maximal status wins through the CLI.""" + """A topic with prd.md is defined, not todo — the maximal status wins through the CLI.""" year_dir = tmp_path / ".goga" / "history" / "2026" (year_dir / "feat-b").mkdir(parents=True) - (year_dir / "feat-b" / "title.txt").write_text("Title\n", encoding="utf-8") + (year_dir / "feat-b" / "todo.md").write_text("Todo\n", encoding="utf-8") (year_dir / "feat-b" / "prd.md").write_text("prd\n", encoding="utf-8") monkeypatch.chdir(tmp_path) monkeypatch.setattr("goga.hooks.tools.packages.packages_distributions", lambda: {}) - result = CliRunner().invoke(history, ["status", "2026", "-s", "new"]) + result = CliRunner().invoke(history, ["status", "2026", "-s", "todo"]) assert result.exit_code == 0 assert result.output == "" - assert (year_dir / "feat-b" / "title.txt").exists() + assert (year_dir / "feat-b" / "todo.md").exists() + + def test_history_status_filter_new_unknown(self) -> None: + """The retired new name is rejected — unknown status, clean error, no collection.""" + with mock.patch.object(_history_module, "collect_topic_statuses") as collect_mock: + result = CliRunner().invoke(history, ["status", "2026", "-s", "new"]) + + assert result.exit_code == 1 + assert "unknown status name: 'new'" in result.stderr + assert "Traceback" not in result.stderr + collect_mock.assert_not_called() class TestHistoryPath: diff --git a/tests/history/test_status.py b/tests/history/test_status.py index a54e0f71..efd1dac7 100644 --- a/tests/history/test_status.py +++ b/tests/history/test_status.py @@ -46,7 +46,7 @@ def _builtin_scale() -> StatusScale: return StatusScale( stages=[ Stage(name="empty", filepath=""), - Stage(name="new", filepath="title.txt"), + Stage(name="todo", filepath="todo.md"), Stage(name="defined", filepath="prd.md"), Stage(name="discovered", filepath="adr.md"), Stage(name="backlog", filepath="task.md"), @@ -141,7 +141,7 @@ class TestResolveTopicStatus: @pytest.mark.parametrize( ("artifact", "expected"), [ - ("title.txt", ["new"]), + ("todo.md", ["todo"]), ("prd.md", ["defined"]), ("adr.md", ["discovered"]), ("task.md", ["backlog"]), From bc8f89aabe10506dd9abecc667081bea6676f1ea Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Tue, 1 Sep 2026 17:57:47 +0000 Subject: [PATCH 167/229] feat: rename topic workflow integration tests to todo vocabulary --- tests/integration/test_topic_workflows.py | 155 +++++++++++++++++----- 1 file changed, 123 insertions(+), 32 deletions(-) diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index bc10df98..645df182 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -24,7 +24,7 @@ creation. publish_topic — the quarantined fast path over the real git cell: the - title commit is built off a pushed ``origin/main`` while the working + todo commit is built off a pushed ``origin/main`` while the working copy, the index, and HEAD stay as they are, the branch is planted and pushed to a real bare ``origin``, and the failed-push scenario breaks the push URL to prove the full rollback of the planted branch. @@ -221,7 +221,7 @@ def _board_rows(output: str, columns: int = 3) -> list[tuple[str, ...]]: Args: output: The captured stdout of ``goga topics status``. columns: The text-column count of the table — 3 without ``--info``, - 4 with it (the title column between branch and statuses). + 4 with it (the todo column between branch and statuses). Returns: The cell tuples of the data rows — the header and separator rows @@ -550,32 +550,50 @@ def test_create_topic_occupied_local_branch_reasks_non_interactively( assert _current_branch(tmp_path) == "feat-a" assert not (tmp_path / ".goga" / "history" / "2025" / "feat-b").exists() + def test_create_topic_empty_todo_writes_no_file( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An empty todo creates the branch and the topic directory — and no todo.md.""" + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + + line = create_topic("feat-empty", year="2025", todo="") + + assert line == "Created branch feat-empty and topic 2025/feat-empty" + assert _current_branch(tmp_path) == "feat-empty" + topic_dir = tmp_path / ".goga" / "history" / "2025" / "feat-empty" + assert topic_dir.is_dir() + assert not (topic_dir / "todo.md").exists() + @requires_git -class TestTopicsStatusTitles: - """The title column of ``goga topics status --info`` over real reads.""" +class TestTopicsStatusTodos: + """The todo column of ``goga topics status --info`` over real reads.""" - def test_board_survives_hand_edited_non_utf8_titles( + def test_board_survives_hand_edited_non_utf8_todos( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Titles outside UTF-8 render with the replacement character. - - The working-copy title reads through pathlib and the ref-tree title - through ``git show`` — neither may raise through the clean-error - boundary, or one hand-edited file would break the whole board. + """Todo summaries outside UTF-8 render with the replacement character. + + Both todo.md reads — the working copy through pathlib and the ref + tree through ``git show`` — degrade the bytes instead of raising + through the clean-error boundary, or one hand-edited file would + break the whole board. The leading marker line never qualifies, so + the summary the board shows is the degraded line the normalization + picked. """ _init_topic_repo(tmp_path) - # The committed side: feat-b's title lives in its ref tree only. + # The committed side: feat-b's todo lives in its ref tree only. _git(tmp_path, "switch", "-q", "feat-b") - (tmp_path / ".goga" / "history" / "2025" / "feat-b" / "title.txt").write_bytes( - b"Rem\xffote\n" + (tmp_path / ".goga" / "history" / "2025" / "feat-b" / "todo.md").write_bytes( + b"###\nRem\xffote\n" ) _git(tmp_path, "add", ".goga") - _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "feat-b title") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "feat-b todo") _git(tmp_path, "switch", "-q", "feat-a") - # The uncommitted side: the current branch's working-copy title. - (tmp_path / ".goga" / "history" / "2025" / "feat-a" / "title.txt").write_bytes( - b"Pay\xffment\n" + # The uncommitted side: the current branch's working-copy todo. + (tmp_path / ".goga" / "history" / "2025" / "feat-a" / "todo.md").write_bytes( + b"###\nPay\xffment\n" ) monkeypatch.chdir(tmp_path) monkeypatch.setenv("COLUMNS", "120") @@ -586,6 +604,78 @@ def test_board_survives_hand_edited_non_utf8_titles( assert "Pay�ment" in result.output assert "Rem�ote" in result.output + def test_create_todo_then_status_info_shows_summary_and_todo_status( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``create --todo`` and ``status --info`` close the loop over real git. + + The written todo.md carries the multi-line todo verbatim plus one + trailing newline, the board reads the topic through it — the + ``[todo]`` status — and the summary column shows the first line the + ``#``-marker normalization qualifies, not the raw first line. + """ + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("COLUMNS", "120") + + created = CliRunner().invoke( + topics, + [ + "--year", + "2025", + "create", + "feat-new", + "--todo", + "###\n# Pay retry cap\n\nRetries ignore the cap.", + ], + ) + + assert created.exit_code == 0 + assert created.output == "Created branch feat-new and topic 2025/feat-new\n" + assert ( + tmp_path / ".goga" / "history" / "2025" / "feat-new" / "todo.md" + ).read_bytes() == b"###\n# Pay retry cap\n\nRetries ignore the cap.\n" + + result = CliRunner().invoke(topics, ["--year", "2025", "status", "--info"]) + + assert result.exit_code == 0 + assert ("* feat-new", "feat-new", "Pay retry cap", "[todo]") in _board_rows( + result.output, columns=4 + ) + + def test_board_old_title_txt_only_topic_is_empty_status( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A topic whose tree carries only the retired title.txt reads ``[empty]``. + + title.txt stopped being an axis artifact, so the topic has nothing + the scale recognizes — no status, no todo summary — the clean break + over real git, with the legacy file left on disk untouched. + """ + _init_topic_repo(tmp_path) + _git(tmp_path, "switch", "-q", "-c", "legacy") + legacy_file = tmp_path / ".goga" / "history" / "2025" / "legacy-work" / "title.txt" + legacy_file.parent.mkdir(parents=True) + legacy_file.write_text("Retired artifact\n", encoding="utf-8") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "legacy topic") + _git(tmp_path, "switch", "-q", "feat-a") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("COLUMNS", "120") + + result = CliRunner().invoke(topics, ["--year", "2025", "status", "--info"]) + + assert result.exit_code == 0 + assert ("legacy-work", "legacy", "", "[empty]") in _board_rows( + result.output, columns=4 + ) + # The legacy file stays byte-exact in its ref tree — the board read + # it and dropped it as an unknown artifact, it never rewrote it. + assert ( + _git_out(tmp_path, "show", "legacy:.goga/history/2025/legacy-work/title.txt") + == "Retired artifact" + ) + @requires_git class TestHistoryPrune: @@ -684,16 +774,16 @@ def test_publish_end_to_end_leaves_user_state_untouched( assert line == f"Created branch Feature/Foo_Bar and published topic {year}/feature-foo-bar" assert "\n" not in line - def test_publish_creates_single_title_commit_and_shows_on_remote_board( + def test_publish_creates_single_todo_commit_and_shows_on_remote_board( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """The published branch carries exactly the title commit — upstream - bound to origin and visible on the remote board with the ``new`` status.""" + """The published branch carries exactly the todo commit — upstream + bound to origin and visible on the remote board with the ``todo`` status.""" _init_publish_repo(tmp_path) monkeypatch.chdir(tmp_path) monkeypatch.setenv("COLUMNS", "120") year = current_year() - topic_path = f".goga/history/{year}/feature-foo-bar/title.txt" + topic_path = f".goga/history/{year}/feature-foo-bar/todo.md" publish_topic( "Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}" @@ -717,7 +807,7 @@ def test_publish_creates_single_title_commit_and_shows_on_remote_board( assert result.exit_code == 0 assert _board_rows(result.output, columns=4) == [ - ("feature-foo-bar", "origin/Feature/Foo_Bar", "Payment retry", "[new]") + ("feature-foo-bar", "origin/Feature/Foo_Bar", "Payment retry", "[todo]") ] def test_publish_failed_push_rolls_back_and_rerun_succeeds( @@ -747,16 +837,16 @@ def test_publish_failed_push_rolls_back_and_rerun_succeeds( assert _git_out(tmp_path, "rev-parse", "--verify", "refs/remotes/origin/Feature/Foo_Bar") assert "published topic" in line - def test_publish_non_ascii_title_survives_utf8( + def test_publish_non_ascii_todo_survives_utf8( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A non-ASCII title round-trips byte-exact — UTF-8 with one + """A non-ASCII todo round-trips byte-exact — UTF-8 with one trailing newline, in the branch tree and on the remote board.""" _init_publish_repo(tmp_path) monkeypatch.chdir(tmp_path) monkeypatch.setenv("COLUMNS", "120") year = current_year() - topic_path = f".goga/history/{year}/feature-foo-bar/title.txt" + topic_path = f".goga/history/{year}/feature-foo-bar/todo.md" publish_topic( "Feature/Foo_Bar", "Оплата повторно", "origin/main", "goga: create topic {slug}" @@ -777,7 +867,7 @@ def test_publish_non_ascii_title_survives_utf8( assert result.exit_code == 0 assert "Оплата" in result.output - assert ("feature-foo-bar", "origin/Feature/Foo_Bar", "Оплата повторно", "[new]") in _board_rows( + assert ("feature-foo-bar", "origin/Feature/Foo_Bar", "Оплата повторно", "[todo]") in _board_rows( result.output, columns=4 ) @@ -910,7 +1000,7 @@ def test_publish_name_the_oracle_misses_never_deletes_real_work( monkeypatch.chdir(tmp_path) with pytest.raises(click.ClickException, match="reference already exists"): - publish_topic("v1", "Title", "origin/main", "goga: create topic {slug}") + publish_topic("v1", "Todo", "origin/main", "goga: create topic {slug}") assert _git_out(tmp_path, "rev-parse", "refs/heads/v1") == before assert "refs/remotes/origin/v1" not in _git_out(tmp_path, "for-each-ref", "refs/remotes/origin") @@ -934,7 +1024,7 @@ def test_publish_non_utf8_remote_output_rolls_back_and_surfaces_clean_error( monkeypatch.chdir(tmp_path) with pytest.raises(click.ClickException, match="remote rejected"): - publish_topic("Feature/Foo_Bar", "Title", "origin/main", "goga: create topic {slug}") + publish_topic("Feature/Foo_Bar", "Todo", "origin/main", "goga: create topic {slug}") # The planted branch was rolled back — nothing of the cycle survives. assert "refs/heads/Feature/Foo_Bar" not in _git_out( @@ -960,7 +1050,7 @@ def test_publish_newline_in_name_cannot_inject_a_second_command( injected = f"evil {base}\nupdate refs/heads/main" with pytest.raises(click.ClickException, match="invalid ref format"): - publish_topic(injected, "Title", "origin/main", "goga: create topic {slug}") + publish_topic(injected, "Todo", "origin/main", "goga: create topic {slug}") assert _git_out(tmp_path, "rev-parse", "refs/heads/main") == base assert _git_out(tmp_path, "for-each-ref", "--format=%(refname)", "refs/heads") == "refs/heads/main" @@ -977,8 +1067,9 @@ def test_publish_empty_template_does_not_wait_for_stdin( argument is empty, and the runner used to leave that stdin inherited from the caller: under a terminal or a harness-held open pipe — neither ever reaching EOF — the publish hung forever with no - output. The cycle must complete, the empty template taken as - verbatim as an empty ``--title`` writing its bare newline. + output. The cycle must complete — the empty template becomes the + commit message verbatim while the non-empty todo still carries the + todo.md payload of the published branch. """ _init_publish_repo(tmp_path) monkeypatch.chdir(tmp_path) @@ -1025,7 +1116,7 @@ def cycle() -> None: assert _git_out(tmp_path, "show", "-s", "--format=%s", "Feature/Foo_Bar") == "" assert ( _git_out( - tmp_path, "show", f"Feature/Foo_Bar:.goga/history/{year}/feature-foo-bar/title.txt" + tmp_path, "show", f"Feature/Foo_Bar:.goga/history/{year}/feature-foo-bar/todo.md" ) == "Payment retry" ) From 8fc247a84030e733f080ad8bb3d5205b05e63d2e Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Tue, 1 Sep 2026 18:20:34 +0000 Subject: [PATCH 168/229] fix: address code review findings --- README.md | 9 +- docs/cli/history.md | 2 +- docs/cli/topics.md | 43 +++++--- docs/configuration/project.md | 2 +- tests/commands/topics/test_topics_command.py | 100 +++++++++++++------ tests/topics/test_board.py | 4 + tests/topics/test_creation.py | 14 +++ tests/topics/test_publishing.py | 3 +- 8 files changed, 128 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 3d532180..d96cfd78 100644 --- a/README.md +++ b/README.md @@ -143,17 +143,18 @@ Work is organized as **topics** — one directory per piece of work under `.goga ```bash goga topics status # the board: every topic of the year across branches goga topics status --remote # same board over remote-tracking refs -goga topics status --info # the board with the title column (first line of title.txt) +goga topics status --info # the board with the todo column (the todo summary of todo.md) goga topics create feat/x # fresh work: the branch verbatim + its topic directory -goga topics create feat/x -t "Payment retry" # same, and writes title.txt (status: new) +goga topics create feat/x -t "Payment retry" # same, and writes todo.md (status: todo) +goga topics create feat/x -t # same, then an interactive multi-line todo entry goga topics create feat/x -p -t "Payment retry" # same, committed + pushed to origin, no switch goga topics switch feat-x # onto the branch hosting that work (branch, slug, or prefix) goga topics --year 2025 status # the board of an explicit year ``` -`--publish`/`-p` is the fast mode: it builds the branch off an explicit base (`--base-ref`, or `topics.base_ref` in `.goga/config.yml`) with a single `title.txt` commit and pushes it to `origin` without switching — your working copy, index, and HEAD stay untouched, and a failed push rolls the branch back. See [`goga topics`](https://qarium.github.io/goga/cli/topics/). +`--publish`/`-p` is the fast mode: it builds the branch off an explicit base (`--base-ref`, or `topics.base_ref` in `.goga/config.yml`) with a single `todo.md` commit and pushes it to `origin` without switching — your working copy, index, and HEAD stay untouched, and a failed push rolls the branch back. See [`goga topics`](https://qarium.github.io/goga/cli/topics/). -The board is a three-column table — topic, branch, statuses, plus a Title column under `--info` — with `*` marking the current branch and a local branch absorbing its remote twin. Each topic carries its **maximal statuses** in scale order: `empty → new → defined → discovered → backlog → designed → specified → planned → done`, deepening as `title.txt`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. A topic can carry several statuses at once (`goga history status` prints them; `-s` filters by any of them). +The board is a three-column table — topic, branch, statuses, plus a todo column under `--info` — with `*` marking the current branch and a local branch absorbing its remote twin. Each topic carries its **maximal statuses** in scale order: `empty → todo → defined → discovered → backlog → designed → specified → planned → done`, deepening as `todo.md`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. A topic can carry several statuses at once (`goga history status` prints them; `-s` filters by any of them). Topics no branch hosts anymore are orphans — `goga history prune --dry-run` lists the orphans of a year, and `goga history prune [YEAR]` deletes them (irreversibly: the history tree is not in git). diff --git a/docs/cli/history.md b/docs/cli/history.md index 13c1d6de..add25271 100644 --- a/docs/cli/history.md +++ b/docs/cli/history.md @@ -42,7 +42,7 @@ A topic carries its **maximal present statuses** in scale order — one brackete | Status | Artifact | | |---|---|---| | `empty` | — | no artifact yet | -| `new` | `title.txt` | written by `goga topics create --title` | +| `todo` | `todo.md` | written by `goga topics create --todo` | | `defined` | `prd.md` | | | `discovered` | `adr.md` | | | `backlog` | `task.md` | | diff --git a/docs/cli/topics.md b/docs/cli/topics.md index f0414dea..dfb9f4f8 100644 --- a/docs/cli/topics.md +++ b/docs/cli/topics.md @@ -8,7 +8,7 @@ Work with the topics of one year — the cross-branch inventory, fresh-work crea ```bash goga topics [--year YYYY] status [--remote] [--info] -goga topics [--year YYYY] create BRANCH_NAME [--title TITLE] [--publish] [--base-ref REF] [--commit TEMPLATE] +goga topics [--year YYYY] create BRANCH_NAME [--todo [TEXT]] [--publish] [--base-ref REF] [--commit TEMPLATE] goga topics [--year YYYY] switch IDENTIFIER ``` @@ -31,11 +31,11 @@ Prints the board — the cross-branch topic inventory of the scoped year — as - A local branch and its remote twin collapse to one row — the local branch wins; a topic hosted only by a remote-tracking ref keeps its row with the remote name in the branch column. - Rows sort by scale order of the first maximal status, then alphabetically by topic. - `--remote`/`-r` reads remote-tracking refs instead of local branches; the current branch shows through its remote twin. -- `--info`/`-i` adds the title column between branch and statuses — the first line of the topic's `title.txt`, an empty cell when the topic has none. The working copy reads the file directly; every other row reads it from the branch's tree (no checkout). +- `--info`/`-i` adds the todo column between branch and statuses — the first line of the topic's `todo.md` that yields text after leading `#` markers are stripped and the edges trimmed; a topic without a `todo.md`, or one whose every line reduces to emptiness, renders an empty cell. The working copy reads the file directly; every other row reads it from the branch's tree (no checkout). - The statuses column wraps onto continuation lines when the segments overflow the terminal width; the table never exceeds the width except on terminals below the narrow threshold of the active column rule — 33 columns for the three-column table, 44 with `--info` — where every column keeps a minimum of 8. - An empty board prints nothing and exits 0 — a year without topics is not an error. -The statuses are the topic's **maximal present statuses** in scale order — `empty, new, defined, discovered, backlog, designed, specified, planned, done`, deepening as `title.txt`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. Tool packages can add their own statuses, shown qualified (`mkdocs.published`); see [Tools](../tools.md). +The statuses are the topic's **maximal present statuses** in scale order — `empty, todo, defined, discovered, backlog, designed, specified, planned, done`, deepening as `todo.md`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. Tool packages can add their own statuses, shown qualified (`mkdocs.published`); see [Tools](../tools.md). ## `goga topics create` @@ -45,30 +45,49 @@ Creates fresh work — a branch named exactly as entered, plus the topic directo goga topics create Feature/Foo_Bar # Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar -goga topics create Feature/Foo_Bar --title "Payment retry" +goga topics create Feature/Foo_Bar --todo "Payment retry" # Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar -# (.goga/history/2026/feature-foo-bar/title.txt now carries "Payment retry") +# (.goga/history/2026/feature-foo-bar/todo.md now carries "Payment retry") ``` - The branch name is taken verbatim (`git switch -c`); git itself rejects invalid names. -- The topic directory is `.goga/history/<YYYY>/<slug>/`, where the slug is the normalized name (lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed: `Feature/Foo_Bar` → `feature-foo-bar`). No artifact file is written unless `--title` is given. -- `-t`/`--title` writes the topic title file `title.txt` in the topic directory — the title as entered plus one trailing newline, UTF-8 — which marks the topic `new` on the status scale and feeds the `--info` column of the board. -- The current branch already hosting the same slug is an idempotent success — `Branch <name> already hosts topic <YYYY>/<slug>` — with nothing touched, except that an explicit `--title` creates or overwrites the title file. +- The topic directory is `.goga/history/<YYYY>/<slug>/`, where the slug is the normalized name (lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed: `Feature/Foo_Bar` → `feature-foo-bar`). No artifact file is written unless a non-empty `--todo` is given. +- `-t`/`--todo` writes the topic todo file `todo.md` in the topic directory — the multi-line text as entered plus one trailing newline, UTF-8 — which marks the topic `todo` on the status scale and feeds the `--info` column of the board. An empty todo — `--todo ""`, `--todo=`, `-t ""` — is not a written value: it starts the interactive entry like the bare flag, and no todo.md is ever created empty. +- The current branch already hosting the same slug is an idempotent success — `Branch <name> already hosts topic <YYYY>/<slug>` — with nothing touched, except that an explicit non-empty `--todo` creates or overwrites the todo file. - Occupancy is probed against three oracles in order: a local branch with the entered name, a remote-tracking branch with the entered name (local refs only — no network), and an existing `.goga/history/<YYYY>/<slug>/` directory (only a directory occupies a topic). - An occupied name or a name that normalizes to an empty slug (a fully non-ASCII name) prints the reason and prompts for a new name on an interactive terminal, restarting with it; with no terminal it exits 1 with the reason (and a hint to `goga topics status` for occupied names). Ctrl-C at the prompt aborts with nothing created. +### Interactive todo entry + +`--todo` given without a value (a bare `-t`) starts an interactive multi-line entry instead of taking the text from the command line: + +``` +$ goga topics create feat/x -t +Enter the todo. Finish with a lone '.' line or Ctrl+D. +Fix payment retries. + +Retries ignore the cap. +. +# Created branch feat/x and topic 2026/feat-x +``` + +- One line per input; every entered line continues the text, and an empty line stays in it as a paragraph separator. +- A lone `.` line or Ctrl+D (EOF) finishes the entry; Ctrl+C aborts the command — it is not a terminator. +- Entering nothing at all cancels the entry — the command continues as without the flag, and no `todo.md` is written. +- A terminal without a TTY is a clean error before any mutation: `todo entry needs an interactive terminal` (exit 1). + ### `--publish` — create and publish in one step -`-p`/`--publish` builds the branch off an explicit base, commits only the topic's `title.txt` on it, and pushes it to `origin` — while you stay on your branch: +`-p`/`--publish` builds the branch off an explicit base, commits only the topic's `todo.md` on it, and pushes it to `origin` — while you stay on your branch: ```bash -goga topics create Feature/Foo_Bar --publish --title "Payment retry" +goga topics create Feature/Foo_Bar --publish --todo "Payment retry" # Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar ``` - The working copy, the index, and HEAD stay untouched — the commit is built through quarantined git plumbing, so a dirty tree and a detached HEAD do not interfere; the topic directory is never created on disk. -- The branch carries exactly one commit — the title file at `.goga/history/<YYYY>/<slug>/title.txt` — and is pushed to `origin` with upstream binding (`git push -u`, exactly that one branch). The topic appears on the remote board with the `new` status. -- `-t`/`--title` is **required** under `--publish` (the board reads the topic through the title file): without it, exit 1 with `--publish needs a topic title — pass --title/-t`. An explicit empty title `""` is accepted and writes the bare newline. +- The branch carries exactly one commit — the todo file at `.goga/history/<YYYY>/<slug>/todo.md` — and is pushed to `origin` with upstream binding (`git push -u`, exactly that one branch). The topic appears on the remote board with the `todo` status. +- `-t`/`--todo` is **required** under `--publish` (the board reads the topic through the todo file). The bare flag — or an explicitly empty value — resolves through the interactive entry first; without a TTY that entry is a clean error. A missing or cancelled todo exits 1 with `--publish needs a todo — pass --todo/-t; the board reads the topic through todo.md`, and an empty todo reaching the domain is a clean error before any mutation: `the fast path needs a non-empty todo — pass the text or enter it interactively`. - Base resolution: `--base-ref` > `topics.base_ref` in `.goga/config.yml` > error. With nothing set, exit 1 with a message naming both the configuration line and the flag, including a two-line YAML example (see [Project Configuration](../configuration/project.md#topics)). - Commit template: `--commit`/`-c` > `topics.publish_commit` > the built-in default `goga: create topic {slug}`. `{slug}` is replaced with the topic slug; a template without the placeholder is used verbatim. - `--base-ref` or `--commit` without `--publish` is a clean error (exit 1) — they act only together with `--publish`. diff --git a/docs/configuration/project.md b/docs/configuration/project.md index c8a677c0..9d7410c6 100644 --- a/docs/configuration/project.md +++ b/docs/configuration/project.md @@ -191,7 +191,7 @@ Optional section consumed by [`goga topics create --publish`](../cli/topics.md#- | Field | Type | Required | Description | |-------|------|----------|-------------| | `topics.base_ref` | `string` | No | Base revision of a published topic branch — any revision string (branch, remote-tracking ref, tag, hash), stored verbatim with no resolvability check. Absent/YAML-null/empty/whitespace resolves to `None`; a non-string raises `ValueError`. Overridden by the `--base-ref` CLI option; when neither is set, `create --publish` exits 1 | -| `topics.publish_commit` | `string` | No | Commit message template of the published title commit; the optional `{slug}` placeholder is replaced with the topic slug, and a template without it is used verbatim. Same normalization and typing rules as `base_ref`. Overridden by the `--commit`/`-c` CLI option; the built-in default is `goga: create topic {slug}` | +| `topics.publish_commit` | `string` | No | Commit message template of the published todo commit; the optional `{slug}` placeholder is replaced with the topic slug, and a template without it is used verbatim. Same normalization and typing rules as `base_ref`. Overridden by the `--commit`/`-c` CLI option; the built-in default is `goga: create topic {slug}` | When `topics` is absent, `config.topics` is `None` ("everything unset"). Unknown keys inside the mapping are ignored — the same stance as `lint` and `codemanifest`. diff --git a/tests/commands/topics/test_topics_command.py b/tests/commands/topics/test_topics_command.py index 7ac80b30..b8390a8f 100644 --- a/tests/commands/topics/test_topics_command.py +++ b/tests/commands/topics/test_topics_command.py @@ -399,6 +399,18 @@ def test_create_flag_with_value_passes_todo(self, flag_form: list[str]) -> None: assert result.exit_code == 0 assert mock_create.call_args == mock.call("feat-a", None, "Payment retry") + @pytest.mark.parametrize("flag_form", [["--todo="], ["-t", ""]]) + def test_create_explicit_empty_value_is_the_entry_marker(self, flag_form: list[str]) -> None: + """An explicitly empty value is indistinguishable from the bare flag — the entry runs.""" + with ( + mock.patch.object(_topics_module, "_prompt_multiline", return_value="entered") as mock_entry, + mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create, + ): + result = CliRunner().invoke(topics, ["create", "feat-a", *flag_form]) + assert result.exit_code == 0 + mock_entry.assert_called_once_with("todo") + assert mock_create.call_args.args[2] == "entered" + def test_create_bare_flag_resolves_todo_through_entry(self) -> None: """A bare -t resolves to the entry marker and its text reaches the domain.""" with ( @@ -628,6 +640,32 @@ def test_create_publish_delegates_resolved_todo(self) -> None: assert mock_publish.call_args == mock.call("X", "T", "origin/main", "goga: create topic {slug}", None) assert result.output == "line\n" + def test_create_publish_bare_flag_resolves_todo_through_entry(self) -> None: + """A bare -t under --publish resolves through the entry; the entered text is published.""" + with ( + mock.patch.object( + _topics_module, "_prompt_multiline", return_value="line one\n\nline two" + ) as mock_entry, + mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish, + ): + result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t", "--base-ref", "origin/main"]) + assert result.exit_code == 0 + mock_entry.assert_called_once_with("todo") + assert mock_publish.call_args == mock.call( + "X", "line one\n\nline two", "origin/main", "goga: create topic {slug}", None + ) + + def test_create_publish_bare_flag_entry_cancel_is_clean_error(self) -> None: + """A cancelled entry under --publish hits the publish gate — never a todo-less publish.""" + with ( + mock.patch.object(_topics_module, "_prompt_multiline", return_value=None), + mock.patch.object(_topics_module, "publish_topic") as mock_publish, + ): + result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t"]) + assert result.exit_code == 1 + assert "--publish needs a todo" in result.stderr + mock_publish.assert_not_called() + def test_create_publish_without_base_names_config_and_flag( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -672,6 +710,34 @@ def test_create_publish_unreadable_config_surfaces_clean_error( assert not isinstance(result.exception, IsADirectoryError) mock_publish.assert_not_called() + def test_create_default_path_never_reads_configuration( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Without --publish the configuration is never read — a config-less repository works.""" + monkeypatch.chdir(tmp_path) + with ( + mock.patch.object(_topics_module, "load_project_config") as mock_load, + mock.patch.object( + _topics_module, "create_topic", return_value="Created branch Feature/Foo_Bar and topic 2026/x" + ) as mock_create, + ): + result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with("Feature/Foo_Bar", None, None) + mock_load.assert_not_called() + + def test_create_publish_missing_config_counts_as_unset( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A missing configuration file counts as unset — the flag and the default act.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish: + result = CliRunner().invoke( + topics, ["create", "X", "--publish", "--todo", "T", "--base-ref", "origin/main"] + ) + assert result.exit_code == 0 + mock_publish.assert_called_once_with("X", "T", "origin/main", "goga: create topic {slug}", None) + class TestMultilineEntry: """The interactive entry cycle — driven by direct calls, never CliRunner. @@ -687,13 +753,17 @@ def _tty(self, monkeypatch: pytest.MonkeyPatch) -> None: """Mock sys.stdin as an interactive terminal.""" monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) - def test_entry_collects_paragraphs(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_entry_collects_paragraphs( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: """Entered lines join with single newlines — an empty line stays a text line.""" self._tty(monkeypatch) with mock.patch.object( click.termui, "visible_prompt_func", side_effect=["line one", "", "line two", "."] ): assert _topics_module._prompt_multiline("todo") == "line one\n\nline two" + # The rule is stated in the prompt itself — before the first input. + assert "Enter the todo. Finish with a lone '.' line or Ctrl+D." in capsys.readouterr().out def test_entry_eof_terminator_returns_collected_text(self, monkeypatch: pytest.MonkeyPatch) -> None: """EOF (Ctrl+D) finishes the entry exactly like the dot terminator.""" @@ -730,31 +800,3 @@ def test_entry_non_interactive_terminal_is_clean_error(self, monkeypatch: pytest ): _topics_module._prompt_multiline("todo") mock_prompt.assert_not_called() - - def test_create_default_path_never_reads_configuration( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Without --publish the configuration is never read — a config-less repository works.""" - monkeypatch.chdir(tmp_path) - with ( - mock.patch.object(_topics_module, "load_project_config") as mock_load, - mock.patch.object( - _topics_module, "create_topic", return_value="Created branch Feature/Foo_Bar and topic 2026/x" - ) as mock_create, - ): - result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar"]) - assert result.exit_code == 0 - mock_create.assert_called_once_with("Feature/Foo_Bar", None, None) - mock_load.assert_not_called() - - def test_create_publish_missing_config_counts_as_unset( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """A missing configuration file counts as unset — the flag and the default act.""" - monkeypatch.chdir(tmp_path) - with mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish: - result = CliRunner().invoke( - topics, ["create", "X", "--publish", "--todo", "T", "--base-ref", "origin/main"] - ) - assert result.exit_code == 0 - mock_publish.assert_called_once_with("X", "T", "origin/main", "goga: create topic {slug}", None) diff --git a/tests/topics/test_board.py b/tests/topics/test_board.py index 1088717b..029b41e6 100644 --- a/tests/topics/test_board.py +++ b/tests/topics/test_board.py @@ -540,6 +540,10 @@ def test_todo_summary_marker_not_at_line_start(self) -> None: """A marker after leading blanks is text — only line-start markers strip.""" assert board._todo_summary(" # indented marker\n") == "# indented marker" + def test_todo_summary_whitespace_only_yields_empty(self) -> None: + """Lines that strip to nothing never qualify — the file exists, the summary is empty.""" + assert board._todo_summary(" \n\t\n") == "" + class TestBoardInfrastructureBoundary: def test_git_failure_surfaces_as_clean_error( diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index e645cad8..01ec4fb4 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -447,6 +447,20 @@ def test_create_topic_writes_multiline_todo( # The todo file is the single artifact of the topic directory. assert [path.name for path in topic_dir.iterdir()] == ["todo.md"] + def test_create_topic_whitespace_todo_writes_verbatim( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A whitespace-only todo is a non-empty text — it passes the gate and is written verbatim.""" + monkeypatch.chdir(tmp_path) + create_and_switch = _wire_inventory(monkeypatch, [], current="main") + + result = create_topic("feat-a", year="2026", todo=" ") + + assert result == "Created branch feat-a and topic 2026/feat-a" + create_and_switch.assert_called_once_with("feat-a") + topic_dir = tmp_path / ".goga" / "history" / "2026" / "feat-a" + assert (topic_dir / "todo.md").read_bytes() == b" \n" + def test_create_topic_idempotent_current_host( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/topics/test_publishing.py b/tests/topics/test_publishing.py index 5aadbb80..69efc63e 100644 --- a/tests/topics/test_publishing.py +++ b/tests/topics/test_publishing.py @@ -213,7 +213,7 @@ class TestPublishTopic: ), ], ) - def test_publish_topic_commits_todo_file( + def test_publish_topic_commits_todo_file( # noqa: PLR0913, PLR0917 — the parametrized scenario columns self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -298,7 +298,6 @@ def test_publish_topic_empty_todo_clean_error_before_mutations( "the fast path needs a non-empty todo" " — pass the text or enter it interactively" ) - assert "non-empty todo" in raised.value.message cycle.commit_file_on_base.assert_not_called() cycle.origin_configured.assert_not_called() _assert_no_mutation(cycle) From ecca0df7cb6fcbfa46657850f24090dc9a8992bd Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Tue, 1 Sep 2026 18:47:18 +0000 Subject: [PATCH 169/229] chore(memory): update project memory --- .goga/memory/memory.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .goga/memory/memory.md diff --git a/.goga/memory/memory.md b/.goga/memory/memory.md new file mode 100644 index 00000000..0062f70b --- /dev/null +++ b/.goga/memory/memory.md @@ -0,0 +1,17 @@ +# Project rules + +## Boundary isolation of external interaction + +All mechanics of interacting with a human — prompt loops, entry-termination detection, terminal capability checks — belong exclusively to the outermost command layer. Deeper layers receive already-resolved primitive values and stay free of any dependency on the input device or environment. + +## Domain-anchored data invariants + +Rules governing the integrity of produced data (for example, that an empty artifact can never be created) are stated and enforced in the core contracts, so every entry point — interactive or programmatic — is forced to conform. The outer layers are merely callers of the enforcing core. + +## Declarative contract altitude + +Contract annotations describe observable behavior — what terminates an entry, how absent or empty input is handled, which error is produced — and never narrate implementation devices or library mechanics. Reusable procedural know-how lives in dedicated practice documents beside their consumers, not inside contracts. + +## Total, unambiguous degenerate-state semantics + +The state space of inputs and artifacts is enumerated explicitly, with every state keeping a distinct meaning: absence is never conflated with presence that normalizes to nothing; a valued, valueless, and missing option are three separate states; observation never mutates; and unsatisfiable states fail with clean user-facing errors instead of raw crashes. From bb4c84515e66e10f43455a7dcd27a81ac828f0ae Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 00:16:35 +0300 Subject: [PATCH 170/229] fix: delete common memory --- .goga/memory/memory.md | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 .goga/memory/memory.md diff --git a/.goga/memory/memory.md b/.goga/memory/memory.md deleted file mode 100644 index 0062f70b..00000000 --- a/.goga/memory/memory.md +++ /dev/null @@ -1,17 +0,0 @@ -# Project rules - -## Boundary isolation of external interaction - -All mechanics of interacting with a human — prompt loops, entry-termination detection, terminal capability checks — belong exclusively to the outermost command layer. Deeper layers receive already-resolved primitive values and stay free of any dependency on the input device or environment. - -## Domain-anchored data invariants - -Rules governing the integrity of produced data (for example, that an empty artifact can never be created) are stated and enforced in the core contracts, so every entry point — interactive or programmatic — is forced to conform. The outer layers are merely callers of the enforcing core. - -## Declarative contract altitude - -Contract annotations describe observable behavior — what terminates an entry, how absent or empty input is handled, which error is produced — and never narrate implementation devices or library mechanics. Reusable procedural know-how lives in dedicated practice documents beside their consumers, not inside contracts. - -## Total, unambiguous degenerate-state semantics - -The state space of inputs and artifacts is enumerated explicitly, with every state keeping a distinct meaning: absence is never conflated with presence that normalizes to nothing; a valued, valueless, and missing option are three separate states; observation never mutates; and unsatisfiable states fail with clean user-facing errors instead of raw crashes. From 0e34662945d67ec8c5020dc4f62a726a26eec404 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 00:27:51 +0300 Subject: [PATCH 171/229] fix: code style --- goga/commands/topics/render.py | 11 +++++++++++ goga/commands/topics/topics.py | 5 +++++ goga/topics/board.py | 12 ++++++++++++ goga/topics/creation.py | 8 ++++++++ goga/topics/ensuring.py | 2 ++ goga/topics/git/publish.py | 1 + goga/topics/publishing.py | 1 + goga/topics/switching.py | 20 ++++++++++++++++++++ 8 files changed, 60 insertions(+) diff --git a/goga/commands/topics/render.py b/goga/commands/topics/render.py index 0000b3f3..0b3deff9 100644 --- a/goga/commands/topics/render.py +++ b/goga/commands/topics/render.py @@ -73,17 +73,21 @@ def render_topic_board(records: list[BoardRecord], width: int, info: bool = Fals """ if not records: return + columns_count = 4 if info else 3 caps = _column_widths(width, columns_count) header = ("Topic", "Branch", "todo", "Statuses") if info else ("Topic", "Branch", "Statuses") + click.echo(_row_line(header, caps)) click.echo(_separator(caps)) + for record in records: topic_text = f"{_CURRENT_MARKER}{record.topic}" if record.current else record.topic leading = ( (topic_text, record.branch, record.todo or "") if info else (topic_text, record.branch) ) segments = [f"[{status}]" for status in record.statuses] + for index, statuses_line in enumerate(_wrap_segments(segments, caps[-1])): cells = (*(cell if index == 0 else "" for cell in leading), statuses_line) click.echo(_row_line(cells, caps)) @@ -103,9 +107,12 @@ def _column_widths(width: int, columns_count: int) -> tuple[int, ...]: minimum of 8 and the table may exceed ``width``. """ usable = width - 3 * columns_count + if usable < columns_count * _MIN_COLUMN: return (_MIN_COLUMN,) * columns_count + cap = usable // columns_count + return (cap,) * (columns_count - 1) + (usable - (columns_count - 1) * cap,) @@ -168,6 +175,7 @@ def _truncate(text: str, cap: int) -> str: """ if len(text) > cap: return f"{text[: cap - 1]}{_ELLIPSIS}" + return text @@ -186,6 +194,7 @@ def _wrap_segments(segments: list[str], statuses_w: int) -> list[str]: """ lines: list[str] = [] current = "" + for segment in segments: piece = _truncate(segment, statuses_w) if not current: @@ -195,6 +204,8 @@ def _wrap_segments(segments: list[str], statuses_w: int) -> list[str]: else: lines.append(current) current = piece + if current or not lines: lines.append(current) + return lines diff --git a/goga/commands/topics/topics.py b/goga/commands/topics/topics.py index 3bf6f536..be154e7c 100644 --- a/goga/commands/topics/topics.py +++ b/goga/commands/topics/topics.py @@ -78,8 +78,10 @@ def _prompt_multiline(label: str) -> str | None: """ if not sys.stdin.isatty(): raise click.ClickException(f"{label} entry needs an interactive terminal") + click.echo(f"Enter the {label}. Finish with a lone '.' line or Ctrl+D.") lines: list[str] = [] + while True: try: # Resolved through the module attribute at call time — a @@ -89,9 +91,12 @@ def _prompt_multiline(label: str) -> str | None: break except KeyboardInterrupt: raise click.Abort() from None + if line == ".": break + lines.append(line) + text = "\n".join(lines) return text if text else None diff --git a/goga/topics/board.py b/goga/topics/board.py index 7ed0a35a..5dc18ee0 100644 --- a/goga/topics/board.py +++ b/goga/topics/board.py @@ -174,9 +174,12 @@ def _board_records(year: str | None, remote: bool) -> list[BoardRecord]: _todo_summary(read_ref_file(ref.name, todo_path)), ) continue + hosted = _current_branch_topic(current, resolved_year, scale) + if hosted is None: continue + slug, statuses, todo = hosted rows[(slug, ref.name)] = (False, statuses, todo) @@ -191,8 +194,10 @@ def _board_records(year: str | None, remote: bool) -> list[BoardRecord]: ) for (slug, branch), (is_remote, statuses, todo) in _collapse_remote_twins(rows).items() ] + scale_order = {stage.name: index for index, stage in enumerate(scale.stages)} records.sort(key=lambda record: (scale_order[record.statuses[0]], record.topic)) + return records @@ -240,11 +245,14 @@ def _year_topics(paths: list[str], year: str) -> dict[str, list[str]]: artifact paths relative to the topic directory. """ topics: dict[str, list[str]] = {} + for path in paths: parts = path.split("/") if len(parts) < _TOPIC_PATH_PARTS or parts[2] != year: continue + topics.setdefault(parts[3], []).append("/".join(parts[4:])) + return topics @@ -268,12 +276,15 @@ def _current_branch_topic( summary, or ``None`` when the branch hosts no topic of the year. """ slug = normalize_topic_slug(current) + if slug == "": return None if not topic_exists(current, year): return None + topic_dir = resolve_topic_dir(current, year) todo = _todo_summary(_read_working(topic_dir / _TODO_FILE)) + return slug, resolve_topic_status(topic_dir, scale), todo @@ -324,6 +335,7 @@ def _collapse_remote_twins(rows: dict[tuple[str, str], _Row]) -> dict[tuple[str, The rows without the collapsed remote twins — the local branch wins. """ local_keys = {key for key, row in rows.items() if not row[0]} + return { key: row for key, row in rows.items() diff --git a/goga/topics/creation.py b/goga/topics/creation.py index a6cc1295..9c4b5b9c 100644 --- a/goga/topics/creation.py +++ b/goga/topics/creation.py @@ -221,14 +221,17 @@ def _occupancy_conflict( """ resolved_year = year or current_year() refs = list_branch_refs() + if any(not ref.remote and ref.name == branch_name for ref in refs): return f"branch '{branch_name}' already exists" if any( ref.remote and ref.name.partition("/")[2] == branch_name for ref in refs ): return f"remote-tracking branch '{branch_name}' already exists" + if topic_exists(slug, resolved_year): return f"history topic '{slug}' already exists for {resolved_year}" + return None @@ -246,11 +249,13 @@ def _slug_conflict(slug: str, year: str | None) -> str | None: # The trailing slash is load-bearing: it keeps a sibling slug that only # shares the prefix text ("feature-foo-bar" of "feature-foo") free. prefix = f"{resolve_history_root().as_posix()}/{resolved_year}/{slug}/" + for ref in list_branch_refs(): if read_ref_tree_paths(ref.name, prefix): return ( f"topic '{slug}' of {resolved_year} is already hosted by branch '{ref.name}'" ) + return None @@ -267,6 +272,7 @@ def _create_topic(branch_name: str, year: str | None, todo: str | None) -> str: The single result line of the outcome. """ resolved_year = year or current_year() + while True: slug = normalize_topic_slug(branch_name) @@ -291,6 +297,7 @@ def _create_topic(branch_name: str, year: str | None, todo: str | None) -> str: ensure_topic_dir(branch_name, resolved_year) if todo: _write_todo(branch_name, resolved_year, todo) + return f"Created branch {branch_name} and topic {resolved_year}/{slug}" @@ -329,5 +336,6 @@ def _reask(reason: str, hint: str = "") -> str: if not sys.stdin.isatty(): message = f"{reason} — {hint}" if hint else reason raise click.ClickException(message) + click.echo(reason, err=True) return click.prompt("New branch name") diff --git a/goga/topics/ensuring.py b/goga/topics/ensuring.py index ebd6ff71..9b35e45b 100644 --- a/goga/topics/ensuring.py +++ b/goga/topics/ensuring.py @@ -88,6 +88,8 @@ def _ensure_topic(identifier: str, year: str | None) -> str: The single result line of the outcome. """ candidates = resolve_switch_candidates(identifier, year) + if not candidates: return create_topic(identifier, year) + return _switch_to_candidate(candidates) diff --git a/goga/topics/git/publish.py b/goga/topics/git/publish.py index 04ef8f0f..dfed4f74 100644 --- a/goga/topics/git/publish.py +++ b/goga/topics/git/publish.py @@ -100,6 +100,7 @@ def commit_file_on_base(base: str, path: str, content: str, message: str) -> str fd, name = tempfile.mkstemp(dir=git_dir, prefix="goga-publish-index-") os.close(fd) index = Path(name) + try: _run_git(["git", "read-tree", base], index=index) blob = _run_git(["git", "hash-object", "-w", "--stdin"], input=content).stdout.strip() diff --git a/goga/topics/publishing.py b/goga/topics/publishing.py index 6dfc84ba..8878fb26 100644 --- a/goga/topics/publishing.py +++ b/goga/topics/publishing.py @@ -109,6 +109,7 @@ def _publish_topic( The single result line of the outcome. """ resolved_year = year or current_year() + while True: slug = normalize_topic_slug(branch_name) diff --git a/goga/topics/switching.py b/goga/topics/switching.py index f96e0b0b..ef5b6d38 100644 --- a/goga/topics/switching.py +++ b/goga/topics/switching.py @@ -209,9 +209,11 @@ def _resolve_switch_candidates( or (slug != "" and candidate.topic is not None and candidate.topic.startswith(slug)) ], ) + for tier in tiers: if tier: return _unique_candidates(tier) + return [] @@ -238,6 +240,7 @@ def _hosted_candidates( """ topics_by_ref = _year_topics_by_ref(refs, year) hosted: list[tuple[BranchRef, str | None, list[str]]] = [] + for ref in refs: if current is not None and not ref.remote and ref.name == current: working_copy = _current_branch_topic(current, year, scale) @@ -247,13 +250,16 @@ def _hosted_candidates( slug, statuses, _todo = working_copy hosted.append((ref, slug, statuses)) continue + topics = topics_by_ref[ref.name] if not topics: hosted.append((ref, None, [])) continue for slug, artifacts in topics.items(): hosted.append((ref, slug, scale.maximal_present(artifacts))) + hosted.sort(key=lambda entry: (entry[0].remote, entry[0].name, entry[1] or "")) + return [ SwitchCandidate( branch=ref.name, @@ -289,6 +295,7 @@ def _unique_candidates(candidates: list[SwitchCandidate]) -> list[SwitchCandidat } unique: list[SwitchCandidate] = [] branches: set[str] = set() + for candidate in candidates: hosted_twin = (candidate.topic, _short_name(candidate.branch)) in local_topics if candidate.remote and hosted_twin: @@ -297,6 +304,7 @@ def _unique_candidates(candidates: list[SwitchCandidate]) -> list[SwitchCandidat continue branches.add(candidate.branch) unique.append(candidate) + return unique @@ -311,10 +319,12 @@ def _switch_topic(identifier: str, year: str | None) -> str: The single result line of the outcome. """ candidates = resolve_switch_candidates(identifier, year) + if not candidates: raise click.ClickException( f"no branch hosts {identifier!r} — run 'goga topics status' to see the board" ) + return _switch_to_candidate(candidates) @@ -335,6 +345,7 @@ def _switch_to_candidate(candidates: list[SwitchCandidate]) -> str: click.Abort: Ctrl-C or EOF at the selection prompt. """ chosen = candidates[0] if len(candidates) == 1 else _choose_candidate(candidates) + if chosen.current: return f"Already on branch {chosen.branch}" if not is_working_tree_clean(): @@ -342,8 +353,10 @@ def _switch_to_candidate(candidates: list[SwitchCandidate]) -> str: if not chosen.remote: checkout_local_branch(chosen.branch) return f"Switched to branch {chosen.branch}" + create_branch_from_remote_tracking(BranchRef(name=chosen.branch, remote=True)) short = chosen.branch.partition("/")[2] + return f"Created branch {short} from {chosen.branch}" @@ -362,13 +375,17 @@ def _choose_candidate(candidates: list[SwitchCandidate]) -> SwitchCandidate: click.Abort: Ctrl-C or EOF at the prompt. """ lines = _numbered_lines(candidates) + if not sys.stdin.isatty(): raise click.ClickException("\n".join(lines)) + for line in lines: click.echo(line) + number = click.prompt( "Select a branch by number", type=click.IntRange(1, len(candidates)) ) + return candidates[number - 1] @@ -383,11 +400,14 @@ def _numbered_lines(candidates: list[SwitchCandidate]) -> list[str]: the topic and the status segments present only when hosted. """ lines = [] + for index, candidate in enumerate(candidates, start=1): line = f"{index}) {candidate.branch}" if candidate.topic is not None: line += f" ({candidate.topic})" if candidate.statuses: line += " " + " ".join(f"[{status}]" for status in candidate.statuses) + lines.append(line) + return lines From 1ef99e952dafc6425a7c386805a596a1c09832df Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Tue, 1 Sep 2026 21:52:16 +0000 Subject: [PATCH 172/229] feat: rename topics status subcommand to board --- README.md | 6 +- docs/cli/history.md | 2 +- docs/cli/topics.md | 8 +-- .../commands/topics/.usages/topics-command.md | 10 +-- goga/commands/topics/CODEMANIFEST | 8 +-- goga/commands/topics/topics.py | 6 +- goga/topics/creation.py | 2 +- goga/topics/switching.py | 2 +- tests/commands/topics/test_topics_command.py | 68 +++++++++---------- tests/integration/test_topic_workflows.py | 38 +++++------ tests/topics/test_creation.py | 2 +- tests/topics/test_ensuring.py | 2 +- tests/topics/test_publishing.py | 4 +- tests/topics/test_switching.py | 2 +- 14 files changed, 80 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index d96cfd78..d3b709eb 100644 --- a/README.md +++ b/README.md @@ -141,9 +141,9 @@ goga schema | goga tool viewer Work is organized as **topics** — one directory per piece of work under `.goga/history/<year>/<topic>/`, each usually living on its own git branch. The `goga topics` command group manages them: ```bash -goga topics status # the board: every topic of the year across branches -goga topics status --remote # same board over remote-tracking refs -goga topics status --info # the board with the todo column (the todo summary of todo.md) +goga topics board # the board: every topic of the year across branches +goga topics board --remote # same board over remote-tracking refs +goga topics board --info # the board with the todo column (the todo summary of todo.md) goga topics create feat/x # fresh work: the branch verbatim + its topic directory goga topics create feat/x -t "Payment retry" # same, and writes todo.md (status: todo) goga topics create feat/x -t # same, then an interactive multi-line todo entry diff --git a/docs/cli/history.md b/docs/cli/history.md index add25271..df912c0a 100644 --- a/docs/cli/history.md +++ b/docs/cli/history.md @@ -107,4 +107,4 @@ goga history prune 2025 # one explicit year ## Notes - The topic slug grammar: lowercase, non-ASCII dropped, anything outside `[a-z0-9]` becomes `-`, repeat hyphens collapsed, edge hyphens trimmed (`Feature/Foo_Bar` → `feature-foo-bar`, `release/1.3.0` → `release-1-3-0`). -- `goga topics status` shows the same statuses across branches; `goga history status` shows the working copy of one year (see [topics](topics.md)). +- `goga topics board` shows the same statuses across branches; `goga history status` shows the working copy of one year (see [topics](topics.md)). diff --git a/docs/cli/topics.md b/docs/cli/topics.md index dfb9f4f8..59710b9b 100644 --- a/docs/cli/topics.md +++ b/docs/cli/topics.md @@ -7,14 +7,14 @@ Work with the topics of one year — the cross-branch inventory, fresh-work crea ## Synopsis ```bash -goga topics [--year YYYY] status [--remote] [--info] +goga topics [--year YYYY] board [--remote] [--info] goga topics [--year YYYY] create BRANCH_NAME [--todo [TEXT]] [--publish] [--base-ref REF] [--commit TEMPLATE] goga topics [--year YYYY] switch IDENTIFIER ``` `--year`/`-y` scopes every subcommand to one four-digit year (default: the current year). The year is never printed. -## `goga topics status` +## `goga topics board` Prints the board — the cross-branch topic inventory of the scoped year — as a three-column table: topic, branch, statuses. @@ -55,7 +55,7 @@ goga topics create Feature/Foo_Bar --todo "Payment retry" - `-t`/`--todo` writes the topic todo file `todo.md` in the topic directory — the multi-line text as entered plus one trailing newline, UTF-8 — which marks the topic `todo` on the status scale and feeds the `--info` column of the board. An empty todo — `--todo ""`, `--todo=`, `-t ""` — is not a written value: it starts the interactive entry like the bare flag, and no todo.md is ever created empty. - The current branch already hosting the same slug is an idempotent success — `Branch <name> already hosts topic <YYYY>/<slug>` — with nothing touched, except that an explicit non-empty `--todo` creates or overwrites the todo file. - Occupancy is probed against three oracles in order: a local branch with the entered name, a remote-tracking branch with the entered name (local refs only — no network), and an existing `.goga/history/<YYYY>/<slug>/` directory (only a directory occupies a topic). -- An occupied name or a name that normalizes to an empty slug (a fully non-ASCII name) prints the reason and prompts for a new name on an interactive terminal, restarting with it; with no terminal it exits 1 with the reason (and a hint to `goga topics status` for occupied names). Ctrl-C at the prompt aborts with nothing created. +- An occupied name or a name that normalizes to an empty slug (a fully non-ASCII name) prints the reason and prompts for a new name on an interactive terminal, restarting with it; with no terminal it exits 1 with the reason (and a hint to `goga topics board` for occupied names). Ctrl-C at the prompt aborts with nothing created. ### Interactive todo entry @@ -112,7 +112,7 @@ IDENTIFIER resolves through three tiers — the first tier with a match wins, so 3. prefix — a branch whose name, or whose hosted slug, starts with the input. - Several candidates on an interactive terminal: the numbered list with each candidate's statuses is printed and a number is prompted; with no terminal, the numbered list itself is the error (exit 1). -- No candidate at all: exit 1 with a hint to run `goga topics status`. +- No candidate at all: exit 1 with a hint to run `goga topics board`. - Already on the hosting branch: idempotent success — `Already on branch <name>` — with no working-tree probe and no mutation. - A local host is checked out (`git switch <branch>`); a remote-only host creates the local branch from the remote-tracking ref (`git switch -c <branch> <remote>/<branch>`, reported as `Created branch <branch> from <remote>/<branch>`). - A switch that would mutate first probes the working tree; a dirty tree exits 1 with `working tree is dirty — commit or stash before switching` before anything is touched. diff --git a/goga/commands/topics/.usages/topics-command.md b/goga/commands/topics/.usages/topics-command.md index 40c7e5db..dc997ad1 100644 --- a/goga/commands/topics/.usages/topics-command.md +++ b/goga/commands/topics/.usages/topics-command.md @@ -5,16 +5,16 @@ manage work as topics: boarding, creating, and switching; for the command facade that registers the group. The group scopes every subcommand to one year (--year/-y, default the -current year); the status subcommand reads remote-tracking refs with +current year); the board subcommand reads remote-tracking refs with --remote/-r and adds the todo column with --info/-i; the create subcommand publishes fresh work without switching under --publish/-p. ## Boarding all work - goga topics status - goga topics --year 2025 status - goga topics status --remote - goga topics status --info + goga topics board + goga topics --year 2025 board + goga topics board --remote + goga topics board --info Prints a three-column table — topic, branch, statuses — with column and row separators fitted to the terminal width. `--info/-i` adds the todo diff --git a/goga/commands/topics/CODEMANIFEST b/goga/commands/topics/CODEMANIFEST index 10d2ed70..d69719bf 100644 --- a/goga/commands/topics/CODEMANIFEST +++ b/goga/commands/topics/CODEMANIFEST @@ -69,7 +69,7 @@ Annotations: | the subcommand registration. Subcommand surfaces: - - status — a --remote/-r flag, an --info/-i flag + - board — a --remote/-r flag, an --info/-i flag - create — a NAME positional, a --todo/-t option, a --publish/-p flag, a --base-ref option, a --commit/-c option - switch — an IDENTIFIER positional @@ -77,8 +77,8 @@ Annotations: | Apply the `convention` CLI command docstring rule for the --help text (rendered verbatim by Click; omit Args/Returns/Raises). methods: - "status(remote: bool = False, info: bool = False) -> exit_code: int": | - Subcommand goga topics status: print the board — the cross-branch + "board(remote: bool = False, info: bool = False) -> exit_code: int": | + Subcommand goga topics board: print the board — the cross-branch topic inventory of the scoped year as a three-column table, or a four-column table with the todo column under --info/-i. @@ -250,5 +250,5 @@ Annotations: | Author: Goga CreatedAt: 29/08/26 Description: | - The goga topics command group with the status, create, and switch + The goga topics command group with the board, create, and switch subcommands over the topics domain. diff --git a/goga/commands/topics/topics.py b/goga/commands/topics/topics.py index be154e7c..d0074181 100644 --- a/goga/commands/topics/topics.py +++ b/goga/commands/topics/topics.py @@ -1,7 +1,7 @@ """The ``goga topics`` command group — the CLI surface of the topics domain. The click group declared in the cell CODEMANIFEST with ``location: -topics.py``: the ``status``/``create``/``switch`` subcommands over the +topics.py``: the ``board``/``create``/``switch`` subcommands over the topics domain. The group carries the year scope every subcommand shares and is a thin wrapper — it resolves the inputs, delegates every computation to the domain routines of ``goga.topics``, and renders the board through the @@ -115,7 +115,7 @@ def topics(ctx: click.Context, year: str | None = None) -> None: ctx.obj.year = year -@topics.command("status") +@topics.command("board") @click.option( "--remote", "-r", @@ -131,7 +131,7 @@ def topics(ctx: click.Context, year: str | None = None) -> None: help="Add the todo column to the table.", ) @click.pass_obj -def status(scope: _TopicsScope, remote: bool = False, info: bool = False) -> None: +def board(scope: _TopicsScope, remote: bool = False, info: bool = False) -> None: """Print the board — the cross-branch topic inventory of the scoped year. One three-column table row per topic: topic, branch, statuses — the row diff --git a/goga/topics/creation.py b/goga/topics/creation.py index 9c4b5b9c..c1a9abad 100644 --- a/goga/topics/creation.py +++ b/goga/topics/creation.py @@ -36,7 +36,7 @@ # The board hint of an occupancy conflict — where the occupied names are # visible to the user. -_BOARD_HINT = "run 'goga topics status' to see the board" +_BOARD_HINT = "run 'goga topics board' to see the board" def check_branch_occupancy( diff --git a/goga/topics/switching.py b/goga/topics/switching.py index ef5b6d38..24b148e8 100644 --- a/goga/topics/switching.py +++ b/goga/topics/switching.py @@ -322,7 +322,7 @@ def _switch_topic(identifier: str, year: str | None) -> str: if not candidates: raise click.ClickException( - f"no branch hosts {identifier!r} — run 'goga topics status' to see the board" + f"no branch hosts {identifier!r} — run 'goga topics board' to see the board" ) return _switch_to_candidate(candidates) diff --git a/tests/commands/topics/test_topics_command.py b/tests/commands/topics/test_topics_command.py index b8390a8f..c7db96f2 100644 --- a/tests/commands/topics/test_topics_command.py +++ b/tests/commands/topics/test_topics_command.py @@ -1,11 +1,11 @@ """Contract and logic tests for the entity declared in ``goga/commands/topics/CODEMANIFEST`` with ``location: topics.py``: -the ``topics`` click group with the ``status``/``create``/``switch`` +the ``topics`` click group with the ``board``/``create``/``switch`` subcommands. The group is a thin wrapper: the ``--year/-y`` option builds the scope every subcommand shares, and each subcommand delegates its computation to the -``goga.topics`` domain — the board collection and rendering for ``status`` +``goga.topics`` domain — the board collection and rendering for ``board`` (the ``--info/-i`` flag adds the todo column to the rendered table), the creation (``--todo/-t`` writes the topic todo file, a bare flag starting the interactive multi-line entry) and switching procedures for @@ -63,7 +63,7 @@ def test_topics_is_a_click_group(self) -> None: def test_topics_registers_three_subcommands(self) -> None: """The group carries exactly the three declared subcommands.""" - assert sorted(topics.commands) == ["create", "status", "switch"] + assert sorted(topics.commands) == ["board", "create", "switch"] def test_topics_group_carries_the_year_option(self) -> None: """The group owns the shared --year/-y option, defaulting to None.""" @@ -86,26 +86,26 @@ def test_scope_is_a_kw_only_dataclass_with_year(self) -> None: assert scope.year == "2025" assert _topics_module._TopicsScope().year is None - def test_status_callback_signature(self) -> None: - """``status(scope, remote=False, info=False)`` — the scope object and the flags.""" - callback = topics.commands["status"].callback + def test_board_callback_signature(self) -> None: + """``board(scope, remote=False, info=False)`` — the scope object and the flags.""" + callback = topics.commands["board"].callback signature = inspect.signature(callback) assert list(signature.parameters) == ["scope", "remote", "info"] assert signature.parameters["remote"].default is False assert signature.parameters["info"].default is False - def test_status_carries_the_remote_flag(self) -> None: - """status: --remote/-r flag, defaulting to False.""" - command = topics.commands["status"] + def test_board_carries_the_remote_flag(self) -> None: + """board: --remote/-r flag, defaulting to False.""" + command = topics.commands["board"] remote_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "remote") assert "-r" in remote_option.opts assert "--remote" in remote_option.opts assert remote_option.is_flag is True assert remote_option.default is False - def test_status_carries_the_info_flag(self) -> None: - """status: --info/-i flag, defaulting to False.""" - command = topics.commands["status"] + def test_board_carries_the_info_flag(self) -> None: + """board: --info/-i flag, defaulting to False.""" + command = topics.commands["board"] info_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "info") assert "-i" in info_option.opts assert "--info" in info_option.opts @@ -194,7 +194,7 @@ def test_topics_group_help_and_year_scope(self) -> None: result = runner.invoke(topics, ["--help"]) assert result.exit_code == 0 assert "Work with the topics of one year." in result.output - for subcommand in ("status", "create", "switch"): + for subcommand in ("board", "create", "switch"): assert subcommand in result.output assert "--year" in result.output assert "-y" in result.output @@ -205,7 +205,7 @@ def test_topics_group_help_and_year_scope(self) -> None: assert scoped.exit_code == 0 mock_create.assert_called_once_with("X", "2025", None) - @pytest.mark.parametrize("subcommand", ["status", "create", "switch"]) + @pytest.mark.parametrize("subcommand", ["board", "create", "switch"]) def test_subcommand_help_follows_the_cli_docstring_rule(self, subcommand: str) -> None: """The rendered help carries no Args/Returns/Raises sections.""" result = CliRunner().invoke(topics, [subcommand, "--help"]) @@ -235,9 +235,9 @@ def test_year_defaults_to_none_for_the_domain(self) -> None: mock_create.assert_called_once_with("X", None, None) -class TestTopicsStatus: - def test_status_collects_and_renders_the_board(self) -> None: - """status hands the domain (scope.year, remote) and renders the records.""" +class TestTopicsBoard: + def test_board_collects_and_renders_the_board(self) -> None: + """board hands the domain (scope.year, remote) and renders the records.""" records = [ BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=True, remote=False), ] @@ -245,7 +245,7 @@ def test_status_collects_and_renders_the_board(self) -> None: mock.patch.object(_topics_module, "collect_topic_board", return_value=records) as mock_collect, mock.patch.dict("os.environ", {"COLUMNS": "100"}), ): - result = CliRunner().invoke(topics, ["status"]) + result = CliRunner().invoke(topics, ["board"]) assert result.exit_code == 0 mock_collect.assert_called_once_with(None, False) assert "feat-a" in result.output @@ -253,27 +253,27 @@ def test_status_collects_and_renders_the_board(self) -> None: assert "[planned]" in result.output assert "| Topic" in result.output - def test_status_passes_the_year_and_the_remote_flag(self) -> None: + def test_board_passes_the_year_and_the_remote_flag(self) -> None: """--year and --remote/-r reach the domain call verbatim.""" with ( mock.patch.object(_topics_module, "collect_topic_board", return_value=[]) as mock_collect, mock.patch.dict("os.environ", {"COLUMNS": "100"}), ): - result = CliRunner().invoke(topics, ["--year", "2025", "status", "--remote"]) + result = CliRunner().invoke(topics, ["--year", "2025", "board", "--remote"]) assert result.exit_code == 0 mock_collect.assert_called_once_with("2025", True) - def test_status_short_forms_bind_the_same_values(self) -> None: + def test_board_short_forms_bind_the_same_values(self) -> None: """-y and -r behave exactly like their long forms.""" with ( mock.patch.object(_topics_module, "collect_topic_board", return_value=[]) as mock_collect, mock.patch.dict("os.environ", {"COLUMNS": "100"}), ): - result = CliRunner().invoke(topics, ["-y", "2024", "status", "-r"]) + result = CliRunner().invoke(topics, ["-y", "2024", "board", "-r"]) assert result.exit_code == 0 mock_collect.assert_called_once_with("2024", True) - def test_topics_status_info_flag_reaches_renderer(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_topics_board_info_flag_reaches_renderer(self, monkeypatch: pytest.MonkeyPatch) -> None: """--info reaches the renderer — the table gains the todo column.""" records = [ BoardRecord( @@ -292,7 +292,7 @@ def test_topics_status_info_flag_reaches_renderer(self, monkeypatch: pytest.Monk shutil, "get_terminal_size", lambda *_args, **_kwargs: os.terminal_size((100, 24)) ) with mock.patch.object(_topics_module, "collect_topic_board", return_value=records): - result = CliRunner().invoke(topics, ["status", "--info"]) + result = CliRunner().invoke(topics, ["board", "--info"]) assert result.exit_code == 0 header = result.output.splitlines()[0] assert "todo" in header @@ -301,7 +301,7 @@ def test_topics_status_info_flag_reaches_renderer(self, monkeypatch: pytest.Monk assert "Statuses" in header assert "Payment retry" in result.output - def test_topics_status_info_short_form_binds_the_same_table(self) -> None: + def test_topics_board_info_short_form_binds_the_same_table(self) -> None: """-i renders the same four-column table as --info.""" records = [ BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=False, remote=False, todo="T"), @@ -310,25 +310,25 @@ def test_topics_status_info_short_form_binds_the_same_table(self) -> None: mock.patch.object(_topics_module, "collect_topic_board", return_value=records), mock.patch.dict("os.environ", {"COLUMNS": "100"}), ): - short = CliRunner().invoke(topics, ["status", "-i"]) - long = CliRunner().invoke(topics, ["status", "--info"]) + short = CliRunner().invoke(topics, ["board", "-i"]) + long = CliRunner().invoke(topics, ["board", "--info"]) assert short.exit_code == 0 assert long.exit_code == 0 assert short.output == long.output assert "todo" in short.output.splitlines()[0] - def test_status_empty_board_prints_nothing_exit_zero(self) -> None: + def test_board_empty_board_prints_nothing_exit_zero(self) -> None: """An empty board is not an error — nothing on stdout, exit 0.""" with ( mock.patch.object(_topics_module, "collect_topic_board", return_value=[]), mock.patch.dict("os.environ", {"COLUMNS": "100"}), ): - result = CliRunner().invoke(topics, ["status"]) + result = CliRunner().invoke(topics, ["board"]) assert result.exit_code == 0 assert result.output == "" @pytest.mark.parametrize(("columns", "expected"), [(40, 40), (30, 33)]) - def test_status_measures_the_terminal_width(self, columns: int, expected: int) -> None: + def test_board_measures_the_terminal_width(self, columns: int, expected: int) -> None: """The render width is the measured terminal width, not a constant.""" records = [ BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=False, remote=False), @@ -337,7 +337,7 @@ def test_status_measures_the_terminal_width(self, columns: int, expected: int) - mock.patch.object(_topics_module, "collect_topic_board", return_value=records), mock.patch.dict("os.environ", {"COLUMNS": str(columns)}), ): - result = CliRunner().invoke(topics, ["status"]) + result = CliRunner().invoke(topics, ["board"]) assert result.exit_code == 0 # Width 40 lays out in thirds — the table fits it exactly; width 30 # is the documented ultra-narrow exception where the minimum 8/8/8 @@ -345,14 +345,14 @@ def test_status_measures_the_terminal_width(self, columns: int, expected: int) - assert result.output.splitlines() != [] assert all(len(line) == expected for line in result.output.splitlines()) - def test_status_domain_error_surfaces_clean(self) -> None: + def test_board_domain_error_surfaces_clean(self) -> None: """A domain ClickException propagates as stderr + exit 1, no traceback.""" with mock.patch.object( _topics_module, "collect_topic_board", - side_effect=click.ClickException("no branch hosts 'x' — run 'goga topics status' to see the board"), + side_effect=click.ClickException("no branch hosts 'x' — run 'goga topics board' to see the board"), ): - result = CliRunner().invoke(topics, ["status"]) + result = CliRunner().invoke(topics, ["board"]) assert result.exit_code == 1 assert "no branch hosts" in result.stderr assert "Traceback" not in result.stderr diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index 645df182..408ff18f 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -4,8 +4,8 @@ feature over its real surfaces — no domain routine and no renderer is mocked, only the boundaries the environment cannot provide: - goga topics status --year Y - -> goga.commands.topics.topics.status + goga topics board --year Y + -> goga.commands.topics.topics.board -> goga.topics.collect_topic_board -> [goga.history.assemble_status_scale (real tool packages) goga.topics.git.list_branch_refs / read_ref_tree_paths @@ -219,7 +219,7 @@ def _board_rows(output: str, columns: int = 3) -> list[tuple[str, ...]]: """Parse the rendered board into its data rows. Args: - output: The captured stdout of ``goga topics status``. + output: The captured stdout of ``goga topics board``. columns: The text-column count of the table — 3 without ``--info``, 4 with it (the todo column between branch and statuses). @@ -236,8 +236,8 @@ def _board_rows(output: str, columns: int = 3) -> list[tuple[str, ...]]: @requires_git -class TestTopicsStatusBoard: - """``goga topics status --year Y`` over a real repository and scale.""" +class TestTopicsBoard: + """``goga topics board --year Y`` over a real repository and scale.""" def test_board_renders_topics_statuses_and_current_marker( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -248,7 +248,7 @@ def test_board_renders_topics_statuses_and_current_marker( monkeypatch.chdir(tmp_path) monkeypatch.setenv("COLUMNS", "120") - result = CliRunner().invoke(topics, ["--year", "2025", "status"]) + result = CliRunner().invoke(topics, ["--year", "2025", "board"]) assert result.exit_code == 0 rows = _board_rows(result.output) @@ -272,7 +272,7 @@ def test_board_empty_year_prints_nothing_and_exits_zero( monkeypatch.chdir(tmp_path) monkeypatch.setenv("COLUMNS", "120") - result = CliRunner().invoke(topics, ["--year", "2030", "status"]) + result = CliRunner().invoke(topics, ["--year", "2030", "board"]) assert result.exit_code == 0 assert result.output == "" @@ -567,8 +567,8 @@ def test_create_topic_empty_todo_writes_no_file( @requires_git -class TestTopicsStatusTodos: - """The todo column of ``goga topics status --info`` over real reads.""" +class TestTopicsBoardTodos: + """The todo column of ``goga topics board --info`` over real reads.""" def test_board_survives_hand_edited_non_utf8_todos( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -598,16 +598,16 @@ def test_board_survives_hand_edited_non_utf8_todos( monkeypatch.chdir(tmp_path) monkeypatch.setenv("COLUMNS", "120") - result = CliRunner().invoke(topics, ["--year", "2025", "status", "--info"]) + result = CliRunner().invoke(topics, ["--year", "2025", "board", "--info"]) assert result.exit_code == 0 assert "Pay�ment" in result.output assert "Rem�ote" in result.output - def test_create_todo_then_status_info_shows_summary_and_todo_status( + def test_create_todo_then_board_info_shows_summary_and_todo_status( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """``create --todo`` and ``status --info`` close the loop over real git. + """``create --todo`` and ``board --info`` close the loop over real git. The written todo.md carries the multi-line todo verbatim plus one trailing newline, the board reads the topic through it — the @@ -636,7 +636,7 @@ def test_create_todo_then_status_info_shows_summary_and_todo_status( tmp_path / ".goga" / "history" / "2025" / "feat-new" / "todo.md" ).read_bytes() == b"###\n# Pay retry cap\n\nRetries ignore the cap.\n" - result = CliRunner().invoke(topics, ["--year", "2025", "status", "--info"]) + result = CliRunner().invoke(topics, ["--year", "2025", "board", "--info"]) assert result.exit_code == 0 assert ("* feat-new", "feat-new", "Pay retry cap", "[todo]") in _board_rows( @@ -663,7 +663,7 @@ def test_board_old_title_txt_only_topic_is_empty_status( monkeypatch.chdir(tmp_path) monkeypatch.setenv("COLUMNS", "120") - result = CliRunner().invoke(topics, ["--year", "2025", "status", "--info"]) + result = CliRunner().invoke(topics, ["--year", "2025", "board", "--info"]) assert result.exit_code == 0 assert ("legacy-work", "legacy", "", "[empty]") in _board_rows( @@ -803,7 +803,7 @@ def test_publish_creates_single_todo_commit_and_shows_on_remote_board( assert _git_out(tmp_path, "rev-parse", "--verify", "refs/heads/Feature/Foo_Bar") _git(tmp_path, "fetch", "-q", "origin") - result = CliRunner().invoke(topics, ["--year", year, "status", "--remote", "--info"]) + result = CliRunner().invoke(topics, ["--year", year, "board", "--remote", "--info"]) assert result.exit_code == 0 assert _board_rows(result.output, columns=4) == [ @@ -863,7 +863,7 @@ def test_publish_non_ascii_todo_survives_utf8( assert len(shown.stdout) == 30 _git(tmp_path, "fetch", "-q", "origin") - result = CliRunner().invoke(topics, ["--year", year, "status", "--remote", "--info"]) + result = CliRunner().invoke(topics, ["--year", year, "board", "--remote", "--info"]) assert result.exit_code == 0 assert "Оплата" in result.output @@ -891,7 +891,7 @@ def test_publish_slug_hosted_by_another_branch_is_blocked( assert raised.value.message == ( f"topic 'feature-foo-bar' of {year} is already hosted by branch 'Host_Branch'" - " — run 'goga topics status' to see the board" + " — run 'goga topics board' to see the board" ) assert _git_out(tmp_path, "for-each-ref", "refs/heads") == heads_before assert "Feature/Foo_Bar" not in _git_out(tmp_path, "for-each-ref", "refs/heads") @@ -925,7 +925,7 @@ def test_publish_from_a_subdirectory_still_sees_the_branch_tree_conflict( assert raised.value.message == ( f"topic 'feature-foo-bar' of {year} is already hosted by branch 'Host_Branch'" - " — run 'goga topics status' to see the board" + " — run 'goga topics board' to see the board" ) assert _git_out(tmp_path, "for-each-ref", "refs/heads") == heads_before assert "Feature/Foo_Bar" not in _git_out(tmp_path, "for-each-ref", "refs/heads") @@ -978,7 +978,7 @@ def test_publish_slug_hosted_only_on_origin_is_blocked( assert raised.value.message == ( f"topic 'remote-only' of {year} is already hosted by branch 'origin/Remote_Only'" - " — run 'goga topics status' to see the board" + " — run 'goga topics board' to see the board" ) def test_publish_name_the_oracle_misses_never_deletes_real_work( diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index 01ec4fb4..91fdd502 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -566,7 +566,7 @@ def test_create_topic_occupied_non_interactive_clean_error( create_topic("feat/x") assert raised.value.message == ( - "branch 'feat/x' already exists — run 'goga topics status' to see the board" + "branch 'feat/x' already exists — run 'goga topics board' to see the board" ) create_and_switch.assert_not_called() assert not (tmp_path / ".goga" / "history").exists() diff --git a/tests/topics/test_ensuring.py b/tests/topics/test_ensuring.py index 2dfcf3fd..a26fe7b4 100644 --- a/tests/topics/test_ensuring.py +++ b/tests/topics/test_ensuring.py @@ -189,7 +189,7 @@ def test_ensure_topic_remote_tracking_twin_is_occupied( ensure_topic("new-work", "2026") assert raised.value.message == ( - "remote-tracking branch 'new-work' already exists — run 'goga topics status' to see the board" + "remote-tracking branch 'new-work' already exists — run 'goga topics board' to see the board" ) create_and_switch.assert_not_called() diff --git a/tests/topics/test_publishing.py b/tests/topics/test_publishing.py index 69efc63e..97791e15 100644 --- a/tests/topics/test_publishing.py +++ b/tests/topics/test_publishing.py @@ -335,7 +335,7 @@ def test_publish_topic_conflict_without_terminal_fails_with_board_hint( assert raised.value.message == ( "topic 'feature-foo-bar' of 2026 is already hosted by branch 'alpha'" - " — run 'goga topics status' to see the board" + " — run 'goga topics board' to see the board" ) _assert_no_mutation(cycle) @@ -359,7 +359,7 @@ def test_publish_topic_branch_occupancy_conflict_skips_the_slug_oracle( assert raised.value.message == ( "branch 'Feature/Foo_Bar' already exists" - " — run 'goga topics status' to see the board" + " — run 'goga topics board' to see the board" ) cycle.check_slug_occupancy.assert_not_called() _assert_no_mutation(cycle) diff --git a/tests/topics/test_switching.py b/tests/topics/test_switching.py index b80a7daa..79cde256 100644 --- a/tests/topics/test_switching.py +++ b/tests/topics/test_switching.py @@ -568,7 +568,7 @@ def test_switch_topic_no_candidates_clean_error( with pytest.raises(click.ClickException) as raised: switch_topic("nope") - assert raised.value.message == ("no branch hosts 'nope' — run 'goga topics status' to see the board") + assert raised.value.message == ("no branch hosts 'nope' — run 'goga topics board' to see the board") checkout.assert_not_called() def test_switch_topic_non_interactive_multiple_candidates_fails_with_list( From 8f4a86d929579758f07eb034a4a487946717a0e8 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Tue, 1 Sep 2026 21:52:18 +0000 Subject: [PATCH 173/229] feat: add sync workflow with russian answer prompt --- .goga/workflows/sync.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .goga/workflows/sync.yml diff --git a/.goga/workflows/sync.yml b/.goga/workflows/sync.yml new file mode 100644 index 00000000..9fc61e96 --- /dev/null +++ b/.goga/workflows/sync.yml @@ -0,0 +1,2 @@ +prompt: | + Answer (feedbacks, proposes, questions and etc) in Russian language. From 07051dd37b83368e63e6d3eaee535e2521712993 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 01:37:21 +0300 Subject: [PATCH 174/229] feat: support todo file in refinement pipeline --- goga/assets/pipelines/refinement.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/goga/assets/pipelines/refinement.yml b/goga/assets/pipelines/refinement.yml index 2a3848d2..7b9af202 100644 --- a/goga/assets/pipelines/refinement.yml +++ b/goga/assets/pipelines/refinement.yml @@ -6,6 +6,10 @@ description: "Task refinement process" title: "Product definition & create PRD" communication: true prompt: | + Use the TODO file at the path printed by `goga history path -f todo.md`, if it exists. + + If the TODO file does not exist — ask user about task. + Constrains: - Don't research a project until you receive the task skills: @@ -16,7 +20,9 @@ description: "Task refinement process" communication: true prompt: | Use the PRD file at the path printed by `goga history path -f prd.md`, if it exists. - If PRD file does not exist — ask user about task. + + If the PRD file does not exist try TODO file at the path printed by `goga history path -f todo.md`. + If the PRD and TODO files do not exist — ask user about task. Constrains: - Don't research a project until you receive the task @@ -31,8 +37,10 @@ description: "Task refinement process" communication: true prompt: | Use the ADR at the path printed by `goga history path -f adr.md` as the input for task formulation, if it exists. - If ADR does not exist — try the PRD file at the path printed by `goga history path -f prd.md`. - If PRD file does not exists — ask user about task. + + If the ADR does not exist — try the PRD file at the path printed by `goga history path -f prd.md`. + If the ADR and PRD files do not exist try TODO file at the path printed by `goga history path -f todo.md`. + If the PRD, ADR and TODO files do not exists — ask user about task. skills: - goga-propose From da679aee325b37a3e8e22f905a920c23f5503471 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 11:36:06 +0000 Subject: [PATCH 175/229] fix: emit explicit memory block values in compiled flows The global memory block now carries concrete values instead of None/True defaults: reflect emits mode: r and memory_use: false (read-only project memory, no global participation), alignment emits the authored mode (rw by default) and memory_use: false (participation is per-stage opt-in). CODEMANIFEST, usages, docs and tests updated in sync. --- .goga/usages/cooks/afm.md | 6 ++- docs/pipelines/workflows.md | 14 ++++--- .../compiler/.usages/memory-emission.md | 11 ++++- goga/pipeline/compiler/CODEMANIFEST | 27 +++++++----- goga/pipeline/compiler/compile_flow.py | 26 +++++++----- goga/pipeline/compiler/flow_memory.py | 28 ++++++++----- .../compiler/test_compile_flow_memory.py | 36 +++++++++------- .../test_compile_flow_memory_integration.py | 42 ++++++++++++++++++- .../compiler/test_flow_memory_logic.py | 35 +++++++++------- 9 files changed, 152 insertions(+), 73 deletions(-) diff --git a/.goga/usages/cooks/afm.md b/.goga/usages/cooks/afm.md index 1ce528ac..10a61958 100644 --- a/.goga/usages/cooks/afm.md +++ b/.goga/usages/cooks/afm.md @@ -153,8 +153,10 @@ order `path`, `mode`, `memory_use`, `max_rules`, `commit`: goga authors memory in the workflow-file (the `memory:` block plus the `reflect`/`memory` stage instructions) and compiles it into the flow-file; the runtime interpretation belongs to afm. The global block is emitted if and only if at least one stage participates in -memory; defaults are materialized (`max_rules: 25`, `commit: false`, and `mode: rw` for -the alignment method); `path` is the fixed prefix `.goga/memory` plus an optional authored +memory; defaults are materialized (`max_rules: 25`, `commit: false`, `mode: r` for the +reflect method, the authored `mode` value (`rw` by default) for the alignment method, and +the global `memory_use: false` under both methods — participation is per-stage opt-in); +`path` is the fixed prefix `.goga/memory` plus an optional authored suffix; the goga-side `method` key (reflect | alignment) is never written to the flow-file. diff --git a/docs/pipelines/workflows.md b/docs/pipelines/workflows.md index 30615b77..09b71f91 100644 --- a/docs/pipelines/workflows.md +++ b/docs/pipelines/workflows.md @@ -418,8 +418,11 @@ Behavior rules: - In the compiled flow-file the block lands between `description` and `stages` with the key order `path, mode, memory_use, max_rules, commit`; the emitted `path` is always the fixed root `.goga/memory` joined with the - authored suffix. Under the reflect method `mode` and `memory_use` are - omitted entirely; under alignment every stage carries an explicit + authored suffix. The block always carries the global opt-out + `memory_use: false` (participation is per-stage, never the global default); + its `mode` is the fixed `r` under the reflect method (read-only project + memory) and the materialized authored value (`rw` by default) under + alignment. Under alignment every stage carries an explicit `memory_use` key (`true` on participants, `false` on everyone else — afm inherits the global default for an unset key, so the compiler never leaves one unset). @@ -789,9 +792,10 @@ authored `memory` block when present, else the materialized defaults (no block, no stage keys). The block lands between `description` and `stages`, sources `path`/`max_rules`/`commit` from the effective configuration (the emitted `path` is the fixed root `.goga/memory` - joined with the authored suffix), and carries `mode`/`memory_use` only - under the alignment method (`mode` the materialized value, - `memory_use: true`). + joined with the authored suffix), and always carries the global opt-out + `memory_use: false` plus a `mode` — the fixed `r` under the reflect method + (read-only project memory), the materialized authored value (`rw` by + default) under the alignment method. - Per-stage keys land in the canonical slots right after `script_timeout`: under reflect every participating stage carries `reflect: {file, mode}` (the authored file verbatim, the materialized diff --git a/goga/pipeline/compiler/.usages/memory-emission.md b/goga/pipeline/compiler/.usages/memory-emission.md index daa78a36..ff9b64a6 100644 --- a/goga/pipeline/compiler/.usages/memory-emission.md +++ b/goga/pipeline/compiler/.usages/memory-emission.md @@ -31,11 +31,18 @@ memory_use, max_rules, commit`: | Ключ | reflect | alignment | |------|---------|-----------| | `path` | склеенный корень памяти | склеенный корень памяти | -| `mode` | — (отсутствует) | материализованное значение | -| `memory_use` | — (отсутствует) | `true` | +| `mode` | `r` (фиксированное) | материализованное авторское значение (`rw` по умолчанию) | +| `memory_use` | `false` | `false` | | `max_rules` | из конфигурации | из конфигурации | | `commit` | из конфигурации | из конфигурации | +Глобальный `memory_use: false` — умолчание-отказ: участие в памяти строго +per-stage (afm вычисляет `UseFor(stage) = stage.memory_use ?? memory.memory_use`, +поэтому глобальный отказ не включает память у стадий без явного ключа). +При reflect стадия участвует через ключ `reflect` (`mode: r` даёт доступ +только на чтение проектной памяти); при alignment — через стадийный +`memory_use: true`. + `path` = `.goga/memory` (без суффикса) или `.goga/memory/<суффикс>`. Когда блок `memory:` в workflow не авторирован (случай 2 — есть только diff --git a/goga/pipeline/compiler/CODEMANIFEST b/goga/pipeline/compiler/CODEMANIFEST index e220f8ac..e0bdd6cc 100644 --- a/goga/pipeline/compiler/CODEMANIFEST +++ b/goga/pipeline/compiler/CODEMANIFEST @@ -702,10 +702,12 @@ Annotations: | `path`: the composed memory root — the fixed root joined with the authored suffix (the bare root when no suffix) - `mode`: the project-memory access mode — present only for the alignment - method; None for the reflect method - `memory_use`: the global participation default — True only for the - alignment method; None for the reflect method + `mode`: the project-memory access mode — the fixed "r" for the reflect + method (read-only project memory); the materialized authored + value ("rw" by default) for the alignment method + `memory_use`: the global participation default — always False in the + emitted block (participation is per-stage opt-in; afm's + UseFor(stage) inherits this default for an unset stage key) `max_rules`: the maximum number of memory rules (always >= 1) `commit`: whether memory changes are committed @@ -716,8 +718,8 @@ Annotations: | - Use @dataclass(kw_only=True) (per `convention`) - Field order is fixed: path, mode, memory_use, max_rules, commit — the emission order of the block keys - - reflect method: mode None, memory_use None; alignment method: mode the - materialized value, memory_use True + - reflect method: mode "r", memory_use False; alignment method: mode the + materialized authored value ("rw" by default), memory_use False - A None field is omitted from the output entirely Constraints: @@ -731,9 +733,11 @@ Annotations: | The composed memory root — the fixed root joined with the authored suffix. "mode -> str | None": | - The project-memory access mode; present only for the alignment method. + The project-memory access mode — "r" for the reflect method, the + materialized authored value for the alignment method. "memory_use -> bool | None": | - The global participation default; True only for the alignment method. + The global participation default; False in every emitted block + (participation is per-stage opt-in). "max_rules -> int": | The maximum number of memory rules; always >= 1. "commit -> bool": | @@ -1444,9 +1448,10 @@ Annotations: | it carries one, else a default-constructed `WorkflowMemory` — its field defaults ARE the materialized authoring defaults): path = the fixed root ".goga/memory" joined with the authored suffix (the bare - root when the suffix is None); reflect method — mode None, - memory_use None; alignment method — mode the materialized value, - memory_use True; max_rules and commit carried from the effective + root when the suffix is None); reflect method — mode "r", + memory_use False; alignment method — mode the materialized authored + value ("rw" by default), memory_use False; max_rules and commit + carried from the effective configuration (25 / False when no block was authored); place the block between description and stages of the `FlowDocument`. When participation does not exist — memory is None (no block, no stage diff --git a/goga/pipeline/compiler/compile_flow.py b/goga/pipeline/compiler/compile_flow.py index ec57bca4..2e947467 100644 --- a/goga/pipeline/compiler/compile_flow.py +++ b/goga/pipeline/compiler/compile_flow.py @@ -91,9 +91,11 @@ stage participates — a memory configuration alone is a silent no-op (no block, no stage keys, not even an opting-out stage key). The emitted block composes the fixed memory root ``.goga/memory`` with the authored suffix -(the bare root when the suffix is ``None``); the reflect method emits ``mode`` -and ``memory_use`` as ``None`` (omitted from the output), the alignment -method emits the materialized ``mode`` and ``memory_use: true``; ``max_rules`` +(the bare root when the suffix is ``None``); the reflect method emits the +fixed ``mode: r`` and ``memory_use: false`` (read-only project memory, no +global participation), the alignment method emits the materialized ``mode`` +(the authored value, ``rw`` by default) and ``memory_use: false``; +``max_rules`` and ``commit`` carry from the effective configuration. The stage keys occupy the canonical slots immediately after ``script_timeout``: under reflect a participating stage carries ``reflect: {file, mode}`` (file verbatim, mode @@ -986,9 +988,12 @@ def _memory_emission( configuration alone never turns the block on). The block composes ``_MEMORY_ROOT`` with the authored suffix (the bare - root when the suffix is ``None``); the reflect method emits ``mode`` and - ``memory_use`` as ``None``, the alignment method the materialized ``mode`` - and ``memory_use: True``; ``max_rules``/``commit`` carry from the effective + root when the suffix is ``None``); the reflect method emits the fixed + ``mode: "r"`` and ``memory_use: False`` (read-only project memory, no + global participation), the alignment method the materialized ``mode`` + (the authored value, ``"rw"`` by default) and ``memory_use: False`` + (participation is per-stage opt-in, never the global default); + ``max_rules``/``commit`` carry from the effective configuration. The keys: under reflect every PARTICIPATING final id carries ``{"reflect": {"file": ..., "mode": ...}}`` (the authored file verbatim, the materialized mode); under alignment EVERY final id carries @@ -1038,8 +1043,8 @@ def _memory_emission( path = _MEMORY_ROOT if config.path is None else f"{_MEMORY_ROOT}/{config.path}" block = FlowMemory( path=path, - mode=(config.mode if method == "alignment" else None), - memory_use=(True if method == "alignment" else None), + mode=("r" if method == "reflect" else config.mode), + memory_use=False, max_rules=config.max_rules, commit=config.commit, ) @@ -1898,8 +1903,9 @@ def compile_flow( included, skipped stages never counted). When at least one stage participates, the top-level ``memory`` block is built (``path`` = the fixed root ``.goga/memory`` joined with the authored suffix; reflect — - ``mode``/``memory_use`` omitted, alignment — the materialized ``mode`` and - ``memory_use: true``; ``max_rules``/``commit`` from the configuration) and + ``mode: r`` and ``memory_use: false``, alignment — the materialized + ``mode`` and + ``memory_use: false``; ``max_rules``/``commit`` from the configuration) and placed between ``description`` and ``stages``, and the stage memory keys are assembled into their canonical slots after ``script_timeout`` — ``reflect: {file, mode}`` on participating stages under reflect, diff --git a/goga/pipeline/compiler/flow_memory.py b/goga/pipeline/compiler/flow_memory.py index 867b67d6..d96dd121 100644 --- a/goga/pipeline/compiler/flow_memory.py +++ b/goga/pipeline/compiler/flow_memory.py @@ -10,17 +10,22 @@ ``memory_use``, ``max_rules``, ``commit``. ``path`` is the composed memory root: the fixed root joined with the authored suffix (the bare root when no suffix was authored) — the caller composes it, this model never does. -``mode`` is the project-memory access mode; it is present only for the -alignment method (``None`` for the reflect method). ``memory_use`` is the -global participation default; ``True`` only for the alignment method -(``None`` for the reflect method). ``max_rules`` is the maximum number of +``mode`` is the project-memory access mode — the fixed ``"r"`` for the +reflect method (read-only project memory), the materialized authored value +for the alignment method (``"rw"`` by default). ``memory_use`` is the global +participation default — always ``False`` in the emitted block (participation +is per-stage opt-in: afm's ``UseFor(stage)`` inherits this global default +for an unset stage key, so the compiler never leaves it unset at ``True``). +``max_rules`` is the maximum number of memory rules (always ``>= 1``); ``commit`` is whether memory changes are committed. A ``None`` field is omitted from the output entirely — the serializer drops it, it never emits an empty value. Only ``mode`` / ``memory_use`` default (to ``None``); ``path`` / ``max_rules`` / ``commit`` carry NO defaults — a block is always complete, and -``compile_flow`` is its single construction site. +``compile_flow`` is its single construction site (it always passes concrete +``mode`` / ``memory_use`` values; the ``None`` defaults exist for the model's +declarative completeness, not for the compiler's emission). """ from __future__ import annotations @@ -34,18 +39,19 @@ class FlowMemory: Field order is fixed (``path``, ``mode``, ``memory_use``, ``max_rules``, ``commit``) — the emission order of the block keys. reflect method — - ``mode`` ``None``, ``memory_use`` ``None``; alignment method — ``mode`` - the materialized value, ``memory_use`` ``True``. A ``None`` field is + ``mode`` the fixed ``"r"``, ``memory_use`` ``False``; alignment method — + ``mode`` the materialized authored value (``"rw"`` by default), + ``memory_use`` ``False``. A ``None`` field is omitted from the output entirely. Args: path: The composed memory root — the fixed root joined with the authored suffix. Composed by ``compile_flow``; carried verbatim here. - mode: The project-memory access mode; present only for the alignment - method. - memory_use: The global participation default; ``True`` only for the - alignment method. + mode: The project-memory access mode — ``"r"`` for the reflect method, + the materialized authored value for the alignment method. + memory_use: The global participation default; ``False`` in every + emitted block (participation is per-stage opt-in). max_rules: The maximum number of memory rules; always ``>= 1``. commit: Whether memory changes are committed. """ diff --git a/tests/pipeline/compiler/test_compile_flow_memory.py b/tests/pipeline/compiler/test_compile_flow_memory.py index 620123bc..922e1b5e 100644 --- a/tests/pipeline/compiler/test_compile_flow_memory.py +++ b/tests/pipeline/compiler/test_compile_flow_memory.py @@ -13,9 +13,9 @@ never count); - the emitted path composes the fixed memory root ``.goga/memory`` with the authored suffix (the bare root when the suffix is ``None``); -- reflect method — ``mode`` and ``memory_use`` stay ``None`` (omitted from the - output); alignment method — ``mode`` the materialized value and - ``memory_use: true``; +- reflect method — ``mode: r`` and ``memory_use: false`` (read-only project + memory, no global participation); alignment method — ``mode`` the + materialized authored value (``rw`` by default) and ``memory_use: false``; - reflect method — a participating stage carries ``reflect: {file, mode}`` (file verbatim, mode materialized); alignment method — EVERY stage carries ``memory_use`` (explicit ``false`` on every non-participating one, because @@ -182,10 +182,13 @@ def test_compile_flow_no_block_with_reflect_instructions_emits_block(self, tmp_p "stages:\n brainstorm:\n reflect:\n file: shared.md\n", ) - assert flow_doc.memory == FlowMemory(path=".goga/memory", max_rules=25, commit=False) + assert flow_doc.memory == FlowMemory( + path=".goga/memory", mode="r", memory_use=False, max_rules=25, commit=False + ) assert "memory:" in text assert "path: .goga/memory" in text - assert "mode:" not in text.split("stages:")[0] + assert "mode: r" in text.split("stages:")[0] + assert "memory_use: false" in text.split("stages:")[0] assert "max_rules: 25" in text assert "commit: false" in text assert " reflect:" in text @@ -234,7 +237,7 @@ def test_compile_flow_alignment_emits_block_and_marks_every_stage(self, tmp_path assert flow_doc.memory == FlowMemory( path=".goga/memory/goga-development", mode="rw", - memory_use=True, + memory_use=False, max_rules=25, commit=False, ) @@ -259,6 +262,7 @@ def test_compile_flow_alignment_authored_mode_carries_verbatim(self, tmp_path: P assert flow_doc.memory is not None assert flow_doc.memory.mode == "r" + assert flow_doc.memory.memory_use is False assert "mode: r" in text def test_compile_flow_reflect_slot_after_script_timeout(self, tmp_path: Path) -> None: @@ -324,7 +328,9 @@ def test_compile_flow_phases_reflect_emits_block_and_stage_keys(self, tmp_path: workflow_text = "stages:\n brainstorm:\n reflect:\n file: shared.md\n" _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_PHASES, workflow_text) - assert flow_doc.memory == FlowMemory(path=".goga/memory", max_rules=25, commit=False) + assert flow_doc.memory == FlowMemory( + path=".goga/memory", mode="r", memory_use=False, max_rules=25, commit=False + ) assert flow_doc.stages[0].fields["reflect"] == {"file": "shared.md", "mode": "rw"} assert flow_doc.stages[0].depends_on is None assert flow_doc.stages[1].fields.get("reflect") is None @@ -422,8 +428,8 @@ def test_compile_flow_alignment_skip_of_only_participating_stage_emits_no_block( assert "memory:" not in text assert all("memory_use" not in stage.fields for stage in flow_doc.stages) - def test_compile_flow_reflect_block_omits_mode_and_memory_use(self, tmp_path: Path) -> None: - """Emission case 6 — the reflect-method block carries exactly path, max_rules, commit.""" + def test_compile_flow_reflect_block_carries_mode_r_and_memory_use_false(self, tmp_path: Path) -> None: + """Emission case 6 — the reflect-method block carries mode: r and memory_use: false.""" workflow_text = ( "memory:\n" " max_rules: 9\n" @@ -436,13 +442,13 @@ def test_compile_flow_reflect_block_omits_mode_and_memory_use(self, tmp_path: Pa _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) assert flow_doc.memory is not None - assert flow_doc.memory.mode is None - assert flow_doc.memory.memory_use is None + assert flow_doc.memory.mode == "r" + assert flow_doc.memory.memory_use is False block_text = text.split("stages:")[0].split("memory:")[1] - assert "mode:" not in block_text - assert "memory_use:" not in block_text + assert "mode: r" in block_text + assert "memory_use: false" in block_text assert "commit: true" in text @@ -536,7 +542,9 @@ def test_memory_emission_default_config_supplies_block_values(self) -> None: emission = _memory_emission(workflow, effective, {"build": ["build"]}) - assert emission.block == FlowMemory(path=".goga/memory", max_rules=25, commit=False) + assert emission.block == FlowMemory( + path=".goga/memory", mode="r", memory_use=False, max_rules=25, commit=False + ) assert emission.keys_by_id == {"build": {"reflect": {"file": "a.md", "mode": "rw"}}} def test_memory_emission_alignment_marks_every_final_id(self) -> None: diff --git a/tests/pipeline/compiler/test_compile_flow_memory_integration.py b/tests/pipeline/compiler/test_compile_flow_memory_integration.py index 9217d7e4..3382c968 100644 --- a/tests/pipeline/compiler/test_compile_flow_memory_integration.py +++ b/tests/pipeline/compiler/test_compile_flow_memory_integration.py @@ -78,7 +78,8 @@ ) # A reflect-method workflow that participates (emission case 6 — a block with -# a reflect instruction): the block carries path / max_rules / commit only. +# a reflect instruction): the block carries the fixed mode: r and the global +# memory_use: false alongside path / max_rules / commit. _REFLECT_WORKFLOW = ( "memory:\n" " max_rules: 40\n" @@ -89,7 +90,8 @@ ) # An alignment-method workflow that participates (emission case 4): the block -# carries the composed path, the materialized mode, and memory_use: true. +# carries the composed path, the materialized mode (rw by default), and the +# global memory_use: false. _ALIGNMENT_WORKFLOW = ( "memory:\n" " method: alignment\n" @@ -227,6 +229,42 @@ def test_compile_flow_skip_via_cli_channel_uses_same_path( assert "review" not in {stage.id for stage in flow_doc.stages} +class TestComposedBlockValues: + """The parser→compiler handoff emits the required block values per method.""" + + @pytest.mark.parametrize( + ("workflow_text", "mode"), + [ + pytest.param(_REFLECT_WORKFLOW, "r", id="reflect-fixed-r"), + pytest.param(_ALIGNMENT_WORKFLOW, "rw", id="alignment-materialized-rw"), + ], + ) + def test_compile_flow_block_mode_and_global_opt_out( + self, + tmp_path: Path, + workflow_text: str, + mode: str, + ) -> None: + """Both methods emit their ``mode`` and the global ``memory_use: false``. + + The real ``parse_workflow`` materializes the configuration and hands it + to ``compile_flow`` — the composed surface (not a hand-built document) + must carry the fixed ``mode: r`` under reflect, the materialized + ``mode: rw`` under alignment, and the global participation opt-out + ``memory_use: false`` under both. + """ + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory is not None + assert flow_doc.memory.mode == mode + assert flow_doc.memory.memory_use is False + + block_text = text.split("stages:")[0].split("memory:")[1] + + assert f"mode: {mode}" in block_text + assert "memory_use: false" in block_text + + class TestPipelineDocumentMirror: """Output-side only — the ``PipelineDocument`` mirror ignores memory.""" diff --git a/tests/pipeline/compiler/test_flow_memory_logic.py b/tests/pipeline/compiler/test_flow_memory_logic.py index 8bc41360..bd2eee3d 100644 --- a/tests/pipeline/compiler/test_flow_memory_logic.py +++ b/tests/pipeline/compiler/test_flow_memory_logic.py @@ -2,10 +2,10 @@ Covers construction behavior beyond the contract surface: the emission-order pin of the field list and the two method shapes — the reflect-method block -(``mode`` / ``memory_use`` both ``None``) versus the alignment-method block -(``mode`` the materialized value, ``memory_use`` ``True``). The ``None`` -fields are the omission signal the serializer drops — a block must never -conflate an unset field with an authored value. +(``mode: r`` / ``memory_use: False``) versus the alignment-method block +(``mode`` the materialized authored value, ``memory_use`` ``False``). The +``None`` fields are the omission signal the serializer drops — a block must +never conflate an unset field with an authored value. """ from __future__ import annotations @@ -30,18 +30,21 @@ def test_flow_memory_field_order_is_emission_order(self) -> None: assert names == ["path", "mode", "memory_use", "max_rules", "commit"] def test_flow_memory_none_fields_distinct_from_values(self) -> None: - """The reflect-method shape leaves ``mode``/``memory_use`` None; alignment carries values. - - A reflect-method block (no ``mode``, no ``memory_use``) is the shape - the compiler builds from a bare reflect configuration — both optional - fields fall to their ``None`` defaults and are omitted from the - output. The alignment-method block carries the materialized mode and - the global participation default ``True``. + """The reflect shape carries ``mode: r``/``memory_use: False``; alignment the authored mode. + + A reflect-method block is the shape the compiler builds from a bare + reflect configuration — the fixed ``mode: r`` and the global opt-out + ``memory_use: False`` (read-only project memory, no global + participation). The alignment-method block carries the materialized + authored mode (``rw`` by default) and the same global + ``memory_use: False`` — participation is per-stage opt-in. The model's + ``None`` defaults remain the omission signal for the serializer — a + block must never conflate an unset field with an authored value. """ - reflect_block = FlowMemory(path=".goga/memory", max_rules=25, commit=False) + reflect_block = FlowMemory(path=".goga/memory", mode="r", memory_use=False, max_rules=25, commit=False) - assert reflect_block.mode is None - assert reflect_block.memory_use is None + assert reflect_block.mode == "r" + assert reflect_block.memory_use is False assert reflect_block.path == ".goga/memory" assert reflect_block.max_rules == 25 assert reflect_block.commit is False @@ -49,13 +52,13 @@ def test_flow_memory_none_fields_distinct_from_values(self) -> None: alignment_block = FlowMemory( path=".goga/memory/goga-development", mode="rw", - memory_use=True, + memory_use=False, max_rules=25, commit=False, ) assert alignment_block.mode == "rw" - assert alignment_block.memory_use is True + assert alignment_block.memory_use is False assert alignment_block.path == ".goga/memory/goga-development" def test_flow_memory_equality_of_identical_constructions(self) -> None: From f8495b752ea042cb36676745d0478878f6bdacbc Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 11:54:42 +0000 Subject: [PATCH 176/229] docs: keep compiled memory form out of user documentation The workflows page pinned the compiler's emission behavior into the flow-file for afm (block key order, landing position, the fixed mode: r / memory_use: false values, the explicit per-stage opt-out). User documentation now covers only the authoring semantics and the user-observable facts (the .goga/memory root, the afm 0.5.60+ requirement); the compiled-output contract lives exclusively in the compiler cell (.usages/memory-emission.md) and its CODEMANIFEST, which the page now points to. Pass 4.9 renamed to Memory participation accordingly. --- docs/pipelines/workflows.md | 65 +++++++++++++++---------------------- 1 file changed, 27 insertions(+), 38 deletions(-) diff --git a/docs/pipelines/workflows.md b/docs/pipelines/workflows.md index 09b71f91..5d2061ec 100644 --- a/docs/pipelines/workflows.md +++ b/docs/pipelines/workflows.md @@ -393,7 +393,7 @@ The top-level block accepts five keys: | Key | Type | Default | Description | |-------------|--------|------------|--------------------------------------------------------------------------------| | `method` | string | `reflect` | The instruction vocabulary: `reflect` pairs with the per-stage `reflect` instruction, `alignment` with the per-stage `memory` instruction. Never part of any output. | -| `path` | string | — | Suffix inside the fixed memory root `.goga/memory` (the emitted `path` is the root joined with it). Must be a relative, non-escaping path shape. | +| `path` | string | — | Suffix inside the fixed memory root `.goga/memory`. Must be a relative, non-escaping path shape. | | `max_rules` | int | `25` | The maximum number of memory rules (`>= 1`). | | `commit` | bool | `false` | Whether memory changes are committed. | | `mode` | string | `rw` under `alignment` | The project-memory access mode (`r`/`w`/`rw`). Authored ONLY under `method: alignment` — an authored `mode` together with `method: reflect` is a structural error. | @@ -415,25 +415,21 @@ Behavior rules: instructions die with it: skipping the only participating stage disables the block entirely. Every `loop`-expanded copy carries the same memory keys as its original. -- In the compiled flow-file the block lands between `description` and - `stages` with the key order `path, mode, memory_use, max_rules, commit`; - the emitted `path` is always the fixed root `.goga/memory` joined with the - authored suffix. The block always carries the global opt-out - `memory_use: false` (participation is per-stage, never the global default); - its `mode` is the fixed `r` under the reflect method (read-only project - memory) and the materialized authored value (`rw` by default) under - alignment. Under alignment every stage carries an explicit - `memory_use` key (`true` on participants, `false` on everyone else — afm - inherits the global default for an unset key, so the compiler never - leaves one unset). +- Project memory lives under the fixed root `.goga/memory` (plus the + authored `path` suffix when one is set). How the memory settings and the + per-stage participation are encoded into the compiled flow-file is an + internal contract of the compiler — see + `goga/pipeline/compiler/.usages/memory-emission.md`; this documentation + intentionally does not pin the compiled form. - Both instructions are allowed ONLY in the `stages` block — under `extend` they are structural errors. A new stage participates through a - `stages`-block entry authored under its name. The compiled keys (`reflect` - / `memory_use`) are likewise forbidden in any stage body — authoring - either in a pipeline-file stage or an extend body is a structural error. -- The emitted keys are interpreted by afm (the shipped image carries - afm 0.5.60+, which the memory mechanism requires) — the compiler only - assembles and serializes them. + `stages`-block entry authored under its name. Authoring a `reflect` or + `memory_use` key in any stage body (a pipeline-file stage or an extend + body) is likewise a structural error — the memory stage keys come from + the workflow instructions alone. +- The memory mechanism is interpreted by the afm runtime (the shipped + image carries afm 0.5.60+, which the memory mechanism requires) — goga + only authors and compiles the instructions. ## Extending the pipeline with new stages @@ -774,7 +770,7 @@ the stage's own agent-mode resolution are independent — the override selects which agent binary runs the stage, while the `roles` field selects how the work is organized inside it. -### Pass 4.9 — Memory emission +### Pass 4.9 — Memory participation After the working body is final (skip removal, loop expansion, and the external `depends_on` rewrite have all run), the compiler computes memory @@ -787,25 +783,18 @@ authored `memory` block when present, else the materialized defaults `memory: true`. Participation is looked up per base name in the working body, so every `loop`-expanded copy inherits its base's verdict and a skipped stage never counts. -- The top-level `memory` block is emitted **iff at least one stage - participates** — a configuration without participants is a silent no-op - (no block, no stage keys). The block lands between `description` and - `stages`, sources `path`/`max_rules`/`commit` from the effective - configuration (the emitted `path` is the fixed root `.goga/memory` - joined with the authored suffix), and always carries the global opt-out - `memory_use: false` plus a `mode` — the fixed `r` under the reflect method - (read-only project memory), the materialized authored value (`rw` by - default) under the alignment method. -- Per-stage keys land in the canonical slots right after `script_timeout`: - under reflect every participating stage carries - `reflect: {file, mode}` (the authored file verbatim, the materialized - mode); under alignment EVERY stage carries `memory_use` — `true` on - participants, an explicit `false` on everyone else. -- The compiled keys are output-side only — `PipelineDocument` keeps - mirroring the source pipeline-file, and an authoring `reflect` or - `memory_use` key in any stage body is a structural error. A workflow - without memory participation compiles byte-identically to the same - workflow compiled before the mechanism existed. +- The memory settings apply **iff at least one stage participates** — a + configuration without participants is a silent no-op: the compiled + output carries no memory keys at all. +- How the configuration and the per-stage participation are encoded into + the flow-file is an internal contract of the compiler (see + `goga/pipeline/compiler/.usages/memory-emission.md`) — this documentation + intentionally does not pin the compiled form. +- Memory application is output-side only — the source pipeline-file is + never touched, and an authoring `reflect` or `memory_use` key in any + stage body is a structural error. A workflow without memory + participation compiles byte-identically to the same workflow compiled + before the mechanism existed. ## Invocation modes From db9f7cc121f5886834fccc1c3e89821b6ccd895c Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 17:28:48 +0300 Subject: [PATCH 177/229] feat: up afm version --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 57f4fac5..9fc8d860 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG AFM_VERSION=0.5.60 +ARG AFM_VERSION=0.5.63 ARG RALPHEX_VERSION=1.6 ARG PYTHON_VERSION=3.12 ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 From fffa4f65e1a18043ac4cc3303a2c86781ec47a69 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 18:05:52 +0300 Subject: [PATCH 178/229] feat: add max_rules to development memory --- .goga/workflows/development.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.goga/workflows/development.yml b/.goga/workflows/development.yml index 992e17f2..723f2fe9 100644 --- a/.goga/workflows/development.yml +++ b/.goga/workflows/development.yml @@ -2,6 +2,7 @@ prompt: | Answer (feedbacks, proposes, questions and etc) in Russian language. memory: + max_rules: 15 commit: true stages: From 05a202ca412c9a3210ddbc0e2b750f71620cdb03 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 19:50:13 +0000 Subject: [PATCH 179/229] feat: apply topics usability architecture to cells Materialize the reviewed topics-usability-update design into the cells. The new editor cell owns the external-editor entry protocol (edit_text in entry.py) with its cooks usage; the topics facade gains the deletion contracts (resolve_delete_targets / delete_topics, DeleteTarget in deletion.py) and the todo-entry usage for create_topic; the git cell gains delete_remote_branch (publish.py). The topics command surface is reworked to board/create/switch/delete, the pipeline command grows the todo flag, and the affected CODEMANIFESTs and .usages files are updated to the new contracts. --- .goga/usages/cooks/click.md | 81 +-- .goga/usages/cooks/editor.md | 95 ++++ goga/commands/CODEMANIFEST | 6 +- .../pipeline/.usages/pipeline-command.md | 13 + goga/commands/pipeline/CODEMANIFEST | 76 ++- .../commands/topics/.usages/topics-command.md | 112 ++-- goga/commands/topics/CODEMANIFEST | 247 +++++---- goga/topics/.usages/creating.md | 76 ++- goga/topics/.usages/deleting.md | 46 ++ goga/topics/.usages/ensuring.md | 40 +- goga/topics/.usages/publishing.md | 6 +- goga/topics/.usages/switching.md | 22 +- goga/topics/.usages/todo-entry.md | 21 + goga/topics/CODEMANIFEST | 521 ++++++++++++------ goga/topics/editor/.usages/editor-entry.md | 36 ++ goga/topics/editor/CODEMANIFEST | 79 +++ goga/topics/git/.usages/deleting.md | 43 ++ goga/topics/git/.usages/publishing.md | 3 +- goga/topics/git/CODEMANIFEST | 41 +- 19 files changed, 1076 insertions(+), 488 deletions(-) create mode 100644 .goga/usages/cooks/editor.md create mode 100644 goga/topics/.usages/deleting.md create mode 100644 goga/topics/.usages/todo-entry.md create mode 100644 goga/topics/editor/.usages/editor-entry.md create mode 100644 goga/topics/editor/CODEMANIFEST create mode 100644 goga/topics/git/.usages/deleting.md diff --git a/.goga/usages/cooks/click.md b/.goga/usages/cooks/click.md index 9d6220ef..96c5eb66 100644 --- a/.goga/usages/cooks/click.md +++ b/.goga/usages/cooks/click.md @@ -202,75 +202,18 @@ def test_hello(): ## Interactive Multi-Line Entry -A multi-line text value (paragraphs included) is collected with a prompt -cycle — one input per line; a lone `.` line or EOF finishes: - -```python -import sys - -import click -from click import termui - - -def prompt_multiline(label: str) -> str | None: - if not sys.stdin.isatty(): - raise click.ClickException(f"{label} entry needs an interactive terminal") - click.echo(f"Enter the {label}. Finish with a lone '.' line or Ctrl+D.") - lines: list[str] = [] - while True: - try: - line = termui.visible_prompt_func("") - except EOFError: - break - except KeyboardInterrupt: - raise click.Abort() from None - if line == ".": - break - lines.append(line) - text = "\n".join(lines) - return text if text else None -``` - -- Every entered line continues the text; an empty line is an allowed text - line — paragraphs survive. -- The two terminators are a line consisting of a single `.` and EOF - (Ctrl+D); the rule is stated in the prompt itself. -- No line entered cancels the entry — return None and continue as without - the value; an empty text is never produced. A single blank line joins to - the empty text, so it cancels the entry the same way — the emptiness - check runs on the joined text, not on the line list. -- Detect the non-interactive terminal before the first prompt — a missing - TTY is a clean error without a traceback. -- KeyboardInterrupt aborts the command — it is not a terminator. -- Resolve `visible_prompt_func` through the module attribute at call time - (`termui.visible_prompt_func`) — `CliRunner` patches - `click.termui.visible_prompt_func` per invoke, and a `from`-imported - binding never sees the patched function. -- In tests drive the cycle by a direct call: monkeypatch `sys.stdin` with - `mock.Mock(**{"isatty.return_value": True})` and patch - `click.termui.visible_prompt_func` with a `side_effect` list of lines — - `EOFError` in the list models Ctrl+D, `"."` models the terminator. Under - `CliRunner` the cycle always refuses — its `sys.stdin` is not a TTY — so - `CliRunner` covers the non-interactive error and the flag matrix only. - -### Option with an optional value - -The flag that starts the entry takes an optional value — a bare flag -passes the entry marker, a given value passes the text: - -```python -@click.option("--todo", "-t", "todo", default=None, is_flag=False, flag_value="", - metavar="[TEXT]", - help="Todo of the fresh work; without a value — interactive entry") -``` - -- `is_flag=False` together with `flag_value` is the optional-value form: - without the explicit `is_flag=False` the option turns into a pure flag - that never takes a value. -- No flag -> None; a bare `--todo`/`-t` -> "" (start the entry); - `--todo "text"` -> the text. -- An empty string parameter value is the entry marker, never a written - value — an empty file is never created. +A multi-line text (a topic todo) is collected or edited in an **external +editor**, not through a click prompt cycle — the `editor` practice +(`.goga/usages/cooks/editor.md`) owns the entry protocol: the +`$VISUAL` → `$EDITOR` → `vi` editor chain, the temporary file, the +cancellation rule (an empty or unchanged file continues as without the +entry), the non-TTY clean error, and the `$EDITOR` test mock. The former +prompt cycle with its lone `.` line and Ctrl+D terminators is abolished. + +Within this practice, click still owns the surrounding CLI moments: the +option surface that triggers or bypasses the entry (a plain value option +that passes the text directly, a boolean flag that requests the entry), +the confirmations, and the clean error rendering. ## Anti-patterns diff --git a/.goga/usages/cooks/editor.md b/.goga/usages/cooks/editor.md new file mode 100644 index 00000000..036dc198 --- /dev/null +++ b/.goga/usages/cooks/editor.md @@ -0,0 +1,95 @@ +# Multi-Line Text Entry with an External Editor + +## Library + +**External editor** — the user's editor resolved git-style: `$VISUAL` → `$EDITOR` → the +system default `vi`. No new Python dependency; the editor is an external process. + +**IMPORTANT** — no editor fields exist in the project or home configuration; the +environment chain plus the default is the whole resolution. + +## Purpose + +Interactive entering and editing of a multi-line text (a topic todo) with full editing +capabilities — erasing any line, moving the cursor — which a prompt cycle cannot offer. +Use this practice for every interactive moment that collects or edits a multi-line text. +The practice replaces the former prompt-cycle entry (the lone `.` line and Ctrl+D +terminators are abolished). + +## Entry Protocol + +1. Resolve the editor: `$VISUAL` first, then `$EDITOR`, otherwise `vi`. +2. Create a temporary file outside the topic directory — empty for a fresh text, or + prefilled with the existing content when an existing text is edited. +3. Print the hint to the terminal **before** launching the editor — an empty file means + cancellation. No hint comments inside the file itself. +4. Launch the editor on the temporary file. Saving and exiting the editor completes the + entry. +5. Read the file back after the editor exits. + +## Cancellation + +An empty or unchanged file means the entry did not happen — execution continues as +without the entry. When an existing target file is edited, cancellation leaves it +untouched. The emptiness check runs on the saved content; a text of only blank lines +cancels the entry the same way. + +## Errors + +- A non-TTY when the entry is requested is a clean error raised **before any mutation** + (except where a command explicitly documents a silent skip of the flag instead). +- A non-zero editor exit code and Ctrl+C are clean errors without mutations. +- A missing editor binary surfaces as a clean error, not a traceback. + +## Writing the Result + +The saved text is written to the target file as entered plus a single trailing newline, +encoded UTF-8. Empty lines inside the text stay as entered. The temporary file is +removed after the session (its exact placement and cleanup details belong to the +implementation). + +## Testing + +Mock `$EDITOR` with a script that writes into the file (or leaves it, or clears it, or +exits non-zero) to model every outcome: save, cancellation, failure. Mock the TTY +detection for the non-interactive error. A real editor is never launched in tests. + +## Launch Mechanism + +The editor process is launched through the editor facility of the click +library (already a project dependency): the facility resolves the +editor git-style ($VISUAL, then $EDITOR, then the system default vi), +creates and reads back the temporary file, and maps a failed editor run +to a clean error. The hint before the launch, the cancellation check +(blank or unchanged saved content), and the non-TTY detection belong to +the calling code. + +```python +import sys + +import click + + +def enter_text(initial: str | None = None) -> str | None: + if not sys.stdin.isatty(): + raise click.ClickException("the entry needs an interactive terminal") + click.echo("Enter the text. An empty or unchanged file cancels the entry.") + start = initial or "" + if start and not start.endswith("\n"): # the click facility prefills + start += "\n" # with a trailing newline + saved = click.edit(text=start) + if saved is None or not saved.strip() or saved == start: + return None + return saved +``` + +The equality check compares against the **normalized prefill**, not the raw +`initial`: the click facility appends a missing trailing newline when it +writes the temporary file, so an unchanged save of a text without one comes +back as `initial + "\n"` — comparing to the raw value would treat the +unchanged save as a change. + +In tests mock the editor with a script that writes into the file (or +leaves it, or clears it, or exits non-zero) via the `$EDITOR` +environment variable, and mock the TTY detection for the +non-interactive error. A real editor is never launched in tests. diff --git a/goga/commands/CODEMANIFEST b/goga/commands/CODEMANIFEST index ed4433f6..c4bf2884 100644 --- a/goga/commands/CODEMANIFEST +++ b/goga/commands/CODEMANIFEST @@ -73,8 +73,8 @@ Annotations: | re-sync. Use the `pipeline-command` practice for consumer scenarios of the - pipeline command: the five forms, the topic switch flow of -t/--topic, - and the flag behavior per form. + pipeline command: the five forms, the topic switch flow of -t/--topic + with --todo, and the flag behavior per form. Use the `install-usage` practice for consumer scenarios of the install command: the four modes, the post-install hooks, and the exit codes. @@ -84,7 +84,7 @@ Annotations: | Use the `topics-command` practice for consumer scenarios of the topics command group: the board table, the creation flow, the switching flow, - and the exit codes. + the deletion flow, and the exit codes. Use the `hooks-command` practice for consumer scenarios of the hooks command: the registry tree, the tool slice, and the exit codes. diff --git a/goga/commands/pipeline/.usages/pipeline-command.md b/goga/commands/pipeline/.usages/pipeline-command.md index 35b4e714..a0bdac4f 100644 --- a/goga/commands/pipeline/.usages/pipeline-command.md +++ b/goga/commands/pipeline/.usages/pipeline-command.md @@ -26,6 +26,7 @@ exit 1). `--info` is a modifier, not a mode: without a name and without | -l / --list | flag | select the listing forms | | -i / --info | flag | show instead of act (overview with --list, card with NAME) | | -t / --topic ID | str | bring the repository onto the requested work (branch name, topic slug, or prefix) before the run, creating it when nothing hosts it; run form only | +| --todo | flag | open the external editor with the topic's todo.md after the switch or the fast creation (run form only; a clean error without --topic and on a non-terminal before any git or docker activity; no short form — -t stays with --topic) | | -w / --workflow NAME | str | apply an explicit workflow (run and card); the file must exist (early host validation) | | --no-workflow | flag | disable workflow resolution (run and card) | | -p / --parallel N | int | max concurrently executing stages; run only | @@ -38,6 +39,8 @@ exit 1). `--info` is a modifier, not a mode: without a name and without goga pipeline development --topic history-com goga pipeline development -t release-1-3-0 goga pipeline refinement -t prune-history-and-new-status + goga pipeline development --topic history-com --todo + goga pipeline refinement -t prune-history-and-new-status --todo Brings the repository onto the requested work — an exact branch name, an exact topic slug, or their prefix — and then launches the usual run. When @@ -50,6 +53,16 @@ and card forms silently ignore -t. Several candidates without a terminal, a dirty working tree on a switch, or an unusable (empty-slug) or occupied name without a terminal is a clean error before any launch. +With --todo the editor opens with the topic's todo.md after the +repository is on the work: the existing content when the topic has a +todo, an empty entry otherwise; a branch without a topic gets its +topic directory created first — the fast process is never interrupted. +Saving overwrites todo.md without a commit; an empty or unchanged file +leaves it untouched. Without a terminal --todo is a clean error before +any git or docker activity, and so is --todo without --topic — the entry +needs requested work; --list and --info forms ignore the flag +silently. + ## Flag behavior in the list/info forms - Ignored (no-op, no side effects): `-e/--env`, `--proxy`, `-c/--clean`, diff --git a/goga/commands/pipeline/CODEMANIFEST b/goga/commands/pipeline/CODEMANIFEST index a1da62f9..8e40ac35 100644 --- a/goga/commands/pipeline/CODEMANIFEST +++ b/goga/commands/pipeline/CODEMANIFEST @@ -113,14 +113,19 @@ Annotations: | check; the `docker-image-version` practice covers the image-side version probe. - The optional -t/--topic flag moves the repository onto the requested work - before the run form launches: the identifier resolves through `ensure_topic` - — an exact branch name, an exact topic slug, or their prefix — and, when - nothing hosts it, fresh work is created: the branch named as entered and - the topic directory of the year. The switch or creation completes before - any docker activity. The procedure runs after the argument-form - validation. The listing and info forms silently skip the whole topic - procedure. + The optional -t/--topic flag moves the repository onto the requested + work before the run form launches: the identifier resolves through + `ensure_topic` — an exact branch name, an exact topic slug, or their + prefix — and, when nothing hosts it, fresh work is created: the + branch named as entered, from the current HEAD, and the topic + directory of the year. The switch or the creation completes before + any docker activity. The optional --todo flag (no short form — -t + stays with --topic) requests the todo entry of the work: after the + switch onto hosted work, or after the fast creation, the external + editor opens with the topic's todo.md — the entry belongs to the + domain orchestration. Without an interactive terminal the flag is a + clean error before any git or docker activity. The listing and info + forms silently skip the whole topic procedure. Use the `ensuring` practice for the consumer patterns of the topics facade used by the topic procedure — the identifier resolution and the @@ -132,7 +137,7 @@ Annotations: | --- -"pipeline(ctx: click.Context, name: str | None, list_requested: bool, info: bool, topic: str | None, extra_env: tuple[str, ...], proxy: str | None, add_host: tuple[str, ...], clean: bool, update: bool, workflow: str | None, no_workflow: bool, skip: tuple[str, ...], parallel: int | None)": +"pipeline(ctx: click.Context, name: str | None, list_requested: bool, info: bool, topic: str | None, todo: bool, extra_env: tuple[str, ...], proxy: str | None, add_host: tuple[str, ...], clean: bool, update: bool, workflow: str | None, no_workflow: bool, skip: tuple[str, ...], parallel: int | None)": location: pipeline.py annotations: | Single CLI command "goga pipeline" with five explicit forms. Every form @@ -160,6 +165,17 @@ Annotations: | branch named as entered and the topic directory of the year) when nothing hosts the identifier. Silently ignored in the flat list, overview, and card forms — not an error. + `todo`: flag from the --todo click option — request the todo entry + of the requested work; run form only: after the switch or + the fast creation of the topic procedure, the external + editor opens with the topic's todo.md inside `ensure_topic` + — a branch without a topic gets its topic directory created + first. Without an interactive terminal a clean error + precedes any git or docker activity. Given without `topic` + in the run form -> a clean error: the entry needs + requested work — before any git or docker activity. + Silently ignored in the flat list, overview, and card + forms — not an error. `extra_env`: raw KEY=VALUE strings from the repeatable -e/--env option, forwarded into the container env-file in the run form only. `proxy`: optional HTTP/HTTPS proxy URL from the --proxy option; when @@ -214,20 +230,27 @@ Annotations: | <cwd>/.goga/workflows/<workflow>.yml exists; a missing file is a clean error (exit 1) 3. Topic procedure (run form only — `name` given, `list_requested` - False, `topic` given): bring the repository onto the requested work - via `ensure_topic` — a switch onto the hosting branch when one - hosts the identifier, or the creation of fresh work (the branch - named as entered and the topic directory of the year) when nothing - does. Echo the single result line to stdout once, immediately after - the topic procedure and before the step-4 dispatch; the forms that - skip the procedure print no topic line. Every git action happens on - the host before any docker activity. Several candidates without an - interactive terminal, a dirty working tree on a switch mutation, or - an unusable (empty-slug) or occupied name without a terminal aborts - the command with a non-zero exit before any image refresh, build, - or launch. The flat list, overview, and card forms skip the - procedure silently — passing -t there is not an error and has no - effect. + and `info` unset): `todo` without `topic` is a clean error + before any git or docker activity — the entry needs requested + work. With + `topic` given, bring the repository onto the requested + work via `ensure_topic` with the `todo` flag — a switch onto the + hosting branch when one hosts the identifier, or the fast + creation when nothing does: the branch named as entered, from + the current HEAD — the configuration base is never read — and + the topic directory of the year, ensured on a branch without a + topic. Under `todo` the editor entry starts only after the + switch or the creation. Echo the single result line to stdout + once, immediately after the topic procedure and before the + step-4 dispatch; the forms that skip the procedure print no + topic line. Every git action and the entry happen on the host + before any docker activity. Several candidates without an + interactive terminal, a dirty working tree on a switch mutation, + an unusable or occupied name without a terminal, or `todo` + without an interactive terminal abort the command with a + non-zero exit before any image refresh, build, or launch. The + flat list, overview, and card forms skip the procedure silently + — passing -t or --todo there is not an error and has no effect. 4. Dispatch by form: - flat list — `run_pipeline_info_container` with name=None, info=False; `update` applies (image refresh before the listing) @@ -260,6 +283,13 @@ Annotations: | error never launches an image - The listing and info forms silently ignore -t/--topic — no message, no side effects + - Without an interactive terminal, `todo` aborts the command before + any git or docker activity — the error precedes the topic + procedure + - The run form rejects `todo` without `topic` — a clean error + before any git or docker activity + - The listing and info forms silently ignore --todo — no message, + no side effects - A repeated run already on the host continues without switching — the call is idempotent - The listing and info forms silently ignore -e/--env, --proxy, diff --git a/goga/commands/topics/.usages/topics-command.md b/goga/commands/topics/.usages/topics-command.md index dc997ad1..d541f38c 100644 --- a/goga/commands/topics/.usages/topics-command.md +++ b/goga/commands/topics/.usages/topics-command.md @@ -31,71 +31,77 @@ lines. An empty board prints nothing and exits 0. ## Creating fresh work - goga topics create Feature/Foo_Bar - goga topics --year 2025 create Feature/Foo_Bar + goga topics create Feature/Foo_Bar --from-current + goga topics create Feature/Foo_Bar --base-ref origin/main goga topics create Feature/Foo_Bar -t "Payment retry" - goga topics create Feature/Foo_Bar --todo "Fix retries. - - Retries ignore the backoff cap." - goga topics create Feature/Foo_Bar -t - -Creates the branch with the name as entered, switches to it, and creates -the topic directory of the scoped year. An explicit `--todo/-t` value -writes the topic todo file `todo.md` — the text as entered plus a -trailing newline, UTF-8; the todo may span multiple lines. `-t` without a -value (or with an empty one) starts the interactive multi-line entry: -type the todo line by line — empty lines continue the text as paragraphs — -and finish with a lone `.` line or Ctrl+D; the rule is stated in the -prompt. Entering nothing cancels the entry: no `todo.md` is written and -the command continues as without the flag. Interactive entry without a -terminal is a clean error. On the idempotent re-run (the current branch -already hosts the same slug) the topic directory is ensured and -`todo.md` is created or overwritten — nothing else mutates, no switch -happens. Without `-t` no todo file is written. Occupied names and empty -slugs trigger a re-ask on an interactive terminal, or a clean error with -a hint otherwise. + goga topics --year 2025 create Feature/Foo_Bar --base-ref origin/main + +Creates the branch off the base — --base-ref, topics.base_ref of +.goga/config.yml, or --from-current (the current HEAD); no base at all +is a clean error before anything else, naming the flag and the +configuration line. The preflight (an empty slug, an occupied branch +name or slug, the current branch hosting the same slug) runs before +the editor: creating the existing is an error with a hint to the +board — the todo of an existing topic is `goga topics switch ID +--todo`. An explicit --todo/-t value (only the value form exists; an +empty value counts as absent) is the todo; without a value a terminal +opens the external editor ($VISUAL/$EDITOR/vi) — an empty or unchanged +file cancels the entry and the command continues without a todo; +without a terminal and without a value the command is a clean error +naming --todo "...". The saved text becomes todo.md — the last action +of the normal path: the branch off the base, the switch, the topic +directory, then todo.md. On a terminal without --publish the +"Publish? [y/N]" ask appears only when a todo was obtained; +confirming publishes with a full rollback on failure, declining takes +the normal path. ## Creating and publishing fresh work - goga topics create Feature/Foo_Bar --publish -t "Payment retry" - goga topics create Feature/Foo_Bar -p --todo - goga topics create Feature/Foo_Bar -p -t "Payment retry" --base-ref origin/release-1.3 + goga topics create Feature/Foo_Bar --publish -t "Payment retry" --base-ref origin/main goga topics create Feature/Foo_Bar -p -t "Payment retry" -c "chore: new topic {slug}" -Creates the branch off the configured base (topics.base_ref in -.goga/config.yml, overridden by --base-ref), commits the topic todo file -on it without touching the working copy — the caller stays on their -branch, a dirty tree and a detached HEAD are both fine — and pushes the -branch to origin with upstream binding. The topic is visible on the -remote board with the todo status. The result is one line: created and -published on the remote. - -- The todo is required in this mode — the value comes from `--todo/-t` or - the interactive entry. -- The commit message comes from topics.publish_commit (default - `goga: create topic {slug}`), overridden by --commit/-c; the {slug} - placeholder takes the topic slug, a template without it is used as is. -- An occupied name, an empty slug, or a slug already hosted by any branch - of the inventory re-asks on an interactive terminal, or fails with a - hint to the board. -- A failed publication rolls back fully — the branch is deleted and one - clean error names the reason; re-run after fixing the cause succeeds. -- The base must come from --base-ref or the configuration — nothing set is - a clean error with a configuration example; the base resolves as git - resolves it, no fetch happens. -- --base-ref or --commit without --publish is a clean error; a missing - origin or an unset git identity is a clean error. +The publish path needs no terminal and asks nothing: the todo comes +from --todo/-t. The base comes from --base-ref, topics.base_ref, or +--from-current. --commit/-c (topics.publish_commit, default +`goga: create topic {slug}`) stays publication-only — an error without +--publish. A failed publication rolls back fully — the branch is +deleted and one clean error names the reason. ## Switching to existing work goga topics switch history-com - goga topics --year 2025 switch release-1-3-0 + goga topics switch history-com --todo -Resolves the identifier — exact branch name, then exact topic slug, then +Resolves the identifier — exact branch name, exact topic slug, then prefixes — and switches. Several candidates offer a numbered list with -statuses; without interactive input the command fails with the list. Already -being on the host is an idempotent success. A dirty working tree is a clean -error when a mutation is needed. Switching is always local. +statuses; without interactive input the command fails with the list. +Already being on the host is an idempotent success. A dirty working +tree is a clean error when a mutation is needed. With --todo the +editor opens with the topic's todo.md after the switch: saving +overwrites the file without a commit, cancelling leaves it untouched. +--todo on a branch without a topic, or without a terminal, is a clean +error before the switch. Switching is always local. + +## Deleting topics + + goga topics delete feature-foo release-1-3-0 + goga topics delete feature-foo --yes + +Resolves every identifier (branch name, topic slug, prefix — plus +topic directories of the year no branch hosts); an unknown or +ambiguous identifier cancels the whole call before anything is +removed. One confirmation for the whole list — "Delete N topics? +[y/N]" with the topic-to-branch pairs; --yes/-y skips it (a +non-terminal without --yes is a clean error; the -y collision with the +group --year is resolved by position). The deletion is symmetric to +creation-and-publication: the local branch and its origin twin are +both removed (the local first; a failed remote deletion restores the +local branch and stops with one clean error), a directory without +branches is removed from disk. The current branch hosting a target is +a clean error — switch away first. Merged work is out of scope: a +topic hosted by a branch that is not its own topic branch is a clean +error naming the hosting branch. Unmerged commits never block: the +deletion is unconditional after the confirmation. ## Exit codes diff --git a/goga/commands/topics/CODEMANIFEST b/goga/commands/topics/CODEMANIFEST index d69719bf..cf76a6de 100644 --- a/goga/commands/topics/CODEMANIFEST +++ b/goga/commands/topics/CODEMANIFEST @@ -4,12 +4,15 @@ Imports: - collect_topic_board - switch_topic - create_topic - - publish_topic + - resolve_delete_targets + - delete_topics Usages: - topic-board - switching + - todo-entry - creating - publishing + - deleting From: goga/topics - Types: - load_project_config @@ -31,51 +34,62 @@ Annotations: | - Understanding the general principles and rules of development and testing in the project Use the `click` practice to build the topics command group: the group - decorator with its own option, the subcommand registration, the flag, - arguments, and options of each subcommand, echo, exit-code propagation, - and the interactive multi-line todo entry with its prompt cycle, - terminators, and non-interactive detection. - - Use the `publishing` practice for the fast creation-and-publication - contract of the domain. Use the `project-configuration` practice for the - schema of the topics section. - - The fast-creation flags resolve their values at this layer: a flag beats - the topics section — `TopicsConfig` — of the project configuration read - via `load_project_config`, which beats the built-in default; the - configuration is read on the publish path only, and only for values no + decorator with its own option, the subcommand registration, the + flags, arguments, and options of each subcommand, echo, exit-code + propagation, the confirmation of the delete subcommand, and the + clean error rendering. + + Use the `creating` and `publishing` practices for the creation and + publication contracts of the domain, the `switching` practice for + the switching contract, the `todo-entry` practice for the todo entry + behind the --todo flags, the `topic-board` practice for the board + contract, and the `deleting` practice for the deletion contract of + the domain. Use the `project-configuration` practice for the schema + of the topics section. + + The creation inputs resolve their values at this layer: the base — + a flag beats the topics section — `TopicsConfig` — of the project + configuration read via `load_project_config`, which beats the + current HEAD requested explicitly; no base at all is a clean error + naming the flag and the configuration line. The commit message + template — a flag beats the topics section; the built-in default + lives in the domain. The configuration is read only for values no flag provided. - This cell is the CLI surface of the topics domain: a thin wrapper that - resolves inputs, delegates every computation to the domain routines, and - renders the board. No inventory walking, no switch resolution, no git - access live here. Domain errors surface as clean CLI errors (stderr, - non-zero exit, no traceback). Use relative imports. + This cell is the CLI surface of the topics domain: a thin wrapper + that resolves inputs, delegates every computation to the domain + routines, renders the board, and confirms the deletion. No inventory + walking, no switch resolution, no git access, no editor session live + here. Domain errors surface as clean CLI errors (stderr, non-zero + exit, no traceback). Use relative imports. --- "topics(year: str | None = None)": location: topics.py annotations: | - The goga topics command group — a click.Group container for the topics - subcommands, exported via __all__ and registered in the root application - group. The group carries the year scope every subcommand shares. + The goga topics command group — a click.Group container for the + topics subcommands, exported via __all__ and registered in the + root application group. The group carries the year scope every + subcommand shares. - `year`: the --year/-y group option — exactly one year, four digits the - recognized form; None means the current year; a search across - years does not exist + `year`: the --year/-y group option — exactly one year, four digits + the recognized form; None means the current year; a search + across years does not exist - Use the `click` practice for the group decorator, the group option, and - the subcommand registration. + Use the `click` practice for the group decorator, the group option, + and the subcommand registration. Subcommand surfaces: - board — a --remote/-r flag, an --info/-i flag - - create — a NAME positional, a --todo/-t option, a --publish/-p flag, - a --base-ref option, a --commit/-c option - - switch — an IDENTIFIER positional - - Apply the `convention` CLI command docstring rule for the --help text - (rendered verbatim by Click; omit Args/Returns/Raises). + - create — a NAME positional, a --todo/-t option, a --publish/-p + flag, a --base-ref option, a --from-current flag, a --commit/-c + option + - switch — an IDENTIFIER positional, a --todo flag + - delete — IDENTIFIER positionals, a --yes/-y flag + + Apply the `convention` CLI command docstring rule for the --help + text (rendered verbatim by Click; omit Args/Returns/Raises). methods: "board(remote: bool = False, info: bool = False) -> exit_code: int": | Subcommand goga topics board: print the board — the cross-branch @@ -106,92 +120,125 @@ Annotations: | - Do not print the year or the artifacts, and no heading line outside the table — the table carries topic, branch, the todo column under `info`, and statuses only - "create(branch_name: str, todo: str | None = None, publish: bool = False, base_ref: str | None = None, commit_message: str | None = None) -> exit_code: int": | - Subcommand goga topics create: create fresh work — a branch with the - name as entered, its topic directory of the scoped year, and an - optional multi-line todo; under --publish the work is created off an - explicit base and published to origin without switching. + "create(branch_name: str, todo: str | None = None, publish: bool = False, base_ref: str | None = None, from_current: bool = False, commit_message: str | None = None) -> exit_code: int": | + Subcommand goga topics create: create fresh work — a branch off + the resolved base with the name as entered, its topic directory + of the scoped year, and an optional multi-line todo; under + --publish the work is created off the base and published to + origin without switching. `branch_name`: NAME positional — the branch name as entered - `todo`: the --todo/-t value — the multi-line todo of the fresh work; - a flag given without a value or with an empty value starts - the interactive entry; None when the flag is absent - `publish`: the --publish/-p flag — the fast creation-and-publication - mode - `base_ref`: the --base-ref value — the base of the published branch; - beats the topics section of the configuration - `commit_message`: the --commit/-c value — the commit message template; - beats the topics section of the configuration + `todo`: the --todo/-t value — the todo text; only the value form + exists, an empty value counts as an absent option + `publish`: the --publish/-p flag — the publication path without + the ask + `base_ref`: the --base-ref value — the base of the branch; valid + with and without `publish` + `from_current`: the --from-current flag — the current HEAD as + the base + `commit_message`: the --commit/-c value — the message template; + publication-only `exit_code`: 0 on success, 1 on error - Apply the `creating` practice for the creation contract of the domain. - Apply the `publishing` practice for the fast creation-and-publication - contract of the domain. - Apply the `project-configuration` practice for the topics section - schema. - Apply the `click` practice for the flag with an optional value, the - interactive multi-line todo entry, and exit-code propagation. + Apply the `creating` practice for the creation contract of the + domain. + Apply the `publishing` practice for the publication contract of + the domain. + Apply the `project-configuration` practice for the topics + section schema. + Apply the `click` practice for the options and exit-code + propagation. Algorithm: - 1. `base_ref` or `commit_message` without `publish` -> clean error: - the publication-only options never act silently - 2. Resolve the todo — a non-empty `todo` value is the todo; a flag - given without a value or with an empty value starts the - interactive multi-line entry: each entered line continues the - text, empty lines inside the text stay as entered, a line - consisting of a single dot or the end of input finishes the entry, - the terminator rule is - stated in the prompt itself; no line entered cancels the entry — - execution continues as without the flag; the entry on a - non-interactive terminal is a clean error before any mutation - 3. `publish` with no resolved todo -> clean error asking for the todo - 4. The default path delegates to `create_topic` with `branch_name`, - the scoped year, and the resolved todo — None when neither the - flag nor the entry produced one - 5. The publish path resolves the base — `base_ref`, otherwise the - topics section of the configuration loaded via - `load_project_config`, otherwise a clean error naming the - configuration line and the flag — and the message template — - `commit_message`, otherwise the topics section, otherwise the - built-in default `goga: create topic {slug}` - 6. The publish path delegates to `publish_topic` with `branch_name`, - the resolved todo, the resolved base, the resolved template, and - the scoped year - 7. Echo the single result line - 8. Propagate the exit code + 1. `commit_message` without `publish` -> clean error: the option + is publication-only + 2. Resolve the base — `base_ref`, otherwise the topics section of + the configuration loaded via `load_project_config`, otherwise + the current HEAD under `from_current`; no base at all -> + clean error naming the flag and the configuration line, + before anything else + 3. Resolve the template — `commit_message`, otherwise the topics + section, otherwise None (the built-in default lives in the + domain) + 4. Delegate to `create_topic` with the name, the base, the todo, + `publish`, the template, and the scoped year + 5. Echo the single result line + 6. Propagate the exit code Requirements: - - The resolved todo is either non-empty text or absent — an empty - todo.md never exists - - The configuration is read on the publish path only, and only for - values no flag provided — the default path never reads it - - A missing configuration file counts as an unset value; a present + - The configuration is read only for values no flag provided; a + missing configuration file counts as an unset value; a present but invalid one surfaces its own clean error Constraints: - - Do not validate the name at the CLI layer — the domain and git own - that - - Do not switch branches or render the board here — every computation - belongs to the domain - "switch(identifier: str) -> exit_code: int": | - Subcommand goga topics switch: bring the repository onto the branch - hosting the requested work. - - `identifier`: IDENTIFIER positional — a branch name, a topic slug, or - their prefix + - Do not validate the name at the CLI layer — the domain and git + own that + - Do not open the editor here — the entry belongs to the domain + - Do not switch branches or render the board here — every + computation belongs to the domain + "switch(identifier: str, todo: bool = False) -> exit_code: int": | + Subcommand goga topics switch: bring the repository onto the + branch hosting the requested work; under --todo enter the todo + of the switched topic after the switch. + + `identifier`: IDENTIFIER positional — a branch name, a topic + slug, or their prefix + `todo`: the --todo flag — enter the todo of the switched topic `exit_code`: 0 on success, 1 on error Apply the `switching` practice for the switching contract of the domain. - Apply the `click` practice for exit-code propagation. + Apply the `click` practice for the flag and exit-code + propagation. Algorithm: - 1. Delegate to `switch_topic` with `identifier` and the scoped year + 1. Delegate to `switch_topic` with the identifier, the flag, and + the scoped year 2. Echo the single result line 3. Propagate the exit code Constraints: - - Do not launch any pipeline — continuation is a separate command + - Do not launch any pipeline — continuation is a separate + command + "delete(identifiers: tuple[str, ...], yes: bool = False) -> exit_code: int": | + Subcommand goga topics delete: resolve and delete identified + topics — the local branch, the origin twin, and the topic + directory — under one confirmation for the whole list. + + `identifiers`: IDENTIFIER positionals — branch names, topic + slugs, or their prefixes + `yes`: the --yes/-y flag — skip the confirmation + `exit_code`: 0 on success (a declined confirmation included), 1 + on error + + Apply the `deleting` practice for the deletion contract of the + domain. + Apply the `click` practice for the confirmation, echo, and + exit-code propagation. + + Algorithm: + 1. Resolve the targets via `resolve_delete_targets` with the + identifiers and the scoped year — a resolution error is clean + and deletes nothing + 2. Without `yes`: no interactive terminal -> clean error; + otherwise print the topic-to-branch pairs of the list and ask + one confirmation for the whole list; a declined answer exits + 0 with nothing deleted + 3. Delegate to `delete_topics` with the targets and the scoped + year + 4. Echo the single result line + 5. Propagate the exit code + + Requirements: + - One confirmation for the whole list — never per topic + - The -y short form collides with the group --year; the + positions on the command line distinguish them + + Constraints: + - Do not offer an interactive choice among ambiguous candidates — + ambiguity is a domain error + - Do not delete anything before the resolution and the + confirmation are complete "render_topic_board(records: list[BoardRecord], width: int, info: bool = False)": location: render.py @@ -250,5 +297,5 @@ Annotations: | Author: Goga CreatedAt: 29/08/26 Description: | - The goga topics command group with the board, create, and switch - subcommands over the topics domain. + The goga topics command group — board, create, switch, and delete — + over the topics domain. diff --git a/goga/topics/.usages/creating.md b/goga/topics/.usages/creating.md index f93dc74d..bdb8354c 100644 --- a/goga/topics/.usages/creating.md +++ b/goga/topics/.usages/creating.md @@ -1,53 +1,47 @@ # topics — creating fresh work -How to create a new branch with its topic directory using the `goga.topics` -facade. For consumers that start new work. +How to create a new branch off an explicit base with its topic +directory using the `goga.topics` facade. For consumers that start new +work. -`create_topic` takes the branch name as entered. The branch keeps the name -verbatim; the topic directory takes the normalized slug of the year — the -two may deliberately differ (Feature/Foo_Bar branches into the -feature-foo-bar topic). +`create_topic` takes the branch name as entered and the base revision. +The branch keeps the name verbatim; the topic directory takes the +normalized slug of the year — the two may deliberately differ +(Feature/Foo_Bar branches into the feature-foo-bar topic). ## Creating ```python from goga.topics import create_topic -result = create_topic("Feature/Foo_Bar") # current year -result = create_topic("Feature/Foo_Bar", year="2025") +result = create_topic("Feature/Foo_Bar", "origin/main") # current year +result = create_topic("Feature/Foo_Bar", "origin/main", year="2025") print(result) # one line describing what was created ``` -- A free name creates the branch, switches to it, and creates the topic - directory of the year. -- The current branch already hosting the same slug is an idempotent - success — no mutation. -- An occupied name or an empty slug triggers a re-ask on an interactive - terminal, or a clean error with the reason and a hint otherwise. -- Occupancy oracles: a local branch ref, a remote-tracking ref, and the - topic directory of the year — exposed as `check_branch_occupancy`. -- No artifact files are written inside the topic directory — artifacts - belong to their producers. - -## Creating with a todo - -```python -from goga.topics import create_topic - -result = create_topic( - "Feature/Foo_Bar", - todo="Fix payment retries.\n\nRetries ignore the backoff cap.", -) -``` - -- Fresh work: the branch, the switch, the topic directory, and the todo - file `todo.md` — the text as entered plus a trailing newline, UTF-8. -- The todo is multi-line: empty lines inside the text stay as entered, so - paragraphs survive. -- `todo` empty or omitted writes no `todo.md` — an existing file is left - untouched. -- The current branch already hosting the same slug with an explicit todo: - the topic directory is ensured and `todo.md` is created or overwritten — - nothing else mutates, no switch happens. -- `todo.md` marks the `todo` status on the topic status scale; no other - artifact is written — artifacts belong to their producers. +- The base is explicit — any revision git resolves; the branch starts + at it and the repository switches to it. +- The preflight runs before any input: an empty slug, an occupied + branch name or slug, or the current branch hosting the same slug is + a clean error with a hint to the board — creating the existing is an + error, not an update. +- The todo: passed by value it is the todo; without a value an + interactive terminal opens the external editor — a cancelled entry + creates without a todo; a non-interactive terminal without a value + is a clean error naming the value option. +- On an interactive terminal without an explicit publish decision, the + publication ask runs when a todo was obtained — the answer chooses + between the normal path and the publication path. +- The normal path: branch off the base, switch, the topic directory, + and todo.md as the last action — the text as entered plus a trailing + newline, UTF-8. +- No todo resolved — no todo.md is written. + +## Occupancy + +- Occupancy oracles: a local branch ref, a remote-tracking ref, and + the topic directory of the year — exposed as + `check_branch_occupancy`; the branch-tree oracle is + `check_slug_occupancy`. +- No artifact files are written inside the topic directory beyond the + todo file — artifacts belong to their producers. diff --git a/goga/topics/.usages/deleting.md b/goga/topics/.usages/deleting.md new file mode 100644 index 00000000..bd2869d4 --- /dev/null +++ b/goga/topics/.usages/deleting.md @@ -0,0 +1,46 @@ +# topics — deleting identified topics + +How to resolve and delete identified topics with the `goga.topics` +facade. For consumers that tear down work: the topics command layer. + +`resolve_delete_targets` turns identifiers into targets — everything is +checked before anything is removed. `delete_topics` executes the +confirmed deletion. + +## Resolving targets + + from goga.topics import resolve_delete_targets + + targets = resolve_delete_targets(["feature-foo", "release-1-3-0"]) + for target in targets: + print(target.topic, target.branch, target.remote, target.has_dir) + +- Identifier tiers: exact branch name, exact topic slug, prefixes — + plus topic directories of the year no branch hosts. +- No match or several matches -> a clean error, no interactive + selection; the whole call is cancelled — all-or-nothing. +- A local branch and its origin twin form one target; repeated + identifiers collapse. +- Merged work is out of scope: a topic hosted by a branch that is not + its own topic branch (the post-merge state) is a clean error naming + the hosting branch — remove it from the hosting branch's tree + instead. +- The current branch hosting a target -> a clean error asking to + switch away first. + +## Deleting confirmed targets + + from goga.topics import delete_topics + + result = delete_topics(targets) # the caller has confirmed + print(result) # one line — the outcome + +- The confirmation belongs to the caller; the deletion is + unconditional — no merge checks. +- Local branch + origin twin: both removed, the local first; a failed + remote deletion restores the local branch at its former commit and + raises one clean error — targets removed before the failure stay + removed. +- A directory without branches is removed from disk. +- The deletion push is a network operation of the domain; no fetch + ever happens. diff --git a/goga/topics/.usages/ensuring.md b/goga/topics/.usages/ensuring.md index 073da623..edb4c934 100644 --- a/goga/topics/.usages/ensuring.md +++ b/goga/topics/.usages/ensuring.md @@ -7,10 +7,10 @@ identifier. `ensure_topic` resolves the identifier exactly like `switch_topic` — exact branch name, then exact topic slug (a local branch beats its remote -twin), then prefixes, first non-empty tier wins — and falls back to -`create_topic` with the identifier as the branch name only when **zero -candidates** resolve. A resolvable identifier therefore never creates -anything. +twin), then prefixes, first non-empty tier wins — and falls back to the fast +creation — the branch named as entered, off the current HEAD — only when **zero candidates** +resolve. A resolvable identifier therefore never creates +anything. With `todo=True` the todo entry runs after the switch or the creation. ## Ensuring work @@ -22,23 +22,23 @@ result = ensure_topic("Feature/Foo_Bar", year="2025") print(result) # one line — the outcome ``` -- Nothing hosts the identifier -> fresh work: the branch is created with - the name as entered, the repository switches to it, and the topic - directory of the year is created from its slug — - `Created branch <name> and topic <year>/<slug>`. No `todo.md` is - written: the creation fallback takes no todo — fresh work with a todo is - `create_topic` alone. -- A hosted identifier -> the plain switch outcome: `Switched to branch - <name>`, `Created branch <name> from <remote>/<name>`, or `Already on - branch <name>` (idempotent, nothing touched). +- Nothing hosts the identifier -> the fast creation from the current + HEAD: the branch named as entered, the switch, the topic directory — + and with `todo=True` the editor entry afterwards (an empty entry + file; a cancelled entry leaves no todo.md). No publication ask + exists here; the configuration base is never read. +- A hosted identifier -> the plain switch outcome; with `todo=True` + the entry follows the switch: the todo.md of the **requested** topic — + the resolution's hosted topic, so a topic merged into another branch + is entered as itself (never a fresh directory of the hosting branch's + name); a branch without a topic gets its topic directory created + first, then the empty entry — the fast process is never interrupted + (a branch name with no slug is a clean error). - Several candidates -> the numbered list with statuses and a number prompt; without interactive input the call fails with the list — ambiguity never escapes into creation. -- An occupied name (an existing branch, a remote-tracking twin, or the - topic directory of the year) or an empty slug triggers a re-ask on an - interactive terminal, or a clean error with the reason and a hint - otherwise. -- A switch that would mutate probes the working tree first — a dirty tree - is a clean error. The creation fallback carries uncommitted changes - onto the fresh branch instead. +- An occupied name or an empty slug is a clean error with the reason + and a hint to the board. +- `todo=True` without an interactive terminal is a clean error before + any action. - Mutations are local-only — no network, no fetch, no push. diff --git a/goga/topics/.usages/publishing.md b/goga/topics/.usages/publishing.md index 63f26c0f..96e60efd 100644 --- a/goga/topics/.usages/publishing.md +++ b/goga/topics/.usages/publishing.md @@ -6,7 +6,8 @@ remote board while the user keeps working: the topics command group, higher-level orchestration. `publish_topic` takes the branch name as entered, a required multi-line -todo, an explicit base, and a commit message template. The branch keeps the +todo, an explicit base, and a commit message template; `commit_message` +omitted — the built-in default `goga: create topic {slug}`. The branch keeps the name verbatim; the topic directory takes the normalized slug of the year — the two may deliberately differ. @@ -42,8 +43,7 @@ print(result) # one line: created and published on the remote ## Occupancy - An occupied name, an empty slug, or a slug already hosted by any branch - of the inventory triggers a re-ask on an interactive terminal, or a clean - error with a hint to the board otherwise. + of the inventory is a clean error with a hint to the board. - `check_slug_occupancy` exposes the branch-tree oracle — the slug duplicate check across the inventory; the three local oracles stay in `check_branch_occupancy`. diff --git a/goga/topics/.usages/switching.md b/goga/topics/.usages/switching.md index 33ca0b0b..67719308 100644 --- a/goga/topics/.usages/switching.md +++ b/goga/topics/.usages/switching.md @@ -4,7 +4,8 @@ How to move the repository onto existing work with the `goga.topics` facade. For consumers that resume work. `switch_topic` resolves the identifier, chooses among candidates, and -performs the switch. Resolution tries three tiers in order — exact branch +performs the switch; with `todo=True` it then enters the todo of the +switched topic. Resolution tries three tiers in order — exact branch name, then exact topic slug (a local branch beats its remote twin), then prefix matches on branch names and slugs — and takes the first non-empty tier: an exact match excludes prefix candidates. A branch without a topic is @@ -28,6 +29,25 @@ print(result) # one line — the outcome - Mutations are local-only: checkout of a local branch, or creation of a local branch from a remote-tracking ref (no network). +## Switching with the todo entry + +```python +from goga.topics import switch_topic + +result = switch_topic("history-com", todo=True) +``` + +- After the switch the external editor opens with the topic's todo.md: + saving overwrites the file as entered plus a trailing newline, + UTF-8, without a commit; an empty or unchanged file leaves it + untouched. +- Already on the hosting branch: the idempotent success — and the + entry still runs. +- A candidate without a topic under `todo=True` is a clean error — + switching creates nothing. +- `todo=True` without an interactive terminal is a clean error before + any switching. + ## Resolving candidates without switching ```python diff --git a/goga/topics/.usages/todo-entry.md b/goga/topics/.usages/todo-entry.md new file mode 100644 index 00000000..2f18f85a --- /dev/null +++ b/goga/topics/.usages/todo-entry.md @@ -0,0 +1,21 @@ +# topics — entering the todo of a topic + +How to collect or edit the todo.md of a topic in one call with the +`goga.topics` facade. For consumers that continue work on a topic: the +switch and ensure orchestrations, the command layer. + +`enter_topic_todo` opens the external editor with the topic's todo.md — +the existing content when the file exists, an empty entry otherwise — +and writes the saved text without a commit. + + from goga.topics import enter_topic_todo + + written = enter_topic_todo("feature-foo") # current year + written = enter_topic_todo("Feature/Foo_Bar", year="2025") + +- Saved text -> todo.md overwritten as entered plus a trailing + newline, UTF-8 — no commit. +- Cancelled entry (empty or unchanged file) -> False, the file stays + untouched. +- The topic directory must exist — creation belongs to the caller. +- A missing interactive terminal is a clean error before anything. diff --git a/goga/topics/CODEMANIFEST b/goga/topics/CODEMANIFEST index 3a587b24..ba1033a5 100644 --- a/goga/topics/CODEMANIFEST +++ b/goga/topics/CODEMANIFEST @@ -11,6 +11,8 @@ Imports: - current_year - StatusScale - assemble_status_scale + - collect_history_tree + - remove_topic_dir Usages: - topic-paths - topic-statuses @@ -28,12 +30,19 @@ Imports: - commit_file_on_base - create_branch_at_commit - delete_local_branch + - delete_remote_branch - push_branch - origin_configured Usages: - refs-and-switching - publishing + - deleting From: goga/topics/git + - Types: + - edit_text + Usages: + - editor-entry + From: goga/topics/editor Usages: convention: .goga/usages/conventions.md @@ -47,37 +56,44 @@ Annotations: | - Organizing the test infrastructure - Understanding the general principles and rules of development and testing in the project - Use the `click` practice for the interactive moments of switching and - creation: click.prompt for the numbered candidate selection and the re-ask - cycle, and the non-interactive detection with its clean error. - - Use the `topic-paths` practice for the consumer patterns of the history - facade — the topic slug, the topic directory of a year, the existence - oracle, the topic todo file path, and the current branch. - Use the `topic-statuses` practice for the status scale patterns of the - history facade — scale assembly and maximal-status computation. - Use the `refs-and-switching` practice for the git patterns of the topics - git cell — the branch inventory, ref tree reading, and file reading - without checkout. + Use the `click` practice for the interactive moments of the domain: + the numbered candidate selection, the publication ask, and the + non-interactive detection with its clean error. + Use the `editor-entry` practice for the editor session patterns. + Use the `topic-paths` practice for the consumer patterns of the + history facade — the topic slug, the topic directory of a year, the + existence oracle, the topic todo file path, and the current branch. + Use the `topic-statuses` practice for the status scale patterns of + the history facade — scale assembly and maximal-status computation. + Use the `refs-and-switching` practice for the git patterns of the + topics git cell — the branch inventory, ref tree reading, and file + reading without checkout. Use the `publishing` practice for the quarantined commit building, branch planting, publication, and rollback patterns of the topics git cell. - - This cell owns the topics domain — the work-tracker view of the history - tree: the cross-branch topic inventory of one year with per-topic - statuses and todo summaries, the switch-identifier resolution and - switching orchestration, the fresh-work creation procedure with its - optional multi-line todo, the fast creation-and-publication procedure — - a committed branch off an explicit base without switching, pushed to - origin, rolled back fully on a failed publication — and the combined - ensure orchestration that switches onto hosted work or creates it when - nothing hosts the identifier. Topic identity, addressing, and statuses - belong to the history facade; git access belongs to the topics git cell. - Git infrastructure failures and the fatal scale-assembly ImportError - surface as click.ClickException. Mutations are local-only and happen - strictly after every decision is made — the publication push of the fast - procedure is the single network exception; no fetch ever happens. Use - relative imports. + Use the `deleting` practice for the symmetric local-and-origin + removal and the restore-on-failure patterns of the topics git cell. + + This cell owns the topics domain — the work-tracker view of the + history tree: the cross-branch topic inventory of one year with + per-topic statuses and todo summaries; the switch-identifier + resolution and switching orchestration with the optional todo entry; + the creation procedure off an explicit base with its preflight, the + interactive todo entry, and the publication ask; the fast + creation-and-publication procedure — a committed branch off an + explicit base without switching, pushed to origin, rolled back fully + on a failed publication; the combined ensure orchestration of the + fast process — always from the current HEAD, the topic directory + ensured on any hosting branch, the todo entry after the switch or + the creation; the todo entry of an existing topic; and the + identified-topic deletion — the local branch, the origin twin, and + the topic directory removed symmetrically with restore on failure. + Topic identity, addressing, and statuses belong to the history + facade; git access to the topics git cell; the editor session to the + editor cell. Git infrastructure failures and the fatal + scale-assembly ImportError surface as click.ClickException. + Mutations are local-only except the two pushes — publication and + deletion; no fetch ever happens. Use relative imports. --- @@ -246,36 +262,43 @@ Annotations: | - Do not choose among multiple candidates — selection belongs to the caller -"switch_topic(identifier: str, year: str | None = None) -> result: str": +"switch_topic(identifier: str, todo: bool = False, year: str | None = None) -> result: str": location: switching.py annotations: | - Bring the repository onto the branch hosting the requested work. + Bring the repository onto the branch hosting the requested work; + with the todo flag, enter the todo of the switched topic after the + switch. - `identifier`: the user input — a branch name, a topic slug, or their - prefix + `identifier`: the user input — a branch name, a topic slug, or + their prefix + `todo`: True enters the todo of the switched topic `year`: optional year as four digits; None means the current year `result`: one line describing the outcome - Apply the `click` practice for the numbered selection prompt and the - non-interactive detection. + Apply the `click` practice for the numbered selection prompt and + the non-interactive detection. Apply the `refs-and-switching` practice for the checkout and remote-tracking branch patterns. Algorithm: - 1. Resolve the candidates via `resolve_switch_candidates` - 2. No candidate -> clean error with a hint to the board - 3. One candidate -> take it; several -> print the numbered list with - statuses and prompt for a number, or fail with the list when no - interactive input is available - 4. Already on the hosting branch -> idempotent success, no mutation, no - cleanliness probe - 5. A mutation is needed -> probe the working tree cleanliness first via - `is_working_tree_clean`; a dirty tree is a clean error naming the - reason and the next step — commit or stash the working copy before + 1. `todo` without an interactive terminal -> clean error before any switching - 6. Local host -> check out the branch via `checkout_local_branch`; - remote-only host -> create the local branch from the remote-tracking - ref via `create_branch_from_remote_tracking` + 2. Resolve the candidates via `resolve_switch_candidates`; none -> + clean error with a hint to the board; several -> the numbered + list with statuses and the number prompt, or the failure with + the list without interactive input + 3. `todo` and the chosen candidate hosts no topic -> clean error — + switching creates nothing + 4. Already on the hosting branch -> idempotent success without + mutation; with `todo` the entry still runs + 5. A mutation is needed -> probe the working tree cleanliness + first via `is_working_tree_clean`; a dirty tree is a clean + error; local host -> check out the branch via + `checkout_local_branch`; remote-only host -> create the local + branch from the remote-tracking ref via + `create_branch_from_remote_tracking` + 6. With `todo` -> enter the todo of the topic via + `enter_topic_todo` 7. Return the single result line Requirements: @@ -284,111 +307,172 @@ Annotations: | - The result is exactly one line Constraints: + - Do not create a topic for a branch without one + - Do not commit the todo write - Do not manage the stages of the hosting pipeline — continuation belongs to the pipeline itself - Do not return to the previous branch — the switch is the outcome -"ensure_topic(identifier: str, year: str | None = None) -> result: str": +"ensure_topic(identifier: str, todo: bool = False, year: str | None = None) -> result: str": location: ensuring.py annotations: | - Bring the repository onto the requested work, creating it when nothing - hosts the identifier. + Bring the repository onto the requested work, creating it when + nothing hosts the identifier; with the todo flag, enter the todo of + the work after the switch or the creation. - `identifier`: the user input — a branch name, a topic slug, or their - prefix + `identifier`: the user input — a branch name, a topic slug, or + their prefix + `todo`: True enters the todo of the work `year`: optional year as four digits; None means the current year `result`: one line describing the outcome - Apply the `click` practice for the interactive moments inherited from - the two orchestrations: the numbered candidate selection and the - creation re-ask cycle with the non-interactive detection. + Apply the `click` practice for the interactive moments inherited + from the switch orchestration. Apply the `topic-paths` practice for the slug and topic-directory - patterns of the creation fallback. + patterns of the creation. Apply the `refs-and-switching` practice for the checkout and create-and-switch patterns. Algorithm: - 1. Resolve the candidates via `resolve_switch_candidates` - 2. No candidate -> create fresh work via `create_topic` with - `identifier` as the branch name — the occupancy oracles, the re-ask - cycle, and the idempotent current-branch success belong to it - 3. Otherwise -> the switch procedure: the candidate choice, the - idempotent already-on-host confirmation, the cleanliness probe, and - the local checkout or the remote-tracking branch creation + 1. `todo` without an interactive terminal -> clean error before any + action + 2. Resolve the candidates via `resolve_switch_candidates` + 3. No candidate -> the fast creation: normalize `identifier` into a + slug via `normalize_topic_slug`; an empty slug or an occupancy + conflict — the oracles `check_branch_occupancy` and + `check_slug_occupancy` — is a clean error; create the branch + named as entered from the current HEAD and switch to it via + `create_and_switch_branch`; create the topic directory of the + year via `ensure_topic_dir`; with `todo` enter the todo of the + fresh topic via `enter_topic_todo` — the entry starts only after + the switch + 4. Otherwise -> the switch procedure via `switch_topic` without the + entry; with `todo`, take the hosted topic of the switched work — + the resolution candidate of step 2 whose branch is the current + branch read via `resolve_current_branch_name` (a remote-tracking + candidate matches by its short name) — and: a hosted topic + exists -> enter its todo via `enter_topic_todo`; the hosting + branch hosts no topic -> an empty slug of its name is a clean + error, otherwise create the topic directory of the year via + `ensure_topic_dir`, then enter the todo of the fresh topic via + `enter_topic_todo` + 5. Return the single result line Requirements: - - Creation happens only at zero candidates — a resolvable identifier - never creates anything - - The result is exactly one line + - Creation happens only at zero candidates — a resolvable + identifier never creates anything + - The creation always starts from the current HEAD — the + configuration base is never read here + - With `todo`, no step follows the todo write - Every mutation is local — no network, no fetch, no push Constraints: - - Do not alter the switch-only contract of `switch_topic` — the topics - switch command keeps its stricter behavior + - Do not ask about publication — the fast process publishes + nothing - Do not manage the stages of the hosting pipeline — continuation belongs to the pipeline itself -"create_topic(branch_name: str, year: str | None = None, todo: str | None = None) -> result: str": +"create_topic(branch_name: str, base_ref: str, todo: str | None = None, publish: bool = False, commit_message: str | None = None, year: str | None = None) -> result: str": location: creation.py annotations: | - Create fresh work — a branch with the name as entered, its topic - directory of the year, and an optional multi-line todo. + Create fresh work — a branch off an explicit base with the name as + entered, its topic directory of the year, and an optional + multi-line todo. `branch_name`: the branch name as entered by the user + `base_ref`: the base revision the branch starts from — any revision + string, resolved as git resolves it + `todo`: the todo text; None or an empty string means no value + `publish`: True takes the publication path without the ask + `commit_message`: the message template; None applies the built-in + default `year`: optional year as four digits; None means the current year - `todo`: optional multi-line todo of the fresh work; None or an empty - string writes no todo.md `result`: one line describing the outcome - Apply the `click` practice for the re-ask prompt and the non-interactive - detection. + Apply the `click` practice for the publication ask and the + non-interactive detection. + Apply the `editor-entry` practice for the editor session. Apply the `topic-paths` practice for the slug, existence, directory creation, and todo-file path patterns. - Apply the `refs-and-switching` practice for the create-and-switch - pattern. + Apply the `refs-and-switching` practice for the checkout pattern. Algorithm: - 1. Normalize `branch_name` into a slug via `normalize_topic_slug` - 2. Empty slug -> input error: print the reason, prompt for a new name - on an interactive terminal and restart, or fail with the reason + 1. Preflight, read-only and before any input: normalize + `branch_name` into a slug via `normalize_topic_slug`; an empty + slug is an input error; the occupancy oracles + `check_branch_occupancy` and `check_slug_occupancy` report a + conflict; the current branch — read via + `resolve_current_branch_name` — hosting the same slug is a + conflict; resolve `base_ref` into its commit via + `resolve_ref_commit` — every conflict is a clean error with a + hint to the board + 2. Resolve the todo: a value given is the todo; without a value, an + interactive terminal opens the editor session via `edit_text`, + its cancellation leaves no todo, a non-interactive terminal is a + clean error naming the value option — before any mutation + 3. `publish` without a resolved todo -> clean error asking for the + todo, before any mutation + 4. The publication ask — interactive terminal, `publish` not set, + and a todo resolved: the answer chooses the path; no ask otherwise - 3. The current branch — read via `resolve_current_branch_name` — hosts - the same slug -> the idempotent path: a non-empty `todo` writes the - topic todo file todo.md — the path resolved via `resolve_topic_file` - — of the ensured topic directory; no `todo` is a success without - mutation; no occupancy check, no switch - 4. `check_branch_occupancy` reports a conflict -> print the reason with - a hint to the board, prompt for a new name on an interactive terminal - and restart, or fail otherwise - 5. Free name -> create the branch named exactly as entered and switch - to it via `create_and_switch_branch`, create the topic directory - via `ensure_topic_dir` of the year, and a non-empty `todo` writes - the todo file todo.md — the path resolved via `resolve_topic_file` - — of the topic directory - 6. Return the single result line + 5. The normal path: create the branch at the base commit via + `create_branch_at_commit` and switch to it via + `checkout_local_branch`, create the topic directory of the year + via `ensure_topic_dir`, and write the todo file todo.md — the + path resolved via `resolve_topic_file` — when a todo resolved; + the write is the last action of the path + 6. The publication path: delegate to `publish_topic` with the + name, the todo, the base, the template, and the year + 7. Return the single result line Requirements: - - The branch keeps the name as entered; the topic directory takes the - slug — the two may deliberately differ - - The todo.md file carries `todo` as entered plus a single trailing - newline, encoded UTF-8 — empty lines inside the text stay as entered - - The todo.md file is written only when a non-empty `todo` is given — - None or an empty string never creates and never overwrites it; an - explicit `todo` creates the file or overwrites it + - Every decision — preflight, todo, ask — precedes the first + mutation + - The todo.md file carries the todo as entered plus a single + trailing newline, encoded UTF-8 — empty lines inside the text + stay as entered + - The todo.md file is written only when a todo resolved - The topic directory exists before the todo.md file is written - - An aborted re-ask leaves the repository untouched - - On the fresh path the branch is created and switched to before the - topic directory and the todo.md file are written — a filesystem - failure of the writes leaves the caller on the new branch with the - directory or the todo missing, reported as a clean error - - The caller stays on the new branch + - The branch keeps the name as entered; the topic directory takes + the slug + - The caller stays on the new branch on the normal path Constraints: - Do not validate branch-name characters — git owns name validity - - Do not auto-pick suffixed names on a conflict — the user re-asks or - aborts - - Do not write artifact files other than the topic todo file inside - the topic directory + - Do not auto-pick suffixed names on a conflict + - Do not write artifact files other than the topic todo file + inside the topic directory + +"enter_topic_todo(topic: str, year: str | None = None) -> written: bool": + location: creation.py + annotations: | + Enter the todo of a topic — the editor session with the topic's + todo.md and the write of the saved text, without a commit. + + `topic`: topic input — a branch name or an already-normalized slug + `year`: optional year as four digits; None means the current year + `written`: True when the saved text was written; False when the + entry was cancelled + + Apply the `editor-entry` practice for the editor session pattern. + Apply the `topic-paths` practice for the todo-file path pattern. + + Algorithm: + 1. Resolve the todo.md path of the topic via `resolve_topic_file`; + an existing file provides the initial text + 2. Open the editor session via `edit_text` with the initial text + 3. A cancelled entry -> False — the file stays untouched + 4. The saved text -> write todo.md as entered plus a single + trailing newline, encoded UTF-8, without a commit -> True + + Requirements: + - The write is the last action — nothing follows it + - The topic directory exists — directory creation belongs to the + caller + + Constraints: + - Do not create the topic directory + - Do not commit the write "check_branch_occupancy(branch_name: str, slug: str, year: str | None = None) -> conflict: str | None": location: creation.py @@ -424,87 +508,77 @@ Annotations: | - Do not resolve remote state over the network — the local inventory only -"publish_topic(branch_name: str, todo: str, base_ref: str, commit_message: str, year: str | None = None) -> result: str": +"publish_topic(branch_name: str, todo: str, base_ref: str, commit_message: str | None = None, year: str | None = None) -> result: str": location: publishing.py annotations: | - Create fresh work and publish it — a branch off an explicit base carrying - one commit with the topic todo, pushed to origin, while the caller stays - on their branch. + Create fresh work and publish it — a branch off an explicit base + carrying one commit with the topic todo, pushed to origin, while + the caller stays on their branch. `branch_name`: the branch name as entered by the user - `todo`: the multi-line todo of the fresh work — written to todo.md as - entered plus a single trailing newline; required and non-empty, - an empty todo is a clean error asking for it + `todo`: the multi-line todo of the fresh work — written to todo.md + as entered plus a single trailing newline; required and + non-empty, an empty todo is a clean error asking for it `base_ref`: the base revision the branch starts from — any revision string, resolved as git resolves it - `commit_message`: the commit message template — the {slug} placeholder - is replaced with the topic slug; a template without - the placeholder is used as is + `commit_message`: the commit message template — the {slug} + placeholder is replaced with the topic slug; None + applies the built-in default `year`: optional year as four digits; None means the current year `result`: one line describing the outcome - Apply the `click` practice for the re-ask prompt and the - non-interactive detection. Apply the `topic-paths` practice for the slug, current-branch, and todo-file path patterns. - Apply the `refs-and-switching` practice for the occupancy inventory and - tree-reading patterns. - Apply the `publishing` practice for the quarantined commit building, - branch planting, publication, and rollback patterns. + Apply the `refs-and-switching` practice for the occupancy inventory + and tree-reading patterns. + Apply the `publishing` practice for the quarantined commit + building, branch planting, publication, and rollback patterns. Algorithm: 1. Normalize `branch_name` into a slug via `normalize_topic_slug` - 2. Empty slug -> input error: print the reason, prompt for a new name on - an interactive terminal and restart the fast cycle, or fail with the - reason otherwise - 3. An empty `todo` -> clean error asking for the todo, before any - mutation - 4. The current branch — read via `resolve_current_branch_name` — hosts - the same slug -> clean error without mutations: the fast path is only - for fresh work - 5. Probe the occupancy oracles in order — `check_branch_occupancy` - first, then `check_slug_occupancy`; the first conflict wins -> print - the reason with a hint to the board, prompt for a new name - on an interactive terminal and restart the fast cycle, or fail - otherwise - 6. `origin_configured` reads False -> clean error with the reason - 7. Resolve `base_ref` into its commit via `resolve_ref_commit` — an + 2. An empty slug, an empty `todo`, or the current branch — read via + `resolve_current_branch_name` — hosting the same slug -> clean + error, before any mutation + 3. The occupancy oracles `check_branch_occupancy` and + `check_slug_occupancy` report a conflict -> clean error with a + hint to the board + 4. `origin_configured` reads False -> clean error with the reason + 5. Resolve `base_ref` into its commit via `resolve_ref_commit` — an unresolvable base is a clean error with the reason, before any mutation - 8. Build the publication commit via `commit_file_on_base` — the parent - commit, the todo.md path resolved via `resolve_topic_file` as a - repository-root-relative posix string, the todo content, and the - applied `commit_message` - 9. Plant the branch named exactly as entered via + 6. Build the publication commit via `commit_file_on_base` — the + parent commit, the todo.md path resolved via `resolve_topic_file` + as a repository-root-relative posix string, the todo content, + and the applied `commit_message` + 7. Plant the branch named exactly as entered via `create_branch_at_commit` - 10. Publish via `push_branch`; a failed publication deletes the branch - via `delete_local_branch` and surfaces one clean error carrying the - reason - 11. Return the single result line + 8. Publish via `push_branch`; a failed publication deletes the + branch via `delete_local_branch` and surfaces one clean error + carrying the reason + 9. Return the single result line Requirements: - The working copy, the index, and HEAD stay untouched — the caller - stays on their branch whatever its state; a dirty tree and a detached - HEAD do not interfere + stays on their branch whatever its state; a dirty tree and a + detached HEAD do not interfere - Every decision is made before the first mutation; the mutation sequence is the commit build, the branch plant, and the push - - The push to origin is the only network operation; no fetch ever - happens - - A failed publication rolls back fully — the planted branch is deleted - and nothing else was ever mutated; a re-run after the cause is - resolved succeeds + - The push to origin is the only network operation of the path; no + fetch ever happens + - A failed publication rolls back fully — the planted branch is + deleted and nothing else was ever mutated; a re-run after the + cause is resolved succeeds - The todo.md file carries `todo` as entered plus a single trailing newline, encoded UTF-8 — the sole artifact of the topic directory - The result is exactly one line Constraints: - Do not validate branch-name characters — git owns name validity - - Do not auto-pick suffixed names on a conflict — the user re-asks or - aborts + - Do not auto-pick suffixed names on a conflict - Do not write artifact files other than the topic todo file inside the topic directory - - Do not switch the caller's branch — the caller keeps their working - state + - Do not switch the caller's branch — the caller keeps their + working state "check_slug_occupancy(slug: str, year: str | None = None) -> conflict: str | None": location: creation.py @@ -546,12 +620,127 @@ Annotations: | - Do not probe the working copy — a topic living only on disk is the file oracle's domain +"DeleteTarget(topic: str, branch: str | None, remote: str | None, has_dir: bool)": + location: deletion.py + annotations: | + One identified deletion target — a topic with its hosting refs and + directory. + + `topic`: the topic slug + `branch`: the hosting local branch name, or None + `remote`: the hosting origin twin name, or None + `has_dir`: True when the topic directory of the year exists on + disk + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "topic -> str": | + The topic slug. + "branch -> str | None": | + The hosting local branch name, or None. + "remote -> str | None": | + The hosting origin twin name, or None. + "has_dir -> bool": | + True when the topic directory of the year exists on disk. + +"resolve_delete_targets(identifiers: list[str], year: str | None = None) -> targets: list[DeleteTarget]": + location: deletion.py + annotations: | + Resolve deletion identifiers into targets — every check before any + removal. + + `identifiers`: the user inputs — branch names, topic slugs, or + their prefixes + `year`: optional year as four digits; None means the current year + `targets`: one `DeleteTarget` per identified topic, in identifier + order + + Apply the `topic-paths` practice for the year and tree patterns. + Apply the `refs-and-switching` practice for the inventory pattern. + + Algorithm: + 1. Resolve the year — `year` when given, otherwise the current year + via `current_year`; collect the branch inventory via + `list_branch_refs` and the topics of the year on disk via + `collect_history_tree` — a topic + directory no branch hosts is a targetable topic + 2. Each identifier resolves through the tiers: the exact branch + name, the exact topic slug, the prefixes of both — the first + non-empty tier wins + 3. No match -> clean error naming the identifier; several matches + -> clean error listing the candidates — no interactive choice + 4. A hosting ref whose name does not normalize into the topic + slug — a branch carrying a topic merged from another branch — + is not part of the target; when no eligible hosting ref + remains, the identifier is a clean error naming the topic and + the hosting branch — merged work is removed from the hosting + branch's tree, not deleted here. A topic directory no branch + hosts stays targetable (no refs, directory only) + 5. A local branch and its origin twin form one target; identifiers + naming one topic collapse into it + 6. The current branch — read via `resolve_current_branch_name` — + hosting any target -> clean error asking to switch away first + 7. Return the targets + + Requirements: + - Read-only — nothing is removed, created, or switched + - All-or-nothing — any unresolved or ambiguous identifier cancels + the whole call + - A topic hosted only by refs that are not its own topic branch + is a clean error naming the hosting branch — merged work is + out of scope + + Constraints: + - Do not resolve remote state over the network — the local + inventory only + - Do not offer an interactive selection + +"delete_topics(targets: list[DeleteTarget], year: str | None = None) -> result: str": + location: deletion.py + annotations: | + Execute the confirmed deletion of the targets — the local branch, + the origin twin, and the topic directory. + + `targets`: the resolved targets — the caller has confirmed them + `year`: optional year as four digits; None means the current year + `result`: one line describing the outcome + + Apply the `deleting` practice for the symmetric removal and the + restore-on-failure patterns. + + Algorithm: + 1. Per target, in order: a local branch exists -> capture its + commit via `resolve_ref_commit` first, then delete the local + branch via `delete_local_branch` + 2. An origin twin exists -> delete it on origin via + `delete_remote_branch`; a failed deletion restores the local + branch at the captured commit via `create_branch_at_commit` and + surfaces one clean error — the targets removed before the + failure stay removed + 3. A target with only an origin twin -> delete it on origin via + `delete_remote_branch` + 4. A target with a directory -> remove the topic directory via + `remove_topic_dir` + 5. Return the single result line + + Requirements: + - The deletion is unconditional — no merge checks; the + confirmation belongs to the caller + - The deletion push is a network operation; no fetch ever happens + + Constraints: + - Do not re-resolve the identifiers — the caller passes resolved + targets + - Do not touch topics outside `targets` + --- Author: Goga CreatedAt: 29/08/26 Description: | - The topics domain — the cross-branch topic inventory with todo summaries, - switch resolution and orchestration, fresh-work creation with an optional - todo, fast creation with publication, and the combined ensure - orchestration. + The topics domain — the cross-branch topic inventory with todo + summaries, switching with the optional todo entry, creation off an + explicit base with preflight and publication ask, fast creation with + publication, the ensure orchestration of the fast process, the todo + entry of a topic, and identified-topic deletion. diff --git a/goga/topics/editor/.usages/editor-entry.md b/goga/topics/editor/.usages/editor-entry.md new file mode 100644 index 00000000..bd435a1a --- /dev/null +++ b/goga/topics/editor/.usages/editor-entry.md @@ -0,0 +1,36 @@ +# topics/editor — the editor entry session + +How to collect or edit a multi-line text through the user's external +editor with the `goga.topics.editor` facade. For consumers that need +interactive text entry in the topics domain: the topics orchestrations. + +The session is the only interactive moment — the caller decides when it +happens. Cancellation is a normal outcome, not an error: the caller +continues as without the entry. + +## Entering a fresh text + + from goga.topics.editor import edit_text + + text = edit_text() + if text is None: + ... # cancelled — continue as without the entry + +- The editor resolves $VISUAL, then $EDITOR, then vi. +- An empty saved file (or only blank lines) cancels the entry. +- The saved text comes back as entered — the caller owns any write. + +## Editing an existing text + + text = edit_text(initial="Fix retries.\n\nIgnore the cap.") + +- The session starts from the existing content; saving without changes + cancels the entry — the existing text is the caller's to keep. + +## Errors + +- A missing interactive terminal, a failed editor run, or an + interrupted session raise a clean error before any mutation — the + caller's state is untouched. +- In tests never launch a real editor: mock the editor with a script + that writes into the file, leaves it, clears it, or exits non-zero. diff --git a/goga/topics/editor/CODEMANIFEST b/goga/topics/editor/CODEMANIFEST new file mode 100644 index 00000000..ae384f12 --- /dev/null +++ b/goga/topics/editor/CODEMANIFEST @@ -0,0 +1,79 @@ +Usages: + convention: .goga/usages/conventions.md + editor: .goga/usages/cooks/editor.md + click: .goga/usages/cooks/click.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + Use the `editor` practice for the entry protocol: the editor chain, + the temporary file, the cancellation rule, the error cases, and the + test mock of the editor. + Use the `click` practice for the editor-launching facility of the + library and the clean error style. + + This cell owns access to the external editor for the topics domain: + the interactive collection and editing of a multi-line text through + the editor resolved by the entry protocol. The session is the single + interactive surface — every orchestration moment, the decision when + an entry happens, belongs to the caller. It is environment access, + not topic logic. Use relative imports. + +--- + +"edit_text(initial: str | None = None) -> text: str | None": + location: entry.py + annotations: | + Collect or edit a multi-line text in the external editor. + + `initial`: the text the session starts from — the existing content + when an entry edits; None or an empty string starts from + an empty entry + `text`: the saved text as entered, or None when the entry was + cancelled + + Apply the `editor` practice for the entry protocol. + Apply the `click` practice for the editor-launching facility and + the clean error style. + Apply the `convention` practice for docstring style and + intra-package imports. + + Algorithm: + 1. No interactive terminal -> clean error, before anything else + 2. Print the hint to the terminal — an empty saved file cancels the + entry — before the editor starts + 3. Run the editor session over a temporary file — empty for a + fresh entry, carrying `initial` plus a missing trailing newline + (the editor facility of the click library appends one to its + prefill) otherwise — per the `editor` practice + 4. A failed editor run or an interrupted session -> clean error, + nothing mutated + 5. The saved content blank or equal to the prefilled text -> + cancellation: return None + 6. Return the saved text as entered + + Requirements: + - The session touches nothing but its temporary file — no project + state is read or mutated + - The hint precedes the editor start + - The text is returned as entered — no normalization, no trailing + newline added + + Constraints: + - Do not place hint comments inside the file itself + - Do not validate the content — every non-blank text is accepted + - Do not write the result anywhere — the write belongs to the + caller + +--- + +Author: Goga +CreatedAt: 02/09/26 +Description: | + Access to the external editor for the topics domain — the + interactive multi-line text entry session. diff --git a/goga/topics/git/.usages/deleting.md b/goga/topics/git/.usages/deleting.md new file mode 100644 index 00000000..e66380cf --- /dev/null +++ b/goga/topics/git/.usages/deleting.md @@ -0,0 +1,43 @@ +# topics/git — deleting a branch on origin + +How to remove a branch from the origin remote (and its local twin) with +the `goga.topics.git` facade. For consumers that tear down published +work: the topics domain, higher-level orchestration. + +The deletion is unconditional after the caller's confirmation — no +merge checks, no force flags. Every policy decision — when deletion is +allowed, what a failed deletion means — belongs to the caller. + +## Deleting a branch on origin + + from goga.topics.git import delete_remote_branch + + delete_remote_branch("feature-foo") # gone from origin + +- The deletion push is a network operation of the cell — the other one + publishes branches; no fetch ever happens. +- The local branch and the working copy stay untouched. +- A git failure surfaces as a clean error carrying the reason. + +## Deleting a local and remote pair with restore + + from goga.topics.git import ( + create_branch_at_commit, + delete_local_branch, + delete_remote_branch, + resolve_ref_commit, + ) + + commit = resolve_ref_commit("feature-foo") # capture BEFORE deletion + delete_local_branch("feature-foo") + try: + delete_remote_branch("feature-foo") + except Exception: + create_branch_at_commit("feature-foo", commit) # restore + raise + +- Capture the commit before the local deletion — after it the name no + longer resolves. +- A failed remote deletion leaves the pair recoverable: the local + branch is restored at the captured commit and the error propagates — + the caller decides the reporting. diff --git a/goga/topics/git/.usages/publishing.md b/goga/topics/git/.usages/publishing.md index 6ffa916b..5a35138f 100644 --- a/goga/topics/git/.usages/publishing.md +++ b/goga/topics/git/.usages/publishing.md @@ -7,7 +7,8 @@ their branch: the topics domain, higher-level orchestration. The quarantined path never touches the working copy, the repository index, or HEAD — a dirty tree and a detached HEAD do not interfere. The push to -origin is the only network operation; everything else is local. Every +origin and the deletion push are the network operations of the cell; +everything else is local. Every policy decision — when to roll back, what a conflict means — belongs to the caller. diff --git a/goga/topics/git/CODEMANIFEST b/goga/topics/git/CODEMANIFEST index bba42280..9a89ffe8 100644 --- a/goga/topics/git/CODEMANIFEST +++ b/goga/topics/git/CODEMANIFEST @@ -11,7 +11,8 @@ Usages: the GIT_INDEX_FILE env var of a single invocation — read-tree of the base, hash-object -w of the blob, update-index --add, write-tree, commit-tree -p; creating a branch at a commit and deleting a branch via - update-ref; pushing a branch to origin with -u). A single-file content + update-ref; pushing a branch to origin with -u), and remote deletion + (deleting a branch on origin via push origin --delete). A single-file content read decodes UTF-8 explicitly and maps every git failure to None — the content is display data; every other invocation propagates its git error. Mock the subprocess call in tests per `convention`. @@ -30,10 +31,11 @@ Annotations: | branch mutations — checking out a local branch, creating a local branch from a remote-tracking ref, creating and switching to a new branch, creating a branch at a commit without switching, deleting a local branch, - and the working-tree cleanliness probe. The quarantined creation path - builds its commits in a temporary index isolated from the working copy — - the working tree, the index, and HEAD stay untouched; pushing a branch to - origin is the single network operation of the domain. It is environment + deleting a branch on the origin remote, and the working-tree cleanliness + probe. The quarantined creation path builds its commits in a temporary + index isolated from the working copy — the working tree, the index, and + HEAD stay untouched; two network operations exist — publishing a branch + to origin and deleting a branch on origin. It is environment access, not topic logic — every decision belongs to the caller. All git access flows through the `git` practice; mock the subprocess call in tests per `convention`. Use relative imports. @@ -363,6 +365,29 @@ Annotations: | - Do not decide whether deletion is safe — the caller owns the rollback policy +"delete_remote_branch(branch_name: str)": + location: publish.py + annotations: | + Delete a branch on the origin remote. + + `branch_name`: the short name of the branch on origin + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git to delete the branch on the origin remote + 2. A git failure surfaces as a clean error carrying the reason + + Requirements: + - Exactly the named branch — no other branches or tags + - The deletion is a network operation — the local branch and the + working copy stay untouched + + Constraints: + - Do not retry or roll back — the caller owns the failure policy + "push_branch(branch_name: str)": location: publish.py annotations: | @@ -380,7 +405,7 @@ Annotations: | rollback Requirements: - - The push is the only network operation of the topics domain + - The push is a network operation of the topics domain - The local branch stays in the repository after the push Constraints: @@ -417,5 +442,5 @@ Author: Goga CreatedAt: 29/08/26 Description: | Git access for the topics domain — branch refs, ref tree reading, - revision resolution, host-side branch mutations, and quarantined branch - construction with publication. + revision resolution, host-side branch mutations, quarantined branch + construction with publication, and remote branch deletion. From dffac283f49e060965e0b38c8ffe29b76e7d1911 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 20:03:36 +0000 Subject: [PATCH 180/229] feat: add goga/topics/editor cell with edit_text --- goga/topics/editor/__init__.py | 12 +++ goga/topics/editor/entry.py | 83 ++++++++++++++++ tests/topics/editor/__init__.py | 0 tests/topics/editor/test_entry.py | 152 ++++++++++++++++++++++++++++++ 4 files changed, 247 insertions(+) create mode 100644 goga/topics/editor/__init__.py create mode 100644 goga/topics/editor/entry.py create mode 100644 tests/topics/editor/__init__.py create mode 100644 tests/topics/editor/test_entry.py diff --git a/goga/topics/editor/__init__.py b/goga/topics/editor/__init__.py new file mode 100644 index 00000000..5db5c4c3 --- /dev/null +++ b/goga/topics/editor/__init__.py @@ -0,0 +1,12 @@ +"""Editor-access cell for the topics domain. + +The interactive multi-line text entry session through the external +editor resolved by the ``editor`` practice — the single interactive +surface of the topics domain. It is environment access, not topic +logic — every decision about when an entry happens belongs to the +caller. +""" + +from .entry import edit_text + +__all__: list[str] = ["edit_text"] diff --git a/goga/topics/editor/entry.py b/goga/topics/editor/entry.py new file mode 100644 index 00000000..5ee1a19a --- /dev/null +++ b/goga/topics/editor/entry.py @@ -0,0 +1,83 @@ +"""The external-editor entry session of the topics-domain editor cell. + +The entity declared in the cell CODEMANIFEST with +``location: entry.py``: the interactive collection and editing of a +multi-line text through the editor resolved by the ``editor`` +practice. The session is the single interactive surface of the topics +domain — every orchestration moment, the decision when an entry +happens, belongs to the caller. It is environment access, not topic +logic. +""" + +from __future__ import annotations + +import sys + +import click + +_HINT = "Enter the text. An empty or unchanged file cancels the entry." + + +def edit_text(initial: str | None = None) -> str | None: + """Collect or edit a multi-line text in the external editor. + + Args: + initial: The text the session starts from — the existing content + when an entry edits; None or an empty string starts from an + empty entry. + + Returns: + The saved text as entered, or None when the entry was cancelled. + + Algorithm: + 1. No interactive terminal -> a clean error, before anything + else + 2. Print the hint to the terminal — an empty saved file cancels + the entry — before the editor starts + 3. Run the editor session over a temporary file — empty for a + fresh entry, carrying ``initial`` plus a missing trailing + newline (the editor facility of the click library appends + one to its prefill) otherwise + 4. A failed editor run or an interrupted session -> a clean + error, nothing mutated + 5. The saved content blank or equal to the prefilled text -> + cancellation: return None + 6. Return the saved text as entered + + Requirements: + The session touches nothing but its temporary file — no project + state is read or mutated. + + The hint precedes the editor start. + + The text is returned as entered — no normalization, no trailing + newline added. + + Constraints: + Do not place hint comments inside the file itself. + + Do not validate the content — every non-blank text is accepted. + + Do not write the result anywhere — the write belongs to the + caller. + + Raises: + click.ClickException: No interactive terminal is attached, or + the editor run failed — the caller's state is untouched. + """ + if not sys.stdin.isatty(): + raise click.ClickException("the entry needs an interactive terminal") + + click.echo(_HINT) + + start = initial or "" + + if start and not start.endswith("\n"): + start += "\n" + + saved = click.edit(text=start) + + if saved is None or not saved.strip() or saved == start: + return None + + return saved diff --git a/tests/topics/editor/__init__.py b/tests/topics/editor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/topics/editor/test_entry.py b/tests/topics/editor/test_entry.py new file mode 100644 index 00000000..a006312b --- /dev/null +++ b/tests/topics/editor/test_entry.py @@ -0,0 +1,152 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/editor/CODEMANIFEST`` with ``location: entry.py``: + +- ``edit_text(initial=None)`` — the interactive multi-line text entry + session in the external editor + +The editor is mocked with a shell script exported as ``$EDITOR`` per +the ``editor`` practice and the TTY detection with a ``sys.stdin`` +stand-in — a real editor never launches in tests. The session's +temporary file lives in the system temp directory and is unlinked by +the editor facility, so the working tree of the test repository stays +untouched. +""" + +from __future__ import annotations + +import inspect +import sys +import typing +from pathlib import Path +from unittest import mock + +import click +import pytest +from goga.topics.editor import edit_text + +# --- Contract tests --- + + +class TestEntryContract: + def test_edit_text_is_importable_from_the_cell_facade(self) -> None: + """``edit_text`` lives on the cell facade as the only export.""" + import goga.topics.editor as cell + + assert cell.edit_text is edit_text + assert cell.__all__ == ["edit_text"] + + def test_declared_signature(self) -> None: + """The routine takes exactly the declared parameter.""" + assert list(inspect.signature(edit_text).parameters) == ["initial"] + + def test_parameter_is_positional_or_keyword_with_contract_hints(self) -> None: + """``initial`` binds positionally and by keyword, defaults to None.""" + parameters = inspect.signature(edit_text).parameters + parameter = parameters["initial"] + + assert parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + assert parameter.default is None + assert typing.get_type_hints(edit_text) == {"initial": str | None, "return": str | None} + + signature = inspect.signature(edit_text) + signature.bind() + signature.bind("text") + signature.bind(initial="text") + + +# --- Editor and terminal stand-ins --- + + +def _editor_script(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, body: str) -> None: + """Export ``$EDITOR`` as an executable shell script running ``body``.""" + editors = tmp_path / "editors" + editors.mkdir(exist_ok=True) + + script = editors / "editor-mock.sh" + script.write_text(f"#!/bin/sh\n{body}\n", encoding="utf-8") + script.chmod(0o755) + + monkeypatch.delenv("VISUAL", raising=False) + monkeypatch.setenv("EDITOR", str(script)) + + +def _tty(monkeypatch: pytest.MonkeyPatch, isatty: bool) -> None: + """Stand in for ``sys.stdin`` with a pinned TTY answer.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": isatty})) + + +def _repo(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """An empty repository working tree as the current directory.""" + repo = tmp_path / "repo" + repo.mkdir() + monkeypatch.chdir(repo) + + return repo + + +def _tree_of(repo: Path) -> list[str]: + """Every path of the working tree, relative and sorted.""" + return sorted(str(path.relative_to(repo)) for path in repo.rglob("*")) + + +# --- Logic tests --- + + +class TestEditText: + def test_edit_text_saves_text_as_entered(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """The saved text comes back verbatim — interior blank line kept, nothing trimmed.""" + _editor_script(monkeypatch, tmp_path, "printf 'Fix retries.\\n\\nIgnore the cap.\\n' > \"$1\"") + _tty(monkeypatch, isatty=True) + + assert edit_text() == "Fix retries.\n\nIgnore the cap.\n" + + def test_edit_text_unchanged_save_cancels(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """An editor that never writes cancels the entry — the working tree untouched.""" + repo = _repo(monkeypatch, tmp_path) + _editor_script(monkeypatch, tmp_path, "exit 0") + _tty(monkeypatch, isatty=True) + + assert edit_text(initial="Old text.\n") is None + assert _tree_of(repo) == [] + + def test_edit_text_initial_without_newline_normalized( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """A prefill without a trailing newline is normalized before the equality check.""" + _tty(monkeypatch, isatty=True) + + _editor_script(monkeypatch, tmp_path, "exit 0") + assert edit_text(initial="Old text.") is None + + _editor_script(monkeypatch, tmp_path, "printf 'Old text.\\n' > \"$1\"") + assert edit_text(initial="Old text.") is None + + def test_edit_text_non_tty_clean_error(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """No terminal — a clean error before the hint and the editor launch.""" + marker = tmp_path / "editors" / "launched.marker" + marker.parent.mkdir(exist_ok=True) + _editor_script(monkeypatch, tmp_path, f"printf launched > '{marker}'") + _tty(monkeypatch, isatty=False) + + with pytest.raises(click.ClickException, match="interactive"): + edit_text("text") + + assert not marker.exists() + + def test_edit_text_failed_editor_clean_error(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """A non-zero editor exit is a clean error — no working-tree file touched.""" + repo = _repo(monkeypatch, tmp_path) + _editor_script(monkeypatch, tmp_path, "exit 3") + _tty(monkeypatch, isatty=True) + + with pytest.raises(click.ClickException, match="Editing failed"): + edit_text("text") + + assert _tree_of(repo) == [] + + def test_edit_text_blank_only_save_cancels(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """A saved file of only blank lines cancels the entry.""" + _editor_script(monkeypatch, tmp_path, "printf '\\n\\n \\n' > \"$1\"") + _tty(monkeypatch, isatty=True) + + assert edit_text() is None From d0a2d8f07573f87d5a8c5ea8d5f13aa4d729e5f0 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 20:09:41 +0000 Subject: [PATCH 181/229] feat: add delete_remote_branch to goga/topics/git --- goga/topics/git/__init__.py | 9 +++-- goga/topics/git/publish.py | 42 ++++++++++++++++++++++-- tests/topics/git/test_publish.py | 56 ++++++++++++++++++++++++++++++++ tests/topics/git/test_trees.py | 4 +-- 4 files changed, 103 insertions(+), 8 deletions(-) diff --git a/goga/topics/git/__init__.py b/goga/topics/git/__init__.py index de72181c..13fcd624 100644 --- a/goga/topics/git/__init__.py +++ b/goga/topics/git/__init__.py @@ -7,15 +7,17 @@ working-tree cleanliness probe — and the quarantined publication: resolving a revision into its commit, building one commit over a base through a temporary index without touching the working copy, planting and -deleting a branch without switching, pushing a branch to origin with -upstream binding, and the origin probe. It is environment access, not -topic logic — every decision belongs to the caller. +deleting a branch without switching, the two network operations of the +cell — pushing a branch to origin with upstream binding and deleting a +branch on the origin remote — and the origin probe. It is environment +access, not topic logic — every decision belongs to the caller. """ from .publish import ( commit_file_on_base, create_branch_at_commit, delete_local_branch, + delete_remote_branch, origin_configured, push_branch, resolve_ref_commit, @@ -37,6 +39,7 @@ "create_branch_at_commit", "create_branch_from_remote_tracking", "delete_local_branch", + "delete_remote_branch", "is_working_tree_clean", "list_branch_refs", "origin_configured", diff --git a/goga/topics/git/publish.py b/goga/topics/git/publish.py index dfed4f74..297c2af8 100644 --- a/goga/topics/git/publish.py +++ b/goga/topics/git/publish.py @@ -3,8 +3,9 @@ The entities declared in the cell CODEMANIFEST with ``location: publish.py``: revision resolution, the quarantined building of one commit that adds a single file on top of a parent commit, planting a -branch at a commit without switching, deleting a local branch, pushing a -branch to origin with upstream binding, and the strict origin probe. The +branch at a commit without switching, deleting a local branch, deleting a +branch on the origin remote, pushing a branch to origin with upstream +binding, and the strict origin probe. The quarantined path never touches the working copy, the repository index, or HEAD — a dirty tree and a detached HEAD do not interfere. Every git invocation follows the ``git`` practice. @@ -194,6 +195,41 @@ def delete_local_branch(branch_name: str) -> None: _run_git(["git", "update-ref", "-d", f"refs/heads/{branch_name}"]) +def delete_remote_branch(branch_name: str) -> None: + """Delete a branch on the origin remote. + + Args: + branch_name: The short name of the branch on origin. + + Algorithm: + 1. Ask git to delete the branch on the origin remote + 2. A git failure surfaces as a clean error carrying the reason + + Requirements: + Exactly the named branch — no other branches or tags. + + The deletion is a network operation — the local branch and the + working copy stay untouched. + + Constraints: + Do not retry or roll back — the caller owns the failure policy. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + deletion push itself (propagated raw — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + # The full refspec is load-bearing exactly as in ``push_branch``: a + # short name that starts with a dash (git accepts + # ``refs/heads/--mirror``, and the plant creates names verbatim) would + # be parsed as a push option — after ``--delete`` a bare ``--mirror`` + # does not name a branch anymore, and ``--repo`` or ``--all`` would act + # at all. The refspec can never start with a dash, so exactly the named + # branch goes. + _run_git(["git", "push", "origin", "--delete", f"refs/heads/{branch_name}"]) + + def push_branch(branch_name: str) -> None: """Publish a branch to the origin remote with upstream binding. @@ -206,7 +242,7 @@ def push_branch(branch_name: str) -> None: rollback Requirements: - The push is the only network operation of the topics domain. + The push is a network operation of the topics domain. The local branch stays in the repository after the push. diff --git a/tests/topics/git/test_publish.py b/tests/topics/git/test_publish.py index 943d8b97..b4bf5d3e 100644 --- a/tests/topics/git/test_publish.py +++ b/tests/topics/git/test_publish.py @@ -9,6 +9,8 @@ - ``create_branch_at_commit(branch_name, commit)`` — create a branch at a commit without switching to it - ``delete_local_branch(branch_name)`` — delete a local branch +- ``delete_remote_branch(branch_name)`` — delete a branch on the origin + remote - ``push_branch(branch_name)`` — publish the branch to origin with upstream binding - ``origin_configured()`` — the strict origin probe @@ -32,6 +34,7 @@ commit_file_on_base, create_branch_at_commit, delete_local_branch, + delete_remote_branch, origin_configured, push_branch, resolve_ref_commit, @@ -64,6 +67,7 @@ def test_entities_are_importable_from_the_cell_facade(self) -> None: assert cell.commit_file_on_base is commit_file_on_base assert cell.create_branch_at_commit is create_branch_at_commit assert cell.delete_local_branch is delete_local_branch + assert cell.delete_remote_branch is delete_remote_branch assert cell.push_branch is push_branch assert cell.origin_configured is origin_configured for name in ( @@ -71,6 +75,7 @@ def test_entities_are_importable_from_the_cell_facade(self) -> None: "commit_file_on_base", "create_branch_at_commit", "delete_local_branch", + "delete_remote_branch", "push_branch", "origin_configured", ): @@ -82,6 +87,7 @@ def test_declared_signatures(self) -> None: assert list(inspect.signature(commit_file_on_base).parameters) == ["base", "path", "content", "message"] assert list(inspect.signature(create_branch_at_commit).parameters) == ["branch_name", "commit"] assert list(inspect.signature(delete_local_branch).parameters) == ["branch_name"] + assert list(inspect.signature(delete_remote_branch).parameters) == ["branch_name"] assert list(inspect.signature(push_branch).parameters) == ["branch_name"] assert list(inspect.signature(origin_configured).parameters) == [] @@ -92,6 +98,7 @@ def test_parameters_are_positional_or_keyword_with_contract_hints(self) -> None: commit_file_on_base: {"base": str, "path": str, "content": str, "message": str, "return": str}, create_branch_at_commit: {"branch_name": str, "commit": str, "return": type(None)}, delete_local_branch: {"branch_name": str, "return": type(None)}, + delete_remote_branch: {"branch_name": str, "return": type(None)}, push_branch: {"branch_name": str, "return": type(None)}, origin_configured: {"return": bool}, } @@ -104,6 +111,14 @@ def test_parameters_are_positional_or_keyword_with_contract_hints(self) -> None: assert all(parameter.default is inspect.Parameter.empty for parameter in parameters.values()), routine assert typing.get_type_hints(routine) == declared, routine + def test_delete_remote_branch_callable_with_name(self) -> None: + """The routine binds as ``delete_remote_branch("name")`` and returns None.""" + run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): + result = delete_remote_branch("name") + + assert result is None + # --- Logic tests --- @@ -347,6 +362,47 @@ def test_push_branch_refspec_cannot_be_parsed_as_an_option(self) -> None: assert not refspec.startswith("-") +class TestDeleteRemoteBranch: + def test_delete_remote_branch_pushes_full_refspec(self) -> None: + """One deletion push addressing the branch through its full ref. + + A short name that starts with a dash would be parsed as a push + option — after ``--delete`` a bare ``--mirror`` does not name a + branch anymore. The ``refs/heads/...`` refspec can never start + with a dash, so exactly the named branch goes. + """ + run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): + delete_remote_branch("feature-foo") + + assert run.call_count == 1 + assert run.call_args.args[0] == [ + "git", + "push", + "origin", + "--delete", + "refs/heads/feature-foo", + ] + + def test_delete_remote_branch_git_failure_propagates_raw(self) -> None: + """A rejected deletion push raises raw — the cell never wraps. + + The caller (``delete_topics``) owns the failure policy: it restores + the local branch at the captured commit and renders one clean + error, so a wrap here would bury the git reason under a second + exception layer. + """ + failure = subprocess.CalledProcessError(1, ["git", "push", "origin"], stderr=b"deny") + + with ( + mock.patch("goga.topics.git.publish.subprocess.run", side_effect=failure), + pytest.raises(subprocess.CalledProcessError) as raised, + ): + delete_remote_branch("feature-foo") + + assert raised.value is failure + + class TestOriginConfigured: def test_origin_configured_true_when_configured(self) -> None: """A readable origin remote URL reads True.""" diff --git a/tests/topics/git/test_trees.py b/tests/topics/git/test_trees.py index 376458ae..a9c37935 100644 --- a/tests/topics/git/test_trees.py +++ b/tests/topics/git/test_trees.py @@ -121,13 +121,13 @@ def test_git_invocation_anchors_the_read_at_the_repository_root(self) -> None: assert paths == [".goga/history/2026/feat-a/plan.md"] def test_file_entity_is_importable_from_the_cell_facade(self) -> None: - """``read_ref_file`` lives on the fourteen-name cell facade.""" + """``read_ref_file`` lives on the fifteen-name cell facade.""" import goga.topics.git as cell assert cell.read_ref_file is read_ref_file assert "read_ref_file" in cell.__all__ assert cell.__all__ == sorted(cell.__all__) - assert len(cell.__all__) == 14 + assert len(cell.__all__) == 15 def test_file_signature_takes_ref_and_path_and_returns_optional_str(self) -> None: """``read_ref_file(ref: str, path: str) -> str | None``.""" From 8f49a7f30d789e00cf8a08a8032a8048a1defa43 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 20:19:31 +0000 Subject: [PATCH 182/229] feat: add DeleteTarget and resolve_delete_targets to goga/topics --- goga/topics/__init__.py | 3 + goga/topics/deletion.py | 410 ++++++++++++++++++++++++++++++++ tests/topics/test_creation.py | 2 + tests/topics/test_deletion.py | 301 +++++++++++++++++++++++ tests/topics/test_publishing.py | 2 + 5 files changed, 718 insertions(+) create mode 100644 goga/topics/deletion.py create mode 100644 tests/topics/test_deletion.py diff --git a/goga/topics/__init__.py b/goga/topics/__init__.py index d67c9d7a..93204084 100644 --- a/goga/topics/__init__.py +++ b/goga/topics/__init__.py @@ -15,6 +15,7 @@ from .board import BoardRecord, collect_topic_board from .creation import check_branch_occupancy, check_slug_occupancy, create_topic +from .deletion import DeleteTarget, resolve_delete_targets from .ensuring import ensure_topic from .publishing import publish_topic from .switching import ( @@ -25,6 +26,7 @@ __all__: list[str] = [ "BoardRecord", + "DeleteTarget", "SwitchCandidate", "check_branch_occupancy", "check_slug_occupancy", @@ -32,6 +34,7 @@ "create_topic", "ensure_topic", "publish_topic", + "resolve_delete_targets", "resolve_switch_candidates", "switch_topic", ] diff --git a/goga/topics/deletion.py b/goga/topics/deletion.py new file mode 100644 index 00000000..93e22009 --- /dev/null +++ b/goga/topics/deletion.py @@ -0,0 +1,410 @@ +"""The identified-topic deletion of the topics domain. + +The entities declared in the cell CODEMANIFEST with +``location: deletion.py``: one identified deletion target — a topic with +its hosting refs and its directory — and the read-only resolution that +maps deletion identifiers to targets (the confirmed removal of the +resolved targets — ``delete_topics`` — completes the module under the +same read-only-decisions-before-any-mutation rule). The resolution +mirrors the switch tiers, keeps merged work out of scope, and collapses +a local branch and its origin twin into one target assembled from the +full inventory. Topic identity and addressing belong to the history +facade; the ref inventory and the ref-tree reading belong to the nested +git cell. Git infrastructure failures surface as +``click.ClickException`` — the clean-error boundary of the domain. +""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass + +import click + +from ..history import ( + collect_history_tree, + current_year, + normalize_topic_slug, + resolve_current_branch_name, + resolve_history_root, +) +from .board import _short_name +from .git import BranchRef, list_branch_refs, read_ref_tree_paths + + +@dataclass(frozen=True, kw_only=True) +class DeleteTarget: + """One identified deletion target — a topic with its hosting refs and + directory. + + Attributes: + topic: The topic slug. + branch: The hosting local branch name, or ``None``. + remote: The hosting origin twin name — the short name the remote + deletion consumes — or ``None``. + has_dir: ``True`` when the topic directory of the year exists on + disk. + """ + + topic: str + branch: str | None + remote: str | None + has_dir: bool + + +def resolve_delete_targets( + identifiers: list[str], year: str | None = None +) -> list[DeleteTarget]: + """Resolve deletion identifiers into targets — every check before any + removal. + + Args: + identifiers: The user inputs — branch names, topic slugs, or their + prefixes. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + One ``DeleteTarget`` per identified topic, in identifier order. + A local branch and its origin twin form one target; repeated + identifiers naming one topic collapse into it. + + Algorithm: + 1. Resolve the year and collect the read-only inventory once: the + branch refs via ``list_branch_refs``, the topics of the year + hosted by every ref via ``read_ref_tree_paths`` under the + history root, and the topics of the year on disk via + ``collect_history_tree`` + 2. Each identifier resolves through the tiers — the exact branch + name (a local ref by its name, a remote-tracking ref by its + short name), the exact topic slug (a ref hosting it in its + tree, or a disk topic), the prefixes of both — the first + non-empty tier wins + 3. Within the tier the distinct hosted topics decide: none or a + single tier without topics -> clean error naming the + identifier; more than one -> clean error listing the + candidates — no interactive choice + 4. Merged-work guard: a hosting ref is part of the target only + when its normalized name equals the topic slug; a topic whose + every hosting ref carries it as merged work is a clean error + naming the topic and the hosting branch — a disk topic no + branch hosts stays targetable (no refs, directory only) + 5. Assemble every identified target from the full inventory — the + local ref and the remote-tracking twin whose normalized names + equal the slug, and the disk presence — never from the tier + that matched, so the result cannot depend on identifier order + 6. The current branch naming any target's branch, or its slug + naming any target's topic -> clean error asking to switch away + first + + Requirements: + Read-only — nothing is removed, created, or switched. + All-or-nothing — any unresolved or ambiguous identifier cancels + the whole call. + + Constraints: + Do not resolve remote state over the network — the local + inventory only. + Do not offer an interactive selection. + + Raises: + click.ClickException: an identifier nothing hosts, an ambiguous + identifier, merged work, the current branch hosting a target, + a git infrastructure failure (its stderr when git reports + one, or a missing git binary), or an OS failure of the + history-tree read. + """ + try: + return _resolve_delete_targets(identifiers, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + except OSError as exc: + raise click.ClickException(f"reading the history tree failed: {exc}") from exc + + +def _resolve_delete_targets(identifiers: list[str], year: str | None) -> list[DeleteTarget]: + """Run the traced resolution — the unwrapped orchestration. + + Args: + identifiers: The user inputs as entered. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The targets in identifier order, deduplicated by topic slug. + """ + resolved_year = year or current_year() + refs = list_branch_refs() + hosted = _hosted_slugs(refs, resolved_year) + disk = _disk_slugs(resolved_year) + + topics: list[str] = [] + for identifier in identifiers: + topic = _identify(identifier, refs, hosted, disk) + if topic not in topics: + topics.append(topic) + + targets = [_assemble_target(topic, refs, hosted, disk) for topic in topics] + _guard_current_branch(targets) + + return targets + + +def _hosted_slugs(refs: list[BranchRef], year: str) -> dict[str, set[str]]: + """Read the topics of one year hosted by every given ref. + + One ``read_ref_tree_paths`` invocation per ref under the year prefix + of the history root — the same tree-reading pattern as the board and + the switch resolution, without checkout and without statuses: the + deletion inventory carries names only. + + Args: + refs: The refs whose trees are read. + year: The resolved year as four digits. + + Returns: + The hosted topic slugs per ref display name — an empty set for a + ref hosting nothing of the year. + """ + prefix = f"{resolve_history_root().as_posix()}/{year}/" + return {ref.name: _slugs_under(read_ref_tree_paths(ref.name, prefix), prefix) for ref in refs} + + +def _slugs_under(paths: list[str], prefix: str) -> set[str]: + """Take the topic slugs of the ref-tree paths under the year prefix. + + Args: + paths: The file paths of one ref tree, relative to the repository + root, already filtered under ``prefix`` by the reader. + prefix: The year prefix the paths sit under. + + Returns: + The distinct topic slugs — the first path segment after the + prefix. + """ + return {path.removeprefix(prefix).split("/", 1)[0] for path in paths} + + +def _disk_slugs(year: str) -> set[str]: + """Read the topics of one year found in the on-disk history tree. + + The scale-free provider of the deletion flow — statuses are never + computed, so the status registry is never touched. + + Args: + year: The resolved year as four digits. + + Returns: + The topic slugs of the year present on disk — a topic directory + no branch hosts is a targetable topic. + """ + for record in collect_history_tree(): + if record.year == year: + return set(record.topics) + return set() + + +def _identify( + identifier: str, refs: list[BranchRef], hosted: dict[str, set[str]], disk: set[str] +) -> str: + """Resolve one identifier into its single topic through the tiers. + + Args: + identifier: The user input as entered. + refs: The full branch inventory. + hosted: The hosted topic slugs per ref display name. + disk: The on-disk topic slugs of the year. + + Returns: + The one identified topic slug. + + Raises: + click.ClickException: nothing matches the identifier, or several + topics match it — no interactive choice. + """ + slug = normalize_topic_slug(identifier) + tiers = ( + _tier_exact_branch(identifier, refs, hosted), + _tier_exact_slug(slug, refs, hosted, disk), + _tier_prefix(identifier, slug, refs, hosted, disk), + ) + + for topics in tiers: + if topics is None: + continue + if len(topics) > 1: + raise click.ClickException( + f"several topics match {identifier!r}: {', '.join(sorted(topics))}" + ) + if topics: + return next(iter(topics)) + break + + raise click.ClickException(f"no topic matches {identifier!r}") + + +def _tier_exact_branch( + identifier: str, refs: list[BranchRef], hosted: dict[str, set[str]] +) -> set[str] | None: + """Take the first tier — the exact branch name. + + Args: + identifier: The user input as entered. + refs: The full branch inventory. + hosted: The hosted topic slugs per ref display name. + + Returns: + The distinct hosted topics of the matched refs — ``None`` when no + ref carries the name (the tier is skipped), an empty set when a + matched branch hosts nothing (deletion deletes topics, not bare + branches). + """ + matched = [ + ref + for ref in refs + if (not ref.remote and ref.name == identifier) + or (ref.remote and _short_name(ref.name) == identifier) + ] + if not matched: + return None + return set().union(*(hosted[ref.name] for ref in matched)) + + +def _tier_exact_slug( + slug: str, refs: list[BranchRef], hosted: dict[str, set[str]], disk: set[str] +) -> set[str] | None: + """Take the second tier — the exact topic slug. + + Args: + slug: The normalized identifier. + refs: The full branch inventory. + hosted: The hosted topic slugs per ref display name. + disk: The on-disk topic slugs of the year. + + Returns: + The hosted topics of the refs carrying the slug, plus the slug + itself when it sits on disk — ``None`` when neither matches (the + tier is skipped). + """ + matched = [ref for ref in refs if slug != "" and slug in hosted[ref.name]] + on_disk = slug != "" and slug in disk + if not matched and not on_disk: + return None + topics = set().union(*(hosted[ref.name] for ref in matched)) if matched else set() + if on_disk: + topics.add(slug) + return topics + + +def _tier_prefix( + identifier: str, slug: str, refs: list[BranchRef], hosted: dict[str, set[str]], disk: set[str] +) -> set[str] | None: + """Take the third tier — the prefixes of both. + + Args: + identifier: The user input as entered. + slug: The normalized identifier. + refs: The full branch inventory. + hosted: The hosted topic slugs per ref display name. + disk: The on-disk topic slugs of the year. + + Returns: + The hosted topics of the refs whose name starts with the + identifier, plus the hosted and disk slugs starting with the + normalized slug — ``None`` when nothing matches (the tier is + skipped). A non-ASCII identifier normalizes to the empty slug, + which every slug starts with, so the slug-prefix arms stay + disabled for it. + """ + topics: set[str] = set() + matched = [ + ref + for ref in refs + if ref.name.startswith(identifier) or _short_name(ref.name).startswith(identifier) + ] + for ref in matched: + topics |= hosted[ref.name] + if slug != "": + topics |= { + hosted_slug for slugs in hosted.values() for hosted_slug in slugs if hosted_slug.startswith(slug) + } + topics |= {disk_slug for disk_slug in disk if disk_slug.startswith(slug)} + if not matched and not topics: + return None + return topics + + +def _assemble_target( + topic: str, refs: list[BranchRef], hosted: dict[str, set[str]], disk: set[str] +) -> DeleteTarget: + """Assemble one topic's target from the full inventory. + + The hosting refs decide eligibility — a ref is part of the target + only when its normalized name equals the topic slug, so a branch + carrying the topic as merged work never turns into a deletion of the + integration branch. The lookup walks the full inventory, never the + tier that matched, so repeated identifiers of one topic in any order + assemble the identical target. + + Args: + topic: The identified topic slug. + refs: The full branch inventory. + hosted: The hosted topic slugs per ref display name. + disk: The on-disk topic slugs of the year. + + Returns: + The assembled target — the local branch and the origin twin short + name of the eligible refs, and the disk presence. + + Raises: + click.ClickException: the topic is hosted only by refs that carry + it as merged work — the hosting branch is named in the error. + """ + hosts = [ref for ref in refs if topic in hosted[ref.name]] + eligible = [ref for ref in hosts if _normalized_name(ref) == topic] + if hosts and not eligible: + names = ", ".join(ref.name for ref in hosts) + raise click.ClickException( + f"topic {topic!r} is hosted by {names} as merged work — " + "remove it from the hosting branch's tree instead of deleting" + ) + + branch = next((ref.name for ref in eligible if not ref.remote), None) + remote = next((_short_name(ref.name) for ref in eligible if ref.remote), None) + return DeleteTarget(topic=topic, branch=branch, remote=remote, has_dir=topic in disk) + + +def _normalized_name(ref: BranchRef) -> str: + """Normalize one ref's name into the topic-slug grammar. + + Args: + ref: The ref whose name is normalized. + + Returns: + The normalized name — the short name for a remote-tracking ref + (the local twin's name), the display name for a local branch. + """ + return normalize_topic_slug(_short_name(ref.name) if ref.remote else ref.name) + + +def _guard_current_branch(targets: list[DeleteTarget]) -> None: + """Reject a deletion that would remove the current branch's topic. + + Args: + targets: The assembled targets. + + Raises: + click.ClickException: the current branch names a target's branch, + or its slug names a target's topic — the deletion needs a + switch away first. + """ + current = resolve_current_branch_name() + if current is None: + return + slug = normalize_topic_slug(current) + for target in targets: + if target.branch == current or slug == target.topic: + raise click.ClickException( + f"the current branch hosts topic {target.topic!r} — switch away before deleting" + ) diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index 91fdd502..228d5745 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -108,6 +108,7 @@ def test_entities_are_importable_from_the_cell_facade(self) -> None: assert cell.check_slug_occupancy is check_slug_occupancy expected = { "BoardRecord", + "DeleteTarget", "SwitchCandidate", "check_branch_occupancy", "check_slug_occupancy", @@ -115,6 +116,7 @@ def test_entities_are_importable_from_the_cell_facade(self) -> None: "create_topic", "ensure_topic", "publish_topic", + "resolve_delete_targets", "resolve_switch_candidates", "switch_topic", } diff --git a/tests/topics/test_deletion.py b/tests/topics/test_deletion.py new file mode 100644 index 00000000..11f48caa --- /dev/null +++ b/tests/topics/test_deletion.py @@ -0,0 +1,301 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/CODEMANIFEST`` with ``location: deletion.py``: + +- ``DeleteTarget(topic, branch, remote, has_dir)`` — one identified + deletion target +- ``resolve_delete_targets(identifiers, year)`` — the read-only resolution + +The git boundary is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched: the inventory, +the ref-tree reading, and the current branch are patched at +``goga.topics.deletion``. The disk tree is real on ``tmp_path`` via +``monkeypatch.chdir`` — ``collect_history_tree`` runs against it. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import subprocess +import typing +from collections.abc import Callable +from pathlib import Path +from unittest import mock + +import click +import pytest +from goga.topics import DeleteTarget, deletion, resolve_delete_targets +from goga.topics.git import BranchRef + +# --- Shared scenario helpers --- + + +def _trees_reader(trees: dict[str, list[str]]) -> Callable[..., list[str]]: + """A ``read_ref_tree_paths`` stand-in answering by ref display name.""" + + def read(ref: str, prefix: str) -> list[str]: + assert prefix == ".goga/history/2026/", "the resolution reads under the year prefix only" + return [path for path in trees.get(ref, []) if path.startswith(prefix)] + + return read + + +def _wire_resolution( + monkeypatch: pytest.MonkeyPatch, + inventory: list[BranchRef], + trees: dict[str, list[str]], + current: str | None, +) -> None: + """Patch the resolution's import points: git inventory, trees, branch.""" + monkeypatch.setattr(deletion, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(deletion, "resolve_current_branch_name", lambda: current) + monkeypatch.setattr(deletion, "read_ref_tree_paths", _trees_reader(trees)) + + +def _disk_topic(cwd: Path, year: str, slug: str) -> None: + """Create the on-disk topic directory of the year.""" + (cwd / ".goga" / "history" / year / slug).mkdir(parents=True, exist_ok=True) + + +def _twin_inventory() -> list[BranchRef]: + """The design-scenario inventory: a local branch and its remote twin.""" + return [ + BranchRef(name="feature-foo", remote=False), + BranchRef(name="origin/feature-foo", remote=True), + ] + + +def _twin_trees() -> dict[str, list[str]]: + """The design-scenario ref trees: one topic on both refs.""" + return { + "feature-foo": [".goga/history/2026/feature-foo/plan.md"], + "origin/feature-foo": [".goga/history/2026/feature-foo/plan.md"], + } + + +# --- Contract tests --- + + +class TestDeletionContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """``DeleteTarget`` and the resolver live on the cell facade.""" + import goga.topics as cell + + assert cell.DeleteTarget is DeleteTarget + assert cell.resolve_delete_targets is resolve_delete_targets + for name in ("DeleteTarget", "resolve_delete_targets"): + assert name in cell.__all__ + + def test_delete_target_is_a_frozen_kw_only_dataclass(self) -> None: + """``@dataclass(frozen=True, kw_only=True)`` with the four declared fields.""" + assert dataclasses.is_dataclass(DeleteTarget) + assert DeleteTarget.__dataclass_params__.frozen is True + assert DeleteTarget.__dataclass_params__.kw_only is True + assert typing.get_type_hints(DeleteTarget) == { + "topic": str, + "branch": str | None, + "remote": str | None, + "has_dir": bool, + } + target = DeleteTarget(topic="x", branch=None, remote=None, has_dir=True) + assert target.topic == "x" + assert target.branch is None + assert target.remote is None + assert target.has_dir is True + with pytest.raises(dataclasses.FrozenInstanceError): + target.topic = "other" # type: ignore[misc] + with pytest.raises(TypeError): + DeleteTarget("x", None, None, True) # type: ignore[misc] + + def test_resolve_delete_targets_signature(self) -> None: + """``resolve_delete_targets(identifiers, year=None) -> list[...]``.""" + signature = inspect.signature(resolve_delete_targets) + assert list(signature.parameters) == ["identifiers", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + assert typing.get_type_hints(resolve_delete_targets) == { + "identifiers": list[str], + "year": str | None, + "return": list[DeleteTarget], + } + + +# --- Logic tests: the resolution tiers --- + + +class TestResolveDeleteTargets: + def test_resolve_delete_targets_exact_slug_with_unhosted_disk_topic( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A disk topic no branch hosts is a target — directory only.""" + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "orphan-topic") + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="origin/other", remote=True), + ] + _wire_resolution(monkeypatch, inventory, {"main": []}, "main") + + targets = resolve_delete_targets(["orphan-topic"], year="2026") + + assert targets == [DeleteTarget(topic="orphan-topic", branch=None, remote=None, has_dir=True)] + + def test_resolve_delete_targets_collapses_local_and_origin_twin( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The branch name and its twin name identify one target with both refs.""" + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "feature-foo") + _wire_resolution(monkeypatch, _twin_inventory(), _twin_trees(), "main") + + targets = resolve_delete_targets(["feature-foo", "origin/feature-foo"], year="2026") + + assert targets == [ + DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True) + ] + + def test_resolve_delete_targets_twin_collapse_order_independent( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Both identifier orders return the identical single target (fix D8).""" + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "feature-foo") + _wire_resolution(monkeypatch, _twin_inventory(), _twin_trees(), "main") + + twin_first = resolve_delete_targets(["origin/feature-foo", "feature-foo"], year="2026") + local_first = resolve_delete_targets(["feature-foo", "origin/feature-foo"], year="2026") + + expected = [ + DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True) + ] + assert twin_first == expected + assert local_first == expected + + def test_resolve_delete_targets_ambiguous_error_all_or_nothing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Several topics in the winning tier cancel the whole call.""" + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "orphan-topic") + inventory = [BranchRef(name="feature-foo", remote=False)] + trees = { + "feature-foo": [ + ".goga/history/2026/feature-foo/plan.md", + ".goga/history/2026/feature-foobar/plan.md", + ] + } + _wire_resolution(monkeypatch, inventory, trees, "main") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-foo", "orphan-topic"], year="2026") + + assert "feature-foo" in raised.value.message + assert "feature-foobar" in raised.value.message + + def test_resolve_delete_targets_current_branch_guard( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A target hosted by the current branch is a clean error — switch away.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="feature-foo", remote=False)] + trees = {"feature-foo": [".goga/history/2026/feature-foo/plan.md"]} + _wire_resolution(monkeypatch, inventory, trees, "feature-foo") + + with pytest.raises(click.ClickException, match="switch"): + resolve_delete_targets(["feature-foo"], year="2026") + + def test_resolve_delete_targets_merged_topic_is_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A topic hosted only by integration refs is merged work, not a target (fix D3).""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="origin/main", remote=True), + ] + trees = { + "main": [".goga/history/2026/feature-x/plan.md"], + "origin/main": [".goga/history/2026/feature-x/plan.md"], + } + _wire_resolution(monkeypatch, inventory, trees, "other") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-x"], year="2026") + + assert "feature-x" in raised.value.message + assert "main" in raised.value.message + + def test_resolve_delete_targets_integration_branch_named_directly_is_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The merged-work guard applies through the exact-branch tier too (fix D3).""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": [".goga/history/2026/cleanup/plan.md"]} + _wire_resolution(monkeypatch, inventory, trees, "other") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["main"], year="2026") + + assert "main" in raised.value.message + assert "cleanup" in raised.value.message + + def test_resolve_delete_targets_branch_without_topic_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A bare branch hosting nothing resolves to no topic — deletion deletes topics.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="gh-pages", remote=False)] + _wire_resolution(monkeypatch, inventory, {}, "main") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["gh-pages"], year="2026") + + assert "gh-pages" in raised.value.message + + def test_resolve_delete_targets_no_match_names_identifier( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An identifier nothing matches is a clean error naming it.""" + monkeypatch.chdir(tmp_path) + _wire_resolution(monkeypatch, [], {}, "main") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["nope"], year="2026") + + assert "nope" in raised.value.message + + +# --- Infrastructure boundary --- + + +class TestDeletionInfrastructureBoundary: + def test_git_failure_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A git infrastructure failure with stderr becomes a ``ClickException``.""" + monkeypatch.chdir(tmp_path) + failure = subprocess.CalledProcessError( + returncode=128, cmd=["git", "for-each-ref"], stderr="fatal: not a git repository" + ) + monkeypatch.setattr(deletion, "list_branch_refs", mock.Mock(side_effect=failure)) + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-foo"], year="2026") + + assert "fatal: not a git repository" in raised.value.message + + def test_missing_git_binary_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A missing git binary during the resolution is a clean error.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(deletion, "list_branch_refs", mock.Mock(side_effect=FileNotFoundError("git"))) + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-foo"], year="2026") + + assert "git is not available" in raised.value.message diff --git a/tests/topics/test_publishing.py b/tests/topics/test_publishing.py index 97791e15..49c1bff5 100644 --- a/tests/topics/test_publishing.py +++ b/tests/topics/test_publishing.py @@ -106,6 +106,7 @@ def test_publish_topic_is_importable_from_the_cell_facade(self) -> None: assert cell.publish_topic is publish_topic expected = { "BoardRecord", + "DeleteTarget", "SwitchCandidate", "check_branch_occupancy", "check_slug_occupancy", @@ -113,6 +114,7 @@ def test_publish_topic_is_importable_from_the_cell_facade(self) -> None: "create_topic", "ensure_topic", "publish_topic", + "resolve_delete_targets", "resolve_switch_candidates", "switch_topic", } From 6b27cad78e79369f0337edd64177e66f76d4095f Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 20:27:01 +0000 Subject: [PATCH 183/229] feat: add delete_topics to goga/topics/deletion --- goga/topics/__init__.py | 3 +- goga/topics/deletion.py | 122 ++++++++++++++++++++-- tests/topics/test_creation.py | 1 + tests/topics/test_deletion.py | 176 +++++++++++++++++++++++++++++++- tests/topics/test_publishing.py | 1 + 5 files changed, 288 insertions(+), 15 deletions(-) diff --git a/goga/topics/__init__.py b/goga/topics/__init__.py index 93204084..62723f3a 100644 --- a/goga/topics/__init__.py +++ b/goga/topics/__init__.py @@ -15,7 +15,7 @@ from .board import BoardRecord, collect_topic_board from .creation import check_branch_occupancy, check_slug_occupancy, create_topic -from .deletion import DeleteTarget, resolve_delete_targets +from .deletion import DeleteTarget, delete_topics, resolve_delete_targets from .ensuring import ensure_topic from .publishing import publish_topic from .switching import ( @@ -32,6 +32,7 @@ "check_slug_occupancy", "collect_topic_board", "create_topic", + "delete_topics", "ensure_topic", "publish_topic", "resolve_delete_targets", diff --git a/goga/topics/deletion.py b/goga/topics/deletion.py index 93e22009..f535d397 100644 --- a/goga/topics/deletion.py +++ b/goga/topics/deletion.py @@ -2,20 +2,23 @@ The entities declared in the cell CODEMANIFEST with ``location: deletion.py``: one identified deletion target — a topic with -its hosting refs and its directory — and the read-only resolution that -maps deletion identifiers to targets (the confirmed removal of the -resolved targets — ``delete_topics`` — completes the module under the -same read-only-decisions-before-any-mutation rule). The resolution -mirrors the switch tiers, keeps merged work out of scope, and collapses -a local branch and its origin twin into one target assembled from the -full inventory. Topic identity and addressing belong to the history -facade; the ref inventory and the ref-tree reading belong to the nested -git cell. Git infrastructure failures surface as +its hosting refs and its directory —, the read-only resolution that +maps deletion identifiers to targets, and the confirmed removal of the +resolved targets — every decision is made before the first mutation. +The resolution mirrors the switch tiers, keeps merged work out of +scope, and collapses a local branch and its origin twin into one target +assembled from the full inventory; the removal deletes the local branch, +the origin twin, and the topic directory, restoring the local branch at +its captured commit when the remote deletion fails. Topic identity and +addressing belong to the history facade; the ref inventory, the +ref-tree reading, and the branch removals belong to the nested git +cell. Git infrastructure failures surface as ``click.ClickException`` — the clean-error boundary of the domain. """ from __future__ import annotations +import contextlib import subprocess from dataclasses import dataclass @@ -25,11 +28,20 @@ collect_history_tree, current_year, normalize_topic_slug, + remove_topic_dir, resolve_current_branch_name, resolve_history_root, ) from .board import _short_name -from .git import BranchRef, list_branch_refs, read_ref_tree_paths +from .git import ( + BranchRef, + create_branch_at_commit, + delete_local_branch, + delete_remote_branch, + list_branch_refs, + read_ref_tree_paths, + resolve_ref_commit, +) @dataclass(frozen=True, kw_only=True) @@ -408,3 +420,93 @@ def _guard_current_branch(targets: list[DeleteTarget]) -> None: raise click.ClickException( f"the current branch hosts topic {target.topic!r} — switch away before deleting" ) + + +def delete_topics(targets: list[DeleteTarget], year: str | None = None) -> str: + """Delete confirmed targets — the local branch, the origin twin, and + the topic directory of each. + + Args: + targets: The confirmed targets, as resolved by + ``resolve_delete_targets``. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + One line reporting the removal. + + Algorithm: + 1. Resolve the year once + 2. Per target, in order: capture the hosting local branch's + commit, delete the local branch, delete the origin twin, then + remove the topic directory — on a remote failure restore the + local branch at the captured commit before the error surfaces + 3. Return the single outcome line + + Requirements: + The commit is captured before the local deletion — after it the + name no longer resolves. + + Targets removed before a failure stay removed; a failed remote + deletion restores the failing target's local branch at the + captured commit, and a failure of the restore itself is + suppressed so the original remote reason surfaces. + + The directory removal is idempotent on absence — a missing + directory is not an error. + + Constraints: + Do not confirm, check merges, or re-resolve — the caller resolved + the targets and owns the confirmation. + + Raises: + click.ClickException: a git infrastructure failure (its stderr + when git reports one, or a missing git binary), or an OS + failure of the removal. + """ + try: + return _delete_topics(targets, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + except OSError as exc: + raise click.ClickException(f"cannot complete the deletion: {exc}") from exc + + +def _delete_topics(targets: list[DeleteTarget], year: str | None) -> str: + """Run the traced removal — the unwrapped orchestration. + + Args: + targets: The confirmed targets, in deletion order. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The single result line of the outcome. + """ + resolved_year = year or current_year() + + for target in targets: + commit: str | None = None + if target.branch is not None: + commit = resolve_ref_commit(target.branch) + delete_local_branch(target.branch) + if target.remote is not None: + try: + delete_remote_branch(target.remote) + except (subprocess.CalledProcessError, OSError): + # Restore the local branch at the captured commit before the + # error propagates — a failure of the restore itself is + # suppressed so the original remote reason surfaces (the + # ``publish_topic`` precedent). A remote-only target has + # nothing to restore; targets removed before this one stay + # removed. + if target.branch is not None: + with contextlib.suppress(subprocess.CalledProcessError, OSError): + create_branch_at_commit(target.branch, commit) + raise + if target.has_dir: + remove_topic_dir(target.topic, resolved_year) + + slugs = ", ".join(target.topic for target in targets) + return f"Deleted {len(targets)} topic(s) of {resolved_year}: {slugs}" diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index 228d5745..2d503691 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -114,6 +114,7 @@ def test_entities_are_importable_from_the_cell_facade(self) -> None: "check_slug_occupancy", "collect_topic_board", "create_topic", + "delete_topics", "ensure_topic", "publish_topic", "resolve_delete_targets", diff --git a/tests/topics/test_deletion.py b/tests/topics/test_deletion.py index 11f48caa..70fadcb1 100644 --- a/tests/topics/test_deletion.py +++ b/tests/topics/test_deletion.py @@ -4,12 +4,14 @@ - ``DeleteTarget(topic, branch, remote, has_dir)`` — one identified deletion target - ``resolve_delete_targets(identifiers, year)`` — the read-only resolution +- ``delete_topics(targets, year)`` — the confirmed removal The git boundary is mocked at the import point per the ``convention`` practice — no git binary and no repository are touched: the inventory, -the ref-tree reading, and the current branch are patched at -``goga.topics.deletion``. The disk tree is real on ``tmp_path`` via -``monkeypatch.chdir`` — ``collect_history_tree`` runs against it. +the ref-tree reading, the current branch, and the removal primitives are +patched at ``goga.topics.deletion``. The disk tree is real on ``tmp_path`` +via ``monkeypatch.chdir`` — ``collect_history_tree`` and (where the +scenario says so) ``remove_topic_dir`` run against it. """ from __future__ import annotations @@ -20,11 +22,13 @@ import typing from collections.abc import Callable from pathlib import Path +from types import SimpleNamespace from unittest import mock import click import pytest -from goga.topics import DeleteTarget, deletion, resolve_delete_targets +from goga.history import remove_topic_dir as _remove_topic_dir +from goga.topics import DeleteTarget, delete_topics, deletion, resolve_delete_targets from goga.topics.git import BranchRef # --- Shared scenario helpers --- @@ -73,6 +77,56 @@ def _twin_trees() -> dict[str, list[str]]: } +def _wire_removal( + monkeypatch: pytest.MonkeyPatch, + *, + commit: str = "c123", + remote_error: Exception | None = None, + restore_error: Exception | None = None, + dir_side_effect: Callable[..., bool] | None = None, +) -> SimpleNamespace: + """Patch the removal's import points with recording mocks. + + Every removal primitive of ``deletion`` is replaced by a mock; the + mocks share one parent so ``wired.order.mock_calls`` records the + cross-primitive call order. ``dir_side_effect`` lets the directory + removal run the real history-facade function against ``tmp_path``. + + Args: + monkeypatch: The patcher scoping the mocks to one test. + commit: The commit the capture mock resolves. + remote_error: The failure the remote deletion raises, if any. + restore_error: The failure the restore (branch re-plant) raises. + dir_side_effect: The real behavior of the directory removal. + + Returns: + The recording mocks: ``order`` (the shared parent), ``capture``, + ``local``, ``remote``, ``restore``, and ``directory``. + """ + order = mock.Mock() + capture = mock.Mock(return_value=commit) + local = mock.Mock() + remote = mock.Mock(side_effect=remote_error) + restore = mock.Mock(side_effect=restore_error) + directory = mock.Mock(return_value=False, side_effect=dir_side_effect) + for name, child in ( + ("capture", capture), + ("local", local), + ("remote", remote), + ("restore", restore), + ("directory", directory), + ): + order.attach_mock(child, name) + monkeypatch.setattr(deletion, "resolve_ref_commit", capture) + monkeypatch.setattr(deletion, "delete_local_branch", local) + monkeypatch.setattr(deletion, "delete_remote_branch", remote) + monkeypatch.setattr(deletion, "create_branch_at_commit", restore) + monkeypatch.setattr(deletion, "remove_topic_dir", directory) + return SimpleNamespace( + order=order, capture=capture, local=local, remote=remote, restore=restore, directory=directory + ) + + # --- Contract tests --- @@ -122,6 +176,28 @@ def test_resolve_delete_targets_signature(self) -> None: "return": list[DeleteTarget], } + def test_delete_topics_is_importable_from_the_cell_facade(self) -> None: + """``delete_topics`` lives on the cell facade.""" + import goga.topics as cell + + assert cell.delete_topics is delete_topics + assert "delete_topics" in cell.__all__ + + def test_delete_topics_signature(self) -> None: + """``delete_topics(targets, year=None) -> str``.""" + signature = inspect.signature(delete_topics) + assert list(signature.parameters) == ["targets", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + assert typing.get_type_hints(delete_topics) == { + "targets": list[DeleteTarget], + "year": str | None, + "return": str, + } + # --- Logic tests: the resolution tiers --- @@ -299,3 +375,95 @@ def test_missing_git_binary_surfaces_as_clean_error( resolve_delete_targets(["feature-foo"], year="2026") assert "git is not available" in raised.value.message + + +# --- Logic tests: the confirmed removal --- + + +class TestDeleteTopics: + def test_delete_topics_restores_local_on_remote_failure( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A failed remote deletion restores the local branch at the captured commit.""" + monkeypatch.chdir(tmp_path) + target = DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True) + wired = _wire_removal( + monkeypatch, + remote_error=subprocess.CalledProcessError(128, "git push", stderr=b"remote error"), + ) + + with pytest.raises(click.ClickException) as raised: + delete_topics([target], year="2026") + + assert "remote error" in raised.value.message + wired.restore.assert_called_once_with("feature-foo", "c123") + wired.directory.assert_not_called() + + def test_delete_topics_full_success_removes_all_three( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The local branch, the origin twin, and the directory go — in that order.""" + monkeypatch.chdir(tmp_path) + topic_dir = tmp_path / ".goga" / "history" / "2026" / "feature-foo" + topic_dir.mkdir(parents=True) + target = DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True) + wired = _wire_removal(monkeypatch, dir_side_effect=_remove_topic_dir) + + line = delete_topics([target], year="2026") + + assert line == "Deleted 1 topic(s) of 2026: feature-foo" + assert not topic_dir.exists() + assert wired.order.mock_calls == [ + mock.call.capture("feature-foo"), + mock.call.local("feature-foo"), + mock.call.remote("feature-foo"), + mock.call.directory("feature-foo", "2026"), + ] + + def test_delete_topics_remote_only_target_no_restore( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A remote-only target has nothing to restore — the error propagates directly.""" + monkeypatch.chdir(tmp_path) + target = DeleteTarget(topic="ghost", branch=None, remote="ghost", has_dir=False) + wired = _wire_removal( + monkeypatch, + remote_error=subprocess.CalledProcessError(128, "git push", stderr=b"deny"), + ) + + with pytest.raises(click.ClickException) as raised: + delete_topics([target], year="2026") + + assert "deny" in raised.value.message + wired.restore.assert_not_called() + + def test_delete_topics_idempotent_directory_absence( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An already-absent directory is a False, not an error — the topic still reports.""" + monkeypatch.chdir(tmp_path) + target = DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True) + wired = _wire_removal(monkeypatch, dir_side_effect=_remove_topic_dir) + + line = delete_topics([target], year="2026") + + assert line == "Deleted 1 topic(s) of 2026: feature-foo" + wired.directory.assert_called_once_with("feature-foo", "2026") + + def test_delete_topics_restore_failure_surfaces_original_reason( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A broken rollback's failure is suppressed — the remote reason surfaces.""" + monkeypatch.chdir(tmp_path) + target = DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True) + _wire_removal( + monkeypatch, + remote_error=subprocess.CalledProcessError(128, "git push", stderr=b"remote error"), + restore_error=subprocess.CalledProcessError(1, "git update-ref", stderr=b"ref lock"), + ) + + with pytest.raises(click.ClickException) as raised: + delete_topics([target], year="2026") + + assert "remote error" in raised.value.message + assert "ref lock" not in raised.value.message diff --git a/tests/topics/test_publishing.py b/tests/topics/test_publishing.py index 49c1bff5..d8b6f4ef 100644 --- a/tests/topics/test_publishing.py +++ b/tests/topics/test_publishing.py @@ -112,6 +112,7 @@ def test_publish_topic_is_importable_from_the_cell_facade(self) -> None: "check_slug_occupancy", "collect_topic_board", "create_topic", + "delete_topics", "ensure_topic", "publish_topic", "resolve_delete_targets", From 8628cc7c74c3c72d3b5e095e2730c37cbb3c84c6 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 20:31:55 +0000 Subject: [PATCH 184/229] =?UTF-8?q?feat:=20rework=20publish=5Ftopic=20?= =?UTF-8?q?=E2=80=94=20domain=20default=20template,=20no=20re-ask,=20singl?= =?UTF-8?q?e=20trailing=20newline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- goga/topics/publishing.py | 160 +++++++++++++++-------------- tests/topics/test_publishing.py | 173 +++++++++++++++++++++++--------- 2 files changed, 210 insertions(+), 123 deletions(-) diff --git a/goga/topics/publishing.py b/goga/topics/publishing.py index 8878fb26..a910d273 100644 --- a/goga/topics/publishing.py +++ b/goga/topics/publishing.py @@ -4,13 +4,15 @@ ``location: publishing.py``: the fast cycle that creates fresh work and publishes it in one go — a branch off an explicit base carrying exactly one commit with the topic todo file, pushed to origin, while the caller stays -on their branch. Every decision is made before the first mutation; the -mutation sequence is the quarantined commit build, the branch plant, and -the push, and a failed publication rolls back fully — the planted branch -is deleted and nothing else was ever mutated. The occupancy oracles and the -re-ask machinery belong to ``creation``; the bounded git mutations to the -nested git cell. Git infrastructure failures surface as -``click.ClickException`` — the clean-error boundary of the domain. +on their branch. Every decision is made before the first mutation; every +conflict of the decision chain is one clean error — there is no re-ask; +the mutation sequence is the quarantined commit build, the branch plant, +and the push, and a failed publication rolls back fully — the planted +branch is deleted and nothing else was ever mutated. The commit message +default lives here as the built-in domain template. The occupancy oracles +belong to ``creation``; the bounded git mutations to the nested git cell. +Git infrastructure failures surface as ``click.ClickException`` — the +clean-error boundary of the domain. """ from __future__ import annotations @@ -26,7 +28,7 @@ resolve_current_branch_name, resolve_topic_file, ) -from .creation import _BOARD_HINT, _reask, check_branch_occupancy, check_slug_occupancy +from .creation import _BOARD_HINT, check_branch_occupancy, check_slug_occupancy from .git import ( commit_file_on_base, create_branch_at_commit, @@ -36,12 +38,18 @@ resolve_ref_commit, ) +# The built-in commit message template of the fast path — the ``{slug}`` +# placeholder is replaced with the topic slug. The domain owns the default, +# so every caller (the CLI flags, the configuration section) may omit the +# template. +_DEFAULT_COMMIT_MESSAGE = "goga: create topic {slug}" + def publish_topic( branch_name: str, todo: str, base_ref: str, - commit_message: str, + commit_message: str | None = None, year: str | None = None, ) -> str: """Create fresh work and publish it — a branch off an explicit base @@ -51,27 +59,26 @@ def publish_topic( Args: branch_name: Branch name as entered by the user. todo: The multi-line todo of the fresh work — written to the topic - todo file todo.md as entered plus a single trailing newline; + todo file todo.md as entered plus a single trailing newline + (exactly one — a todo already ending in a newline keeps it); required and non-empty, an empty todo is a clean error asking for it. base_ref: Base revision the branch starts from — any revision string, resolved as git resolves it. commit_message: Commit message template — the ``{slug}`` placeholder is replaced with the topic slug; a template - without the placeholder is used as is. + without the placeholder is used as is; ``None`` applies the + built-in default ``goga: create topic {slug}``. year: Optional year as four digits; ``None`` means the current year. Returns: One line describing the created and published work. Raises: - click.ClickException: an empty todo, the current branch already - hosting the slug, a missing origin remote, an unresolved - occupancy conflict without a terminal, a git infrastructure - failure (its stderr when git reports one, or a missing git - binary). - click.Abort: Ctrl-C or EOF at the re-ask prompt — nothing was - mutated. + click.ClickException: an empty slug, an empty todo, the current + branch already hosting the slug, an occupancy conflict, a + missing origin remote, a git infrastructure failure (its + stderr when git reports one, or a missing git binary). """ try: return _publish_topic(branch_name, todo, base_ref, commit_message, year) @@ -93,7 +100,7 @@ def _publish_topic( branch_name: str, todo: str, base_ref: str, - commit_message: str, + commit_message: str | None, year: str | None, ) -> str: """Run the traced fast cycle — the unwrapped orchestration. @@ -102,7 +109,8 @@ def _publish_topic( branch_name: Branch name as entered by the user. todo: The multi-line todo of the fresh work as entered by the user. base_ref: Base revision the branch starts from. - commit_message: Commit message template with ``{slug}`` optional. + commit_message: Commit message template with ``{slug}`` optional; + ``None`` applies the built-in default. year: Optional year as four digits; ``None`` means the current year. Returns: @@ -110,62 +118,62 @@ def _publish_topic( """ resolved_year = year or current_year() - while True: - slug = normalize_topic_slug(branch_name) - - if slug == "": - reason = f"branch name '{branch_name}' normalizes to an empty topic slug" - branch_name = _reask(reason) - continue - - if not todo: - raise click.ClickException( - "the fast path needs a non-empty todo" - " — pass the text or enter it interactively" - ) - - current = resolve_current_branch_name() - if current is not None and normalize_topic_slug(current) == slug: - raise click.ClickException( - f"branch {current} already hosts topic {resolved_year}/{slug}" - " — the fast path is only for fresh work" - ) - - conflict = check_branch_occupancy(branch_name, slug, resolved_year) - if conflict is None: - conflict = check_slug_occupancy(slug, resolved_year) - if conflict is not None: - branch_name = _reask(conflict, _BOARD_HINT) - continue - - if not origin_configured(): - raise click.ClickException( - "origin is not configured — the fast mode publishes to origin" - ) - - base_commit = resolve_ref_commit(base_ref) - - path = resolve_topic_file(slug, "todo.md", resolved_year).as_posix() - commit = commit_file_on_base( - base_commit, - path, - f"{todo}\n", - commit_message.replace("{slug}", slug), + slug = normalize_topic_slug(branch_name) + if slug == "": + raise click.ClickException( + f"branch name '{branch_name}' normalizes to an empty topic slug" + ) + + if not todo: + raise click.ClickException( + "the fast path needs a non-empty todo" + " — pass the text or enter it interactively" + ) + + current = resolve_current_branch_name() + if current is not None and normalize_topic_slug(current) == slug: + raise click.ClickException( + f"branch {current} already hosts topic {resolved_year}/{slug}" + " — the fast path is only for fresh work" ) - create_branch_at_commit(branch_name, commit) - try: - push_branch(branch_name) - except (subprocess.CalledProcessError, OSError): - # Full rollback before the one clean error — a git failure and a - # spawn-level OS failure of the push alike leave nothing of this - # cycle behind. A failure of the rollback itself is suppressed so - # the original push reason surfaces; a branch left behind stays - # visible on the board. - with contextlib.suppress(subprocess.CalledProcessError, OSError): - delete_local_branch(branch_name) - raise - - return ( - f"Created branch {branch_name} and published topic {resolved_year}/{slug}" + conflict = check_branch_occupancy(branch_name, slug, resolved_year) + if conflict is None: + conflict = check_slug_occupancy(slug, resolved_year) + if conflict is not None: + raise click.ClickException(f"{conflict} — {_BOARD_HINT}") + + if not origin_configured(): + raise click.ClickException( + "origin is not configured — the fast mode publishes to origin" ) + + base_commit = resolve_ref_commit(base_ref) + + # The single-trailing-newline rule: an editor-sourced todo already ends + # with a newline (click's read-back), so an unconditional append would + # publish a blank trailing line; a bare value gains exactly one. + content = todo if todo.endswith("\n") else todo + "\n" + message = commit_message if commit_message is not None else _DEFAULT_COMMIT_MESSAGE + path = resolve_topic_file(slug, "todo.md", resolved_year).as_posix() + commit = commit_file_on_base( + base_commit, + path, + content, + message.replace("{slug}", slug), + ) + + create_branch_at_commit(branch_name, commit) + try: + push_branch(branch_name) + except (subprocess.CalledProcessError, OSError): + # Full rollback before the one clean error — a git failure and a + # spawn-level OS failure of the push alike leave nothing of this + # cycle behind. A failure of the rollback itself is suppressed so + # the original push reason surfaces; a branch left behind stays + # visible on the board. + with contextlib.suppress(subprocess.CalledProcessError, OSError): + delete_local_branch(branch_name) + raise + + return f"Created branch {branch_name} and published topic {resolved_year}/{slug}" diff --git a/tests/topics/test_publishing.py b/tests/topics/test_publishing.py index d8b6f4ef..afb0517f 100644 --- a/tests/topics/test_publishing.py +++ b/tests/topics/test_publishing.py @@ -29,16 +29,19 @@ def _non_interactive(monkeypatch: pytest.MonkeyPatch) -> None: - """Make stdin a non-terminal — the re-ask path must abort cleanly.""" + """Make stdin a non-terminal — every conflict is a clean error.""" monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) -def _interactive( - monkeypatch: pytest.MonkeyPatch, answers: list[str] -) -> mock.Mock: - """Make stdin a terminal and answer the re-ask prompts in order.""" +def _terminal(monkeypatch: pytest.MonkeyPatch) -> mock.Mock: + """Make stdin a terminal and arm a prompt marker that fails on use. + + The re-ask cycle is abolished — a conflict on a terminal is a clean + error like anywhere else, so ``click.prompt`` must never run; the + returned marker asserts exactly that. + """ monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) - prompt = mock.Mock(side_effect=answers) + prompt = mock.Mock(name="prompt-marker") monkeypatch.setattr(click, "prompt", prompt) return prompt @@ -123,10 +126,11 @@ def test_publish_topic_is_importable_from_the_cell_facade(self) -> None: assert "publish_topic" in cell.__all__ def test_publish_topic_signature(self) -> None: - """``publish_topic(branch_name, todo, base_ref, commit_message, year=None)``. + """``publish_topic(branch_name, todo, base_ref, commit_message=None, year=None)``. - ``commit_message`` carries no default — the design-review pin: the - template is always an explicit argument. ``todo`` is required and + ``commit_message`` defaults to ``None`` — ``None`` applies the + built-in domain default template, so every caller (the CLI flags, + the configuration section) may omit it. ``todo`` is required and non-empty at the call site; an empty todo is a clean error asking for it. """ @@ -142,20 +146,30 @@ def test_publish_topic_signature(self) -> None: parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) - assert ( - signature.parameters["commit_message"].default is inspect.Parameter.empty - ) + assert signature.parameters["commit_message"].default is None assert signature.parameters["year"].default is None hints = typing.get_type_hints(publish_topic) assert hints == { "branch_name": str, "todo": str, "base_ref": str, - "commit_message": str, + "commit_message": str | None, "year": str | None, "return": str, } + def test_publish_topic_default_template_binds(self) -> None: + """The template-free call shape binds — ``commit_message=None``. + + The plan's pinned call: ``publish_topic("b", "t", "origin/main", + commit_message=None, year="2026")`` must keep binding after the + rework — the default moved into the domain, so omitting the + template is the supported call, not an error. + """ + assert inspect.signature(publish_topic).bind( + "b", "t", "origin/main", commit_message=None, year="2026" + ) + def test_no_working_copy_write_in_publishing(self) -> None: """A shallow source guardrail against working-copy writes. @@ -280,10 +294,10 @@ def test_publish_topic_commits_todo_file( # noqa: PLR0913, PLR0917 — the para ) assert "\n" not in result - def test_publish_topic_empty_todo_clean_error_before_mutations( + def test_publish_topic_empty_todo_clean_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """An empty todo is one clean error — before every decision and mutation. + """An empty todo is one clean error — before every oracle and git call. The gate sits between the slug normalization and the current-branch check, so not a single git mutation and not even the origin probe @@ -301,8 +315,12 @@ def test_publish_topic_empty_todo_clean_error_before_mutations( "the fast path needs a non-empty todo" " — pass the text or enter it interactively" ) - cycle.commit_file_on_base.assert_not_called() + cycle.resolve_current_branch_name.assert_not_called() + cycle.check_branch_occupancy.assert_not_called() + cycle.check_slug_occupancy.assert_not_called() cycle.origin_configured.assert_not_called() + cycle.resolve_ref_commit.assert_not_called() + cycle.commit_file_on_base.assert_not_called() _assert_no_mutation(cycle) def test_publish_topic_current_branch_hosting_slug_is_clean_error( @@ -325,7 +343,12 @@ def test_publish_topic_current_branch_hosting_slug_is_clean_error( def test_publish_topic_conflict_without_terminal_fails_with_board_hint( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """An occupancy conflict without a terminal: the reason and the hint.""" + """An occupancy conflict: the reason and the hint, no re-ask. + + The re-ask cycle is abolished — the conflict is one clean error on + every terminal kind; the non-interactive call here is the plainest + instance of the rule. + """ monkeypatch.chdir(tmp_path) _non_interactive(monkeypatch) cycle = _wire_cycle(monkeypatch) @@ -476,51 +499,107 @@ def test_publish_topic_detached_head_does_not_interfere( cycle.resolve_current_branch_name.assert_called_once_with() cycle.push_branch.assert_called_once_with("Feature/Foo_Bar") - def test_publish_topic_reask_restarts_the_fast_cycle( + @pytest.mark.parametrize( + ("commit_message", "expected_message"), + [ + pytest.param(None, "goga: create topic feature-foo", id="default"), + pytest.param("feat({slug}): todo", "feat(feature-foo): todo", id="template"), + ], + ) + def test_publish_topic_default_message_when_none( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + commit_message: str | None, + expected_message: str, + ) -> None: + """A ``None`` template applies the built-in domain default. + + The default moved into the domain: the CLI and the configuration + pass ``None`` when neither provides a template, and the built-in + ``goga: create topic {slug}`` is substituted with the slug like any + other template; an explicit template keeps its own substitution. + """ + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.resolve_ref_commit.return_value = "c0" + + result = publish_topic( + "feature-foo", "Fix.", "origin/main", commit_message, year="2026" + ) + + assert cycle.commit_file_on_base.call_args.args[3] == expected_message + assert result == "Created branch feature-foo and published topic 2026/feature-foo" + + def test_publish_topic_no_reask_on_conflict( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A re-asked name restarts the whole cycle — new branch, new slug.""" + """An occupancy conflict on a terminal is a clean error — no prompt. + + The re-ask cycle is abolished: ``click.prompt`` must never run, the + reason carries the board hint, and no commit is built. + """ monkeypatch.chdir(tmp_path) - prompt = _interactive(monkeypatch, ["Feature/Baz"]) + prompt = _terminal(monkeypatch) cycle = _wire_cycle(monkeypatch) - cycle.check_slug_occupancy.side_effect = [ - "topic 'feature-foo-bar' of 2026 is already hosted by branch 'alpha'", - None, - ] + cycle.check_branch_occupancy.return_value = "branch 'feature-foo' already exists" - result = publish_topic("Feature/Foo_Bar", "T", "origin/main", "m", "2026") + with pytest.raises(click.ClickException) as raised: + publish_topic("feature-foo", "T", "origin/main", "m", "2026") - assert result == "Created branch Feature/Baz and published topic 2026/feature-baz" - assert prompt.call_count == 1 - assert prompt.call_args.args[0] == "New branch name" - cycle.create_branch_at_commit.assert_called_once_with("Feature/Baz", "<commit>") - assert ( - cycle.commit_file_on_base.call_args.args[1] - == ".goga/history/2026/feature-baz/todo.md" + assert raised.value.message == ( + "branch 'feature-foo' already exists" + " — run 'goga topics board' to see the board" ) - assert cycle.commit_file_on_base.call_args.args[3] == "m" - cycle.push_branch.assert_called_once_with("Feature/Baz") + prompt.assert_not_called() + _assert_no_mutation(cycle) + + @pytest.mark.parametrize( + "todo", + [ + pytest.param("Fix.\n", id="editor-form"), + pytest.param("Fix.", id="bare-value"), + ], + ) + def test_publish_topic_editor_todo_single_trailing_newline( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, todo: str + ) -> None: + """The committed todo carries exactly one trailing newline (fix D6). + + An editor-sourced todo already ends with ``\\n`` — click's + read-back — so the former unconditional ``f"{todo}\\n"`` published + a blank trailing line; the conditional rule keeps exactly one + newline for both the editor form and a bare value. + """ + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + + publish_topic("feature-foo", todo, "origin/main", year="2026") + + assert cycle.commit_file_on_base.call_args.args[2] == "Fix.\n" - def test_publish_topic_empty_slug_reasks( + def test_publish_topic_empty_slug_is_clean_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A name that normalizes to nothing re-asks — no board hint, no mutation.""" + """A name that normalizes to nothing is one clean error — no re-ask. + + The reason names the entered name; nothing is probed, prompted, or + mutated. + """ monkeypatch.chdir(tmp_path) - prompt = _interactive(monkeypatch, ["Feature/Baz"]) + prompt = _terminal(monkeypatch) cycle = _wire_cycle(monkeypatch) - result = publish_topic("///", "T", "origin/main", "m") + with pytest.raises(click.ClickException) as raised: + publish_topic("///", "T", "origin/main", "m") - assert prompt.call_count == 1 - assert prompt.call_args.args[0] == "New branch name" - cycle.create_branch_at_commit.assert_called_once_with("Feature/Baz", "<commit>") - assert ( - cycle.commit_file_on_base.call_args.args[1] - == ".goga/history/2026/feature-baz/todo.md" + assert raised.value.message == ( + "branch name '///' normalizes to an empty topic slug" ) - assert cycle.commit_file_on_base.call_args.args[2] == "T\n" - cycle.push_branch.assert_called_once_with("Feature/Baz") - assert result == "Created branch Feature/Baz and published topic 2026/feature-baz" + prompt.assert_not_called() + cycle.resolve_current_branch_name.assert_not_called() + cycle.check_branch_occupancy.assert_not_called() + _assert_no_mutation(cycle) # --- Infrastructure boundary --- From 938f17ff59c3deed71be96644bc80c7fdca29ec2 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 20:38:06 +0000 Subject: [PATCH 185/229] =?UTF-8?q?feat:=20add=20enter=5Ftopic=5Ftodo=20to?= =?UTF-8?q?=20goga/topics=20=E2=80=94=20shared=20single-trailing-newline?= =?UTF-8?q?=20todo=20write?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- goga/topics/__init__.py | 8 ++- goga/topics/creation.py | 97 ++++++++++++++++++++++++----- tests/topics/test_creation.py | 107 +++++++++++++++++++++++++++++++- tests/topics/test_publishing.py | 1 + 4 files changed, 197 insertions(+), 16 deletions(-) diff --git a/goga/topics/__init__.py b/goga/topics/__init__.py index 62723f3a..94c39947 100644 --- a/goga/topics/__init__.py +++ b/goga/topics/__init__.py @@ -14,7 +14,12 @@ """ from .board import BoardRecord, collect_topic_board -from .creation import check_branch_occupancy, check_slug_occupancy, create_topic +from .creation import ( + check_branch_occupancy, + check_slug_occupancy, + create_topic, + enter_topic_todo, +) from .deletion import DeleteTarget, delete_topics, resolve_delete_targets from .ensuring import ensure_topic from .publishing import publish_topic @@ -34,6 +39,7 @@ "create_topic", "delete_topics", "ensure_topic", + "enter_topic_todo", "publish_topic", "resolve_delete_targets", "resolve_switch_candidates", diff --git a/goga/topics/creation.py b/goga/topics/creation.py index c1a9abad..76769804 100644 --- a/goga/topics/creation.py +++ b/goga/topics/creation.py @@ -1,19 +1,21 @@ -"""The fresh-work creation of the topics domain. +"""The fresh-work creation and the todo entry of the topics domain. The entities declared in the cell CODEMANIFEST with ``location: creation.py``: the three-oracle occupancy check of a fresh-work name, the branch-tree slug oracle that reads the topic directory of a slug across every branch tree of the inventory — without checkout, so a topic -hosted only on a branch (or only on ``origin``) is visible — and the +hosted only on a branch (or only on ``origin``) is visible — the orchestrator that creates the branch — named exactly as entered — together with its topic directory of the year and, when a non-empty -todo is given, its topic todo file. Topic identity and addressing belong -to the history facade; the bounded git mutation belongs to the nested git -cell. Git -infrastructure failures surface as ``click.ClickException`` — the -clean-error boundary of the domain; the interactive moments follow the -``click`` practice. The status scale is never assembled here — creation is -not a status consumer. +todo is given, its topic todo file, and the todo entry of a topic — the +editor session of the nested editor cell over the topic's todo.md and +the write of the saved text, without a commit. Topic identity and +addressing belong to the history facade; the bounded git mutation belongs +to the nested git cell; the editor session belongs to the nested editor +cell. Git infrastructure failures surface as ``click.ClickException`` — +the clean-error boundary of the domain; the interactive moments follow +the ``click`` practice. The status scale is never assembled here — +creation is not a status consumer. """ from __future__ import annotations @@ -32,6 +34,7 @@ resolve_topic_file, topic_exists, ) +from .editor import edit_text from .git import create_and_switch_branch, list_branch_refs, read_ref_tree_paths # The board hint of an occupancy conflict — where the occupied names are @@ -206,6 +209,46 @@ def create_topic( ) from exc +def enter_topic_todo(topic: str, year: str | None = None) -> bool: + """Enter the todo of a topic — the editor session with the topic's + todo.md and the write of the saved text, without a commit. + + Args: + topic: Topic input — a branch name or an already-normalized slug. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + True when the saved text was written; False when the entry was + cancelled. + + Algorithm: + 1. Resolve the todo.md path of the topic via ``resolve_topic_file``; + an existing file provides the initial text + 2. Open the editor session via ``edit_text`` with the initial text + 3. A cancelled entry -> False — the file stays untouched + 4. The saved text -> write todo.md as entered plus a single + trailing newline, encoded UTF-8, without a commit -> True + + Requirements: + The write is the last action — nothing follows it. + The topic directory exists — directory creation belongs to the + caller. + + Constraints: + Do not create the topic directory. + Do not commit the write. + + Raises: + click.ClickException: a failed editor session (the editor cell's + own clean error), or a filesystem failure of the read or the + write. + """ + try: + return _enter_topic_todo(topic, year) + except OSError as exc: + raise click.ClickException(f"cannot write the todo file: {exc}") from exc + + def _occupancy_conflict( branch_name: str, slug: str, year: str | None ) -> str | None: @@ -301,21 +344,47 @@ def _create_topic(branch_name: str, year: str | None, todo: str | None) -> str: return f"Created branch {branch_name} and topic {resolved_year}/{slug}" +def _enter_topic_todo(topic: str, year: str | None) -> bool: + """Run the traced todo-entry procedure — the unwrapped orchestration. + + Args: + topic: Topic input — a branch name or an already-normalized slug. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + True when the saved text was written; False when the entry was + cancelled. + """ + resolved_year = year or current_year() + + path = resolve_topic_file(topic, "todo.md", resolved_year) + initial = path.read_text(encoding="utf-8") if path.exists() else None + + saved = edit_text(initial) + + if saved is None: + return False + + _write_todo(topic, resolved_year, saved) + return True + + def _write_todo(name: str, year: str, todo: str) -> None: """Write the topic todo file of a topic directory. The file carries the todo as entered plus a single trailing newline, - encoded UTF-8 — created when absent, overwritten when present. The topic + encoded UTF-8 — created when absent, overwritten when present; a text + that already ends in a newline keeps exactly that one. The topic directory must already exist; only directories are created here. Args: name: Topic input — a branch name or an already-normalized slug. year: Year as four digits. - todo: Multi-line todo of the fresh work as entered by the user. + todo: Multi-line todo text as entered — a fresh-work value or the + editor session's saved text. """ - resolve_topic_file(name, "todo.md", year).write_text( - f"{todo}\n", encoding="utf-8" - ) + content = todo if todo.endswith("\n") else f"{todo}\n" + resolve_topic_file(name, "todo.md", year).write_text(content, encoding="utf-8") def _reask(reason: str, hint: str = "") -> str: diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index 2d503691..e049c377 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -7,12 +7,16 @@ a topic slug - ``create_topic(branch_name, year, todo)`` — the fresh-work creation procedure with its optional topic todo file +- ``enter_topic_todo(topic, year)`` — the editor session over the topic's + todo.md and the write of the saved text, without a commit The git boundary is mocked at the import point per the ``convention`` practice — no git binary and no repository are touched. The filesystem scenarios (the topic oracle and the created directory) run against ``tmp_path`` with the real history path routines; the scale is never assembled — creation -is not a status consumer. +is not a status consumer. The editor session is mocked with a shell script +exported as ``$EDITOR`` per the ``editor`` practice and the TTY detection +with a ``sys.stdin`` stand-in — a real editor never launches in tests. """ from __future__ import annotations @@ -32,6 +36,7 @@ check_slug_occupancy, create_topic, creation, + enter_topic_todo, ) from goga.topics.git import BranchRef @@ -78,6 +83,22 @@ def _topic_dir(cwd: Path, year: str, slug: str) -> Path: return path +def _editor_script(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, body: str) -> None: + """Export ``$EDITOR`` as an executable shell script running ``body``.""" + editors = tmp_path / "editors" + editors.mkdir(exist_ok=True) + script = editors / "editor-mock.sh" + script.write_text(f"#!/bin/sh\n{body}\n", encoding="utf-8") + script.chmod(0o755) + monkeypatch.delenv("VISUAL", raising=False) + monkeypatch.setenv("EDITOR", str(script)) + + +def _tty(monkeypatch: pytest.MonkeyPatch) -> None: + """Make stdin a terminal — the editor session launches.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + + def _wire_slug_oracle( monkeypatch: pytest.MonkeyPatch, inventory: list[BranchRef], @@ -106,6 +127,7 @@ def test_entities_are_importable_from_the_cell_facade(self) -> None: assert cell.create_topic is create_topic assert cell.check_branch_occupancy is check_branch_occupancy assert cell.check_slug_occupancy is check_slug_occupancy + assert cell.enter_topic_todo is enter_topic_todo expected = { "BoardRecord", "DeleteTarget", @@ -116,6 +138,7 @@ def test_entities_are_importable_from_the_cell_facade(self) -> None: "create_topic", "delete_topics", "ensure_topic", + "enter_topic_todo", "publish_topic", "resolve_delete_targets", "resolve_switch_candidates", @@ -156,6 +179,23 @@ def test_check_branch_occupancy_signature(self) -> None: "return": str | None, } + def test_enter_topic_todo_signature(self) -> None: + """``enter_topic_todo(topic, year=None) -> bool`` — binds as declared.""" + signature = inspect.signature(enter_topic_todo) + assert list(signature.parameters) == ["topic", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(enter_topic_todo) + assert hints == { + "topic": str, + "year": str | None, + "return": bool, + } + signature.bind("feature-foo", year="2026") + def test_create_topic_signature(self) -> None: """``create_topic(branch_name, year=None, todo=None) -> str``.""" signature = inspect.signature(create_topic) @@ -696,6 +736,71 @@ def test_create_topic_todo_survives_reask( assert todo_file.read_text(encoding="utf-8") == "T\n" +# --- Logic tests: the todo entry of a topic --- + + +class TestEnterTopicTodo: + def test_enter_topic_todo_edits_existing_file( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An existing todo.md seeds the session; the saved text overwrites it. + + Variant: a cancelled session — an editor that never writes — + returns False and leaves the file verbatim. + """ + monkeypatch.chdir(tmp_path) + todo_file = _topic_dir(tmp_path, "2026", "feature-foo") / "todo.md" + _tty(monkeypatch) + + todo_file.write_text("Old line.\n", encoding="utf-8") + _editor_script(monkeypatch, tmp_path, "printf 'New line.\\n' > \"$1\"") + assert enter_topic_todo("feature-foo", year="2026") is True + assert todo_file.read_text(encoding="utf-8") == "New line.\n" + + todo_file.write_text("Old line.\n", encoding="utf-8") + _editor_script(monkeypatch, tmp_path, "exit 0") + assert enter_topic_todo("feature-foo", year="2026") is False + assert todo_file.read_text(encoding="utf-8") == "Old line.\n" + + def test_enter_topic_todo_seeds_existing_content( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The session starts from the existing todo.md — the prefill proves it. + + The editor script copies the prefilled temporary file aside instead + of editing: the prefill must carry the existing content verbatim + (the session's own normalization keeps the trailing newline). + """ + monkeypatch.chdir(tmp_path) + todo_file = _topic_dir(tmp_path, "2026", "feature-foo") / "todo.md" + todo_file.write_text("Old line.\n", encoding="utf-8") + prefill = tmp_path / "prefill.txt" + _editor_script(monkeypatch, tmp_path, f"cp \"$1\" '{prefill}'") + _tty(monkeypatch) + + assert enter_topic_todo("feature-foo", year="2026") is False + assert prefill.read_text(encoding="utf-8") == "Old line.\n" + assert todo_file.read_text(encoding="utf-8") == "Old line.\n" + + def test_enter_topic_todo_missing_file_empty_entry( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A missing todo.md starts from an empty entry — the fresh-entry path. + + The topic directory exists without the file — the state after + ``ensure`` on a branch whose tree hosts no todo yet. + """ + monkeypatch.chdir(tmp_path) + _topic_dir(tmp_path, "2026", "feature-foo") + _editor_script(monkeypatch, tmp_path, "printf 'First.\\n' > \"$1\"") + _tty(monkeypatch) + + assert enter_topic_todo("feature-foo", year="2026") is True + + todo_file = tmp_path / ".goga" / "history" / "2026" / "feature-foo" / "todo.md" + assert todo_file.read_text(encoding="utf-8") == "First.\n" + + # --- Infrastructure boundary --- diff --git a/tests/topics/test_publishing.py b/tests/topics/test_publishing.py index afb0517f..7fd79723 100644 --- a/tests/topics/test_publishing.py +++ b/tests/topics/test_publishing.py @@ -117,6 +117,7 @@ def test_publish_topic_is_importable_from_the_cell_facade(self) -> None: "create_topic", "delete_topics", "ensure_topic", + "enter_topic_todo", "publish_topic", "resolve_delete_targets", "resolve_switch_candidates", From a0c751dd663e44ea8e2c793469a10a26d9816608 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 20:45:27 +0000 Subject: [PATCH 186/229] =?UTF-8?q?feat:=20rework=20switch=5Ftopic=20?= =?UTF-8?q?=E2=80=94=20todo=20flag=20with=20post-switch=20todo=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- goga/commands/topics/topics.py | 2 +- goga/topics/switching.py | 102 +++++++--- tests/commands/topics/test_topics_command.py | 4 +- tests/integration/test_topic_workflows.py | 16 +- tests/topics/test_switching.py | 186 +++++++++++++++++-- 5 files changed, 262 insertions(+), 48 deletions(-) diff --git a/goga/commands/topics/topics.py b/goga/commands/topics/topics.py index d0074181..eb56e8a0 100644 --- a/goga/commands/topics/topics.py +++ b/goga/commands/topics/topics.py @@ -266,6 +266,6 @@ def switch(scope: _TopicsScope, identifier: str) -> None: needed. One result line on stdout; no pipeline is launched — continuation is a separate command. """ - line = switch_topic(identifier, scope.year) + line = switch_topic(identifier, year=scope.year) click.echo(line) click.get_current_context().exit(0) diff --git a/goga/topics/switching.py b/goga/topics/switching.py index 24b148e8..a06c15d0 100644 --- a/goga/topics/switching.py +++ b/goga/topics/switching.py @@ -4,7 +4,9 @@ ``location: switching.py``: one candidate of a switch-identifier resolution, the read-only resolver walking the same ref trees as the board, and the orchestrator that brings the repository onto the chosen -host branch by purely switching — the shared switch tail also serves the +host branch by purely switching — with the todo flag it enters the todo +of the switched topic through the entry of ``creation.py`` after the +switch. The shared switch tail also serves the ensure orchestration of ``ensuring.py``. Topic identity and statuses belong to the history facade; the bounded git mutations belong to the nested git cell. Git infrastructure failures and the fatal scale-assembly ``ImportError`` @@ -28,6 +30,7 @@ resolve_current_branch_name, ) from .board import _current_branch_topic, _short_name, _year_topics_by_ref +from .creation import enter_topic_todo from .git import ( BranchRef, checkout_local_branch, @@ -120,12 +123,17 @@ def resolve_switch_candidates( raise click.ClickException(str(exc)) from exc -def switch_topic(identifier: str, year: str | None = None) -> str: - """Bring the repository onto the branch hosting the requested work. +def switch_topic( + identifier: str, todo: bool = False, year: str | None = None +) -> str: + """Bring the repository onto the branch hosting the requested work; + with the todo flag, enter the todo of the switched topic after the + switch. Args: identifier: The user input — a branch name, a topic slug, or their prefix. + todo: ``True`` enters the todo of the switched topic. year: Optional year as four digits; ``None`` means the current year. Returns: @@ -133,13 +141,16 @@ def switch_topic(identifier: str, year: str | None = None) -> str: checkout, or the branch creation. Algorithm: - 1. Resolve the candidates via ``resolve_switch_candidates`` - 2. No candidate -> clean error with a hint to the board - 3. One candidate -> take it; several -> print the numbered list with - statuses and prompt for a number, or fail with the list when no - interactive input is available + 1. ``todo`` without an interactive terminal -> clean error before + any switching + 2. Resolve the candidates via ``resolve_switch_candidates``; none -> + clean error with a hint to the board; several -> print the + numbered list with statuses and prompt for a number, or fail + with the list when no interactive input is available + 3. ``todo`` and the chosen candidate hosts no topic -> clean error + — switching creates nothing 4. Already on the hosting branch -> idempotent success, no mutation, - no cleanliness probe + no cleanliness probe; with ``todo`` the entry still runs 5. A mutation is needed -> probe the working tree cleanliness first via ``is_working_tree_clean``; a dirty tree is a clean error naming the reason and the next step — commit or stash the @@ -147,7 +158,9 @@ def switch_topic(identifier: str, year: str | None = None) -> str: 6. Local host -> check out the branch via ``checkout_local_branch``; remote-only host -> create the local branch from the remote-tracking ref via ``create_branch_from_remote_tracking`` - 7. Return the single result line + 7. With ``todo`` -> enter the todo of the topic via + ``enter_topic_todo`` — after the switch + 8. Return the single result line Requirements: Every mutation is local — no network, no fetch, no push. @@ -155,21 +168,24 @@ def switch_topic(identifier: str, year: str | None = None) -> str: The result is exactly one line. Constraints: + Do not create a topic for a branch without one. + Do not commit the todo write. Do not manage the stages of the hosting pipeline — continuation belongs to the pipeline itself. Do not return to the previous branch — the switch is the outcome. Raises: - click.ClickException: no branch hosts the identifier, several - candidates without an interactive terminal, a dirty working - tree, a git infrastructure failure (its stderr when git reports - one, or a missing git binary), or the fatal ``ImportError`` of - the scale assembly. + click.ClickException: ``todo`` without an interactive terminal, no + branch hosts the identifier, several candidates without an + interactive terminal, the chosen candidate hosts no topic under + ``todo``, a dirty working tree, a git infrastructure failure + (its stderr when git reports one, or a missing git binary), or + the fatal ``ImportError`` of the scale assembly. click.Abort: Ctrl-C or EOF at the selection prompt — the repository is left untouched. """ try: - return _switch_topic(identifier, year) + return _switch_topic(identifier, todo, year) except subprocess.CalledProcessError as exc: detail = (exc.stderr or "").strip() or str(exc) raise click.ClickException(f"git failed: {detail}") from exc @@ -308,16 +324,20 @@ def _unique_candidates(candidates: list[SwitchCandidate]) -> list[SwitchCandidat return unique -def _switch_topic(identifier: str, year: str | None) -> str: +def _switch_topic(identifier: str, todo: bool, year: str | None) -> str: """Run the traced switch procedure — the unwrapped orchestration. Args: identifier: The user input as entered. + todo: ``True`` enters the todo of the switched topic after the switch. year: Optional year as four digits; ``None`` means the current year. Returns: The single result line of the outcome. """ + if todo and not sys.stdin.isatty(): + raise click.ClickException("the todo entry needs an interactive terminal") + candidates = resolve_switch_candidates(identifier, year) if not candidates: @@ -325,13 +345,25 @@ def _switch_topic(identifier: str, year: str | None) -> str: f"no branch hosts {identifier!r} — run 'goga topics board' to see the board" ) - return _switch_to_candidate(candidates) + chosen = _take_candidate(candidates) + + if todo and chosen.topic is None: + raise click.ClickException( + f"branch '{chosen.branch}' hosts no topic — switching creates nothing" + ) + + line = _apply_candidate(chosen) + + if todo: + enter_topic_todo(chosen.topic, year) + + return line def _switch_to_candidate(candidates: list[SwitchCandidate]) -> str: """Take the resolved candidates onto the working copy — the shared switch - tail of ``switch_topic`` and the ensure orchestration of - ``ensuring.py``. + tail of the ensure orchestration of ``ensuring.py`` (until its own + rework): the candidate choice followed by the mutation tail. Args: candidates: The non-empty candidate list of the resolution. @@ -344,8 +376,36 @@ def _switch_to_candidate(candidates: list[SwitchCandidate]) -> str: dirty working tree when a mutation is needed. click.Abort: Ctrl-C or EOF at the selection prompt. """ - chosen = candidates[0] if len(candidates) == 1 else _choose_candidate(candidates) + return _apply_candidate(_take_candidate(candidates)) + + +def _take_candidate(candidates: list[SwitchCandidate]) -> SwitchCandidate: + """Narrow the resolved candidates to the chosen one. + + Args: + candidates: The non-empty candidate list of the resolution. + + Returns: + The single candidate — taken directly when unambiguous, chosen by + the numbered selection otherwise. + """ + return candidates[0] if len(candidates) == 1 else _choose_candidate(candidates) + + +def _apply_candidate(chosen: SwitchCandidate) -> str: + """Bring the working copy onto the chosen candidate — the mutation tail + shared by ``switch_topic`` and ``_switch_to_candidate``. + + Args: + chosen: The chosen candidate of the resolution. + + Returns: + The single result line of the outcome. + Raises: + click.ClickException: a dirty working tree when a mutation is + needed. + """ if chosen.current: return f"Already on branch {chosen.branch}" if not is_working_tree_clean(): diff --git a/tests/commands/topics/test_topics_command.py b/tests/commands/topics/test_topics_command.py index c7db96f2..c7e0383d 100644 --- a/tests/commands/topics/test_topics_command.py +++ b/tests/commands/topics/test_topics_command.py @@ -450,7 +450,7 @@ def test_switch_echoes_the_domain_result_line(self) -> None: ) as mock_switch: result = CliRunner().invoke(topics, ["switch", "feat-a"]) assert result.exit_code == 0 - mock_switch.assert_called_once_with("feat-a", None) + mock_switch.assert_called_once_with("feat-a", year=None) assert result.output.splitlines() == ["Switched to branch feat/a"] def test_switch_receives_the_scoped_year(self) -> None: @@ -458,7 +458,7 @@ def test_switch_receives_the_scoped_year(self) -> None: with mock.patch.object(_topics_module, "switch_topic", return_value="Already on branch feat/a") as mock_switch: result = CliRunner().invoke(topics, ["--year", "2025", "switch", "feat-a"]) assert result.exit_code == 0 - mock_switch.assert_called_once_with("feat-a", "2025") + mock_switch.assert_called_once_with("feat-a", year="2025") assert result.output.splitlines() == ["Already on branch feat/a"] @pytest.mark.parametrize(("subcommand", "argument"), [("create", "branch_name"), ("switch", "identifier")]) diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index 408ff18f..ed2f800a 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -413,7 +413,7 @@ def test_switch_on_current_host_is_idempotent_without_mutations( mock.patch.object(topics_switching, "create_branch_from_remote_tracking") as create_branch, ): clean_probe.return_value = True - line = switch_topic("feat-a", "2025") + line = switch_topic("feat-a", year="2025") assert line == "Already on branch feat-a" assert clean_probe.called is False @@ -428,7 +428,7 @@ def test_switch_by_slug_checks_out_local_host( _add_solo_branch(tmp_path) monkeypatch.chdir(tmp_path) - line = switch_topic("solo", "2025") + line = switch_topic("solo", year="2025") assert line == "Switched to branch solo" assert _current_branch(tmp_path) == "solo" @@ -442,7 +442,7 @@ def test_switch_by_branch_name_hosting_several_topics_switches( monkeypatch.chdir(tmp_path) monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) - line = switch_topic("feat-b", "2025") + line = switch_topic("feat-b", year="2025") assert line == "Switched to branch feat-b" assert _current_branch(tmp_path) == "feat-b" @@ -463,7 +463,7 @@ def test_switch_ambiguous_slug_lists_candidates_without_terminal( monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) with pytest.raises(click.ClickException, match=r"(?s)1\) one.*2\) two"): - switch_topic("shared", "2025") + switch_topic("shared", year="2025") assert _current_branch(tmp_path) == "feat-a" @@ -481,8 +481,8 @@ def test_switch_by_slug_with_pushed_twin_switches_and_is_idempotent( _git(tmp_path, "switch", "-q", "feat-a") monkeypatch.chdir(tmp_path) - line = switch_topic("work-x", "2025") - idempotent = switch_topic("work-x", "2025") + line = switch_topic("work-x", year="2025") + idempotent = switch_topic("work-x", year="2025") assert line == "Switched to branch work/x" assert idempotent == "Already on branch work/x" @@ -497,7 +497,7 @@ def test_switch_by_slug_creates_branch_from_remote_tracking( _git(tmp_path, "branch", "-D", "feat-a") monkeypatch.chdir(tmp_path) - line = switch_topic("feat-a", "2025") + line = switch_topic("feat-a", year="2025") assert line == "Created branch feat-a from origin/feat-a" assert _current_branch(tmp_path) == "feat-a" @@ -512,7 +512,7 @@ def test_switch_refuses_dirty_working_tree( monkeypatch.chdir(tmp_path) with pytest.raises(click.ClickException, match="dirty"): - switch_topic("solo", "2025") + switch_topic("solo", year="2025") assert _current_branch(tmp_path) == "feat-a" diff --git a/tests/topics/test_switching.py b/tests/topics/test_switching.py index 79cde256..d48a9cc7 100644 --- a/tests/topics/test_switching.py +++ b/tests/topics/test_switching.py @@ -26,6 +26,7 @@ import click import pytest +from goga.history import current_year from goga.history.statuses import StatusScale from goga.topics import ( SwitchCandidate, @@ -84,6 +85,22 @@ def _non_interactive(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) +def _interactive(monkeypatch: pytest.MonkeyPatch) -> None: + """Make stdin a terminal — the todo entry of the switch is reachable.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + + +def _wire_entry(monkeypatch: pytest.MonkeyPatch) -> mock.Mock: + """Patch the todo entry at its import point in ``switching``. + + Returns: + The entry — a recording mock. + """ + entry = mock.Mock(return_value=True) + monkeypatch.setattr(switching, "enter_topic_todo", entry) + return entry + + def _working_copy_topic(cwd: Path, year: str, slug: str, artifacts: list[str]) -> None: """Create the working-copy topic directory with its artifact files.""" for artifact in artifacts: @@ -161,15 +178,16 @@ def test_resolve_switch_candidates_signature(self) -> None: } def test_switch_topic_signature(self) -> None: - """``switch_topic(identifier, year=None) -> str``.""" + """``switch_topic(identifier, todo=False, year=None) -> str``.""" signature = inspect.signature(switch_topic) - assert list(signature.parameters) == ["identifier", "year"] + assert list(signature.parameters) == ["identifier", "todo", "year"] assert all( parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) + assert signature.parameters["todo"].default is False assert signature.parameters["year"].default is None hints = typing.get_type_hints(switch_topic) - assert hints == {"identifier": str, "year": str | None, "return": str} + assert hints == {"identifier": str, "todo": bool, "year": str | None, "return": str} # --- Logic tests: resolution --- @@ -369,7 +387,7 @@ def test_switch_topic_single_candidate_switches( _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=True) - result = switch_topic("feat/a", "2026") + result = switch_topic("feat/a", year="2026") assert result == "Switched to branch feat/a" checkout.assert_called_once_with("feat/a") @@ -392,7 +410,7 @@ def test_switch_topic_slug_with_pushed_twin_switches_local( _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") _cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) - result = switch_topic("feat-a", "2026") + result = switch_topic("feat-a", year="2026") assert result == "Switched to branch feat/a" checkout.assert_called_once_with("feat/a") @@ -421,7 +439,7 @@ def test_switch_topic_branch_hosting_several_topics_switches( _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "other") _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=True) - result = switch_topic("main", "2026") + result = switch_topic("main", year="2026") assert result == "Switched to branch main" checkout.assert_called_once_with("main") @@ -462,7 +480,7 @@ def test_switch_topic_dirty_tree_clean_error( _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=False) with pytest.raises(click.ClickException) as raised: - switch_topic("feat/a", "2026") + switch_topic("feat/a", year="2026") assert raised.value.message == "working tree is dirty — commit or stash before switching" checkout.assert_not_called() @@ -480,7 +498,7 @@ def test_switch_topic_remote_only_creates_branch( _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) _cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) - result = switch_topic("feat-b", "2026") + result = switch_topic("feat-b", year="2026") assert result == "Created branch feat/b from origin/feat/b" checkout.assert_not_called() @@ -509,7 +527,7 @@ def test_switch_topic_multiple_candidates_prompt( prompt = mock.Mock(return_value=2) monkeypatch.setattr(click, "prompt", prompt) - result = switch_topic("feat", "2026") + result = switch_topic("feat", year="2026") assert result == "Switched to branch feat/ab" checkout.assert_called_once_with("feat/ab") @@ -544,7 +562,7 @@ def test_switch_topic_prompt_rejects_out_of_range_input( answers = iter(["9", "2"]) monkeypatch.setattr(click.termui, "visible_prompt_func", lambda _text: next(answers)) - result = switch_topic("feat", "2026") + result = switch_topic("feat", year="2026") assert result == "Switched to branch feat/ab" checkout.assert_called_once_with("feat/ab") @@ -592,7 +610,7 @@ def test_switch_topic_non_interactive_multiple_candidates_fails_with_list( monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) with pytest.raises(click.ClickException) as raised: - switch_topic("feat", "2026") + switch_topic("feat", year="2026") assert "feat/a" in raised.value.message assert "feat/ab" in raised.value.message @@ -603,6 +621,142 @@ def test_switch_topic_non_interactive_multiple_candidates_fails_with_list( creation.assert_not_called() +# --- Logic tests: switching with the todo entry --- + + +class TestSwitchTopicTodo: + def test_switch_topic_todo_enters_after_switch( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """``todo=True``: the checkout runs first, then the entry of the switched topic.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feature-foo", remote=False), + BranchRef(name="main", remote=False), + ] + trees = { + "feature-foo": [".goga/history/2026/feature-foo/plan.md"], + "main": ["README.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=True) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + order = mock.Mock() + order.attach_mock(checkout, "checkout") + order.attach_mock(entry, "entry") + + result = switch_topic("feature-foo", todo=True, year="2026") + + assert result == "Switched to branch feature-foo" + entry.assert_called_once_with("feature-foo", "2026") + assert order.mock_calls == [ + mock.call.checkout("feature-foo"), + mock.call.entry("feature-foo", "2026"), + ] + + def test_switch_topic_todo_idempotent_still_enters( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Already on the host under ``todo=True``: the idempotent line — the + entry still runs, without a cleanliness probe or a checkout.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, current_year(), "feature-foo", ["plan.md"]) + inventory = [BranchRef(name="feature-foo", remote=False)] + trees = {"feature-foo": [".goga/history/2026/feature-foo/plan.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "feature-foo") + cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + + result = switch_topic("feature-foo", todo=True) + + assert result == "Already on branch feature-foo" + entry.assert_called_once_with("feature-foo", None) + cleanliness.assert_not_called() + checkout.assert_not_called() + creation.assert_not_called() + + def test_switch_topic_todo_candidate_without_topic_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """``todo=True`` on a candidate without a topic: a clean error after + the choice — switching creates nothing.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feature-foo", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feature-foo": ["README.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + + with pytest.raises(click.ClickException, match="topic"): + switch_topic("feature-foo", todo=True, year="2026") + + cleanliness.assert_not_called() + checkout.assert_not_called() + creation.assert_not_called() + entry.assert_not_called() + + def test_switch_topic_todo_non_tty_error_before_resolution( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``todo=True`` without a terminal: the clean error fires before any + resolution — ordering, not just the error.""" + monkeypatch.chdir(tmp_path) + _non_interactive(monkeypatch) + resolver = mock.Mock(side_effect=AssertionError("the resolution must not run")) + monkeypatch.setattr(switching, "resolve_switch_candidates", resolver) + + with pytest.raises(click.ClickException, match="interactive"): + switch_topic("anything", todo=True) + + resolver.assert_not_called() + + def test_switch_topic_several_candidates_non_tty_with_todo( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Several candidates, no terminal, ``todo=True``: the todo check + fires first — the error names the terminal, not the candidate list.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="feat/ab", remote=False), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "feat/ab": [".goga/history/2026/feat-ab/prd.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) + cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) + _non_interactive(monkeypatch) + + with pytest.raises(click.ClickException) as raised: + switch_topic("feat", todo=True, year="2026") + + assert "interactive" in raised.value.message + assert "1)" not in raised.value.message + assert "feat/a" not in raised.value.message + cleanliness.assert_not_called() + checkout.assert_not_called() + creation.assert_not_called() + + # --- Infrastructure boundary --- @@ -651,7 +805,7 @@ def test_broken_tool_package_import_surfaces_as_clean_error( monkeypatch.setattr(switching, "assemble_status_scale", mock.Mock(side_effect=broken)) with pytest.raises(click.ClickException) as raised: - switch_topic("feat/a", "2026") + switch_topic("feat/a", year="2026") assert raised.value.message == "package goga_tool_bad failed to import: boom" @@ -676,7 +830,7 @@ def test_switch_mutation_failure_surfaces_as_clean_error( monkeypatch.setattr(switching, "checkout_local_branch", mock.Mock(side_effect=failure)) with pytest.raises(click.ClickException) as raised: - switch_topic("feat/a", "2026") + switch_topic("feat/a", year="2026") assert "error: cannot switch" in raised.value.message @@ -697,7 +851,7 @@ def test_missing_git_binary_at_mutation_surfaces_as_clean_error( monkeypatch.setattr(switching, "is_working_tree_clean", mock.Mock(side_effect=FileNotFoundError("git"))) with pytest.raises(click.ClickException) as raised: - switch_topic("feat/a", "2026") + switch_topic("feat/a", year="2026") assert "git" in raised.value.message @@ -724,7 +878,7 @@ def test_broken_import_after_resolution_surfaces_as_clean_error( monkeypatch.setattr(switching, "is_working_tree_clean", mock.Mock(side_effect=broken)) with pytest.raises(click.ClickException) as raised: - switch_topic("feat/a", "2026") + switch_topic("feat/a", year="2026") assert raised.value.message == "package goga_tool_bad failed to import: boom" @@ -752,7 +906,7 @@ def test_selection_prompt_abort_leaves_repository_untouched( cleanliness, checkout, creation = _wire_mutations(monkeypatch) with pytest.raises(click.Abort): - switch_topic("feat", "2026") + switch_topic("feat", year="2026") cleanliness.assert_not_called() checkout.assert_not_called() From d85e62e484a58ea3ddd6cc09096fe377f4585725 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 20:56:22 +0000 Subject: [PATCH 187/229] =?UTF-8?q?feat:=20rework=20ensure=5Ftopic=20?= =?UTF-8?q?=E2=80=94=20fast=20creation=20from=20current=20HEAD,=20todo=20f?= =?UTF-8?q?lag=20with=20resolution-based=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- goga/topics/ensuring.py | 204 +++++++-- .../pipeline/test_pipeline_dispatch.py | 19 +- tests/topics/test_ensuring.py | 410 ++++++++++++++---- 3 files changed, 501 insertions(+), 132 deletions(-) diff --git a/goga/topics/ensuring.py b/goga/topics/ensuring.py index 9b35e45b..3a42aaf8 100644 --- a/goga/topics/ensuring.py +++ b/goga/topics/ensuring.py @@ -3,10 +3,12 @@ The entity declared in the cell CODEMANIFEST with ``location: ensuring.py``: the combined orchestrator that brings the repository onto the requested work — by switching when a branch hosts the -identifier, by creating the fresh work when nothing does. The resolution -and the switch tail belong to the switching module; the creation fallback -belongs to the creation module. Topic identity and statuses belong to the -history facade; the bounded git mutations belong to the nested git cell. +identifier, by the fast creation from the current HEAD when nothing does; +with the todo flag the todo entry of the ensured work runs after the +switch or the creation. The resolution and the switch orchestration belong +to the switching module; the occupancy oracles and the todo entry belong +to the creation module; the topic-directory creation belongs to the +history facade; the bounded git mutation belongs to the nested git cell. Git infrastructure failures and the fatal scale-assembly ``ImportError`` surface as ``click.ClickException`` — the clean-error boundary of the domain; the interactive moments follow the ``click`` practice. @@ -15,59 +17,89 @@ from __future__ import annotations import subprocess +import sys import click -from .creation import create_topic -from .switching import _switch_to_candidate, resolve_switch_candidates - - -def ensure_topic(identifier: str, year: str | None = None) -> str: +from ..history import ( + current_year, + ensure_topic_dir, + normalize_topic_slug, + resolve_current_branch_name, +) +from .board import _short_name +from .creation import ( + _BOARD_HINT, + check_branch_occupancy, + check_slug_occupancy, + enter_topic_todo, +) +from .git import create_and_switch_branch +from .switching import SwitchCandidate, resolve_switch_candidates, switch_topic + + +def ensure_topic(identifier: str, todo: bool = False, year: str | None = None) -> str: """Bring the repository onto the requested work, creating it when nothing - hosts the identifier. + hosts the identifier; with the todo flag, enter the todo of the work + after the switch or the creation. Args: identifier: The user input — a branch name, a topic slug, or their prefix. + todo: ``True`` enters the todo of the ensured work. year: Optional year as four digits; ``None`` means the current year. Returns: - One line describing the outcome — the idempotent success, the - checkout, the branch creation from a remote-tracking ref, or the - fresh-work creation. + One line describing the outcome — the switch line of the delegated + switch orchestration or the creation line of the fast creation. Algorithm: - 1. Resolve the candidates via ``resolve_switch_candidates`` - 2. No candidate -> create fresh work via ``create_topic`` with the - identifier as the branch name — the occupancy oracles, the re-ask - cycle, and the idempotent current-branch success belong to it - 3. Otherwise -> the switch procedure — the candidate choice, the - idempotent confirmation, the cleanliness probe, and the checkout + 1. ``todo`` without an interactive terminal -> clean error before + any action + 2. Resolve the candidates via ``resolve_switch_candidates`` + 3. No candidate -> the fast creation from the current HEAD: the + slug guard and the occupancy oracles are clean errors, then the + branch named as entered is created and switched to via + ``create_and_switch_branch``, the topic directory of the year is + created via ``ensure_topic_dir``, and with ``todo`` the todo of + the fresh topic is entered — the entry starts only after the + switch + 4. Otherwise -> the switch orchestration via ``switch_topic`` + without the entry; with ``todo`` the hosted topic comes from the + step-2 resolution candidate whose branch is the current branch + read via ``resolve_current_branch_name`` (a remote-tracking + candidate matches by its short name): a hosted topic is entered + via ``enter_topic_todo``; a hosting branch without one gets its + topic directory created via ``ensure_topic_dir`` — an empty slug + of its name is a clean error — then the fresh entry + 5. Return the single result line Requirements: Creation happens only at zero candidates — a resolvable identifier never creates anything. - The result is exactly one line. + The creation always starts from the current HEAD — the + configuration base is never read here. + With ``todo``, no step follows the todo write. Every mutation is local — no network, no fetch, no push. + The result is exactly one line. Constraints: - Do not alter the switch-only contract of ``switch_topic`` — the - topics switch command keeps its stricter behavior. + Do not ask about publication — the fast process publishes nothing. Do not manage the stages of the hosting pipeline — continuation belongs to the pipeline itself. Raises: - click.ClickException: several candidates without an interactive - terminal, a dirty working tree on a switch mutation, an unusable - (empty-slug) or occupied name without a terminal, a git - infrastructure failure (its stderr when git reports one, or a - missing git binary), or the fatal ``ImportError`` of the scale - assembly. - click.Abort: Ctrl-C or EOF at a selection or re-ask prompt — the - repository is left untouched. + click.ClickException: ``todo`` without an interactive terminal, an + unusable (empty-slug) or occupied name of the fast creation, + several candidates without an interactive terminal, a dirty + working tree on a switch mutation, a git infrastructure failure + (its stderr when git reports one, or a missing git binary), or + the fatal ``ImportError`` of the scale assembly. + click.Abort: Ctrl-C or EOF at a selection prompt — the repository + is left untouched. """ try: - return _ensure_topic(identifier, year) + return _ensure_topic(identifier, todo, year) except subprocess.CalledProcessError as exc: detail = (exc.stderr or "").strip() or str(exc) raise click.ClickException(f"git failed: {detail}") from exc @@ -77,19 +109,123 @@ def ensure_topic(identifier: str, year: str | None = None) -> str: raise click.ClickException(str(exc)) from exc -def _ensure_topic(identifier: str, year: str | None) -> str: +def _ensure_topic(identifier: str, todo: bool, year: str | None) -> str: """Run the traced ensure procedure — the unwrapped orchestration. Args: identifier: The user input as entered. + todo: ``True`` enters the todo of the ensured work. year: Optional year as four digits; ``None`` means the current year. Returns: The single result line of the outcome. """ + if todo and not sys.stdin.isatty(): + raise click.ClickException("the todo entry needs an interactive terminal") + candidates = resolve_switch_candidates(identifier, year) if not candidates: - return create_topic(identifier, year) + return _create_fresh_work(identifier, todo, year) + + line = switch_topic(identifier, todo=False, year=year) + + if todo: + _enter_switched_todo(candidates, year) + + return line + + +def _create_fresh_work(identifier: str, todo: bool, year: str | None) -> str: + """Create the fresh work off the current HEAD — the zero-candidate path. + + The branch keeps the name as entered and starts at git's default start + point (the current HEAD); the topic directory takes the normalized + slug. The decisions — the slug guard and the occupancy oracles — + precede the first mutation; the todo entry starts only after the + switch. + + Args: + identifier: The user input as entered — becomes the branch name. + todo: ``True`` enters the todo of the fresh topic after the switch. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The creation line — the branch as entered and the topic of the + normalized slug. + """ + resolved_year = year or current_year() + + slug = normalize_topic_slug(identifier) + if slug == "": + raise click.ClickException(f"branch name '{identifier}' normalizes to an empty topic slug") + + conflict = check_branch_occupancy(identifier, slug, year) + if conflict is None: + conflict = check_slug_occupancy(slug, year) + if conflict is not None: + raise click.ClickException(f"{conflict} — {_BOARD_HINT}") + + create_and_switch_branch(identifier) + ensure_topic_dir(identifier, year) + if todo: + enter_topic_todo(identifier, year) + + return f"Created branch {identifier} and topic {resolved_year}/{slug}" + + +def _enter_switched_todo(candidates: list[SwitchCandidate], year: str | None) -> None: + """Enter the todo of the switched work — the post-switch todo path. + + The hosted topic comes from the step-2 resolution candidate whose + branch is the current branch, never from the normalized current-branch + name: a topic merged into another branch is entered as itself, and no + directory of the hosting branch's name is created. A hosting branch + without a topic gets its topic directory created first — the fresh + entry needs a place to land — unless its name normalizes to an empty + slug, which is a clean error (the history facade's ``ValueError`` on + an empty slug never escapes the module). + + Args: + candidates: The step-2 resolution candidates — the topic lookup + never depends on which candidate the switch chose. + year: Optional year as four digits; ``None`` means the current year. + """ + current = resolve_current_branch_name() + + topic = _hosted_topic_of_current(candidates, current) + + if topic is not None: + enter_topic_todo(topic, year) + return + + if current is None or normalize_topic_slug(current) == "": + raise click.ClickException(f"branch name '{current}' normalizes to an empty topic slug") + + ensure_topic_dir(current, year) + enter_topic_todo(current, year) + + +def _hosted_topic_of_current(candidates: list[SwitchCandidate], current: str | None) -> str | None: + """Find the hosted topic of the current branch among the candidates. + + A local candidate matches by its full branch name; a remote-tracking + candidate matches by its short name — the local branch the switch + created from it. The first candidate of the resolution order wins: a + branch hosting several topics contributes its first entry, the same + choice the switch orchestration's own todo path makes. + + Args: + candidates: The step-2 resolution candidates. + current: The current branch name, or ``None`` when there is none. + + Returns: + The hosted topic slug of the matching candidate, or ``None`` when + the current branch hosts none of the candidates. + """ + for candidate in candidates: + hosted_by = _short_name(candidate.branch) if candidate.remote else candidate.branch + if hosted_by == current: + return candidate.topic - return _switch_to_candidate(candidates) + return None diff --git a/tests/commands/pipeline/test_pipeline_dispatch.py b/tests/commands/pipeline/test_pipeline_dispatch.py index 2ebff983..9ecc9410 100644 --- a/tests/commands/pipeline/test_pipeline_dispatch.py +++ b/tests/commands/pipeline/test_pipeline_dispatch.py @@ -46,6 +46,7 @@ from goga.history import current_year from goga.topics import board as topics_board from goga.topics import creation as topics_creation +from goga.topics import ensuring as topics_ensuring from goga.topics import switching as topics_switching from goga.topics.git import BranchRef @@ -422,15 +423,18 @@ def _wire_topic_domain( The resolution reads the scale, the ref inventory, the ref trees, and the current branch at their import points inside the topics domain (the same - points the domain's own tests patch); the mutations are recording mocks. - The creation fallback of ``ensure_topic`` runs the REAL ``create_topic``, - so the creation module's git boundary is wired the same way. Only the - topics facade stays real — exactly the wiring ``pipeline`` relies on - through ``from ...topics import ensure_topic``. + points the domain's own tests patch); the switch mutations are recording + mocks. The fast creation of ``ensure_topic`` at zero candidates runs the + REAL occupancy oracles — so the creation module's git boundary (the + inventory, the current branch, and the branch-tree slug oracle) is wired + the same way — and the REAL topic-directory creation; only its + create-and-switch mutation is a recording mock at ``ensuring``'s import + point. Only the topics facade stays real — exactly the wiring + ``pipeline`` relies on through ``from ...topics import ensure_topic``. Returns: The cleanliness probe, the local checkout, the remote-tracking branch - creation, and the create-and-switch mutation of the creation fallback + creation, and the create-and-switch mutation of the fast creation — all as recording mocks. """ monkeypatch.setattr(topics_switching, "assemble_status_scale", _builtin_scale) @@ -447,8 +451,9 @@ def _wire_topic_domain( monkeypatch.setattr(topics_creation, "list_branch_refs", lambda: inventory) monkeypatch.setattr(topics_creation, "resolve_current_branch_name", lambda: current) + monkeypatch.setattr(topics_creation, "read_ref_tree_paths", _trees_reader(trees)) create_and_switch = mock.Mock() - monkeypatch.setattr(topics_creation, "create_and_switch_branch", create_and_switch) + monkeypatch.setattr(topics_ensuring, "create_and_switch_branch", create_and_switch) return cleanliness, checkout, remote_creation, create_and_switch diff --git a/tests/topics/test_ensuring.py b/tests/topics/test_ensuring.py index a26fe7b4..4e7ac5a8 100644 --- a/tests/topics/test_ensuring.py +++ b/tests/topics/test_ensuring.py @@ -1,16 +1,19 @@ """Contract and logic tests for the entity declared in ``goga/topics/CODEMANIFEST`` with ``location: ensuring.py``: -- ``ensure_topic(identifier, year)`` — the switch-or-create orchestration +- ``ensure_topic(identifier, todo, year)`` — the switch-or-create orchestration The git boundary is mocked at the import point per the ``convention`` -practice — no git binary and no repository are touched. The ref-tree -helper shared with the board is mocked at its owner (``goga.topics.board``); -the working-copy scenarios use ``tmp_path`` + ``monkeypatch.chdir`` with the -real history path routines, and the scale is the ``builtin_scale`` fixture. -The creation fallback of ``ensure_topic`` runs the REAL ``create_topic`` -with its git boundary patched at ``goga.topics.creation``'s import points — -the same wiring the creation tests use. +practice — no git binary and no repository are touched. The resolution runs +real through the switching module's patched import points where the scenario +walks the inventory and is mocked at ``goga.topics.ensuring``'s import point +where the scenario pins an exact candidate list; the switch tail is +``switch_topic`` mocked at its import point in ``ensuring`` (its own +orchestration is the switching suite's concern); the fast creation mocks the +occupancy oracles, ``create_and_switch_branch``, and the todo entry at their +import points in ``ensuring`` — with the topic-directory creation real on a +``tmp_path`` tree where the design says so. The scale is the +``builtin_scale`` fixture. """ from __future__ import annotations @@ -24,8 +27,9 @@ import click import pytest +from goga.history import current_year from goga.history.statuses import StatusScale -from goga.topics import board, creation, ensure_topic, switching +from goga.topics import SwitchCandidate, board, ensure_topic, ensuring, switching from goga.topics.git import BranchRef # --- Shared scenario helpers --- @@ -55,8 +59,19 @@ def _wire_resolution( monkeypatch.setattr(board, "read_ref_tree_paths", _trees_reader(trees)) +def _wire_resolver(monkeypatch: pytest.MonkeyPatch, candidates: list[SwitchCandidate]) -> mock.Mock: + """Patch the resolution at its import point in ``ensuring``. + + Returns: + The resolver as a recording mock answering the pinned candidates. + """ + resolver = mock.Mock(return_value=candidates) + monkeypatch.setattr(ensuring, "resolve_switch_candidates", resolver) + return resolver + + def _wire_mutations(monkeypatch: pytest.MonkeyPatch, clean: bool = True) -> tuple[mock.Mock, mock.Mock, mock.Mock]: - """Patch the switch mutations at their import points. + """Patch the switch mutations at their import points in ``switching``. Returns: The cleanliness probe, the local checkout, and the remote-tracking @@ -71,29 +86,78 @@ def _wire_mutations(monkeypatch: pytest.MonkeyPatch, clean: bool = True) -> tupl return cleanliness, checkout, remote_creation -def _wire_creation_boundary( +def _wire_switch(monkeypatch: pytest.MonkeyPatch, line: str) -> mock.Mock: + """Patch the switch orchestration at its import point in ``ensuring``. + + Returns: + ``switch_topic`` as a recording mock answering the given result line. + """ + switch = mock.Mock(return_value=line) + monkeypatch.setattr(ensuring, "switch_topic", switch) + return switch + + +def _wire_fast_creation( monkeypatch: pytest.MonkeyPatch, - inventory: list[BranchRef], - current: str | None, -) -> mock.Mock: - """Patch the creation fallback's import points inside ``goga.topics.creation``. + occupied: str | None = None, + real_dir: bool = False, +) -> tuple[mock.Mock, mock.Mock | None]: + """Patch the fast-creation boundary at ``ensuring``'s import points. + + Args: + monkeypatch: The patch fixture. + occupied: The branch-oracle answer — a conflict reason or ``None``. + real_dir: Keep ``ensure_topic_dir`` real (the on-disk scenarios). Returns: - The create-and-switch mutation as a recording mock — the only git - mutation of the fallback. + The create-and-switch mutation and the topic-directory creation as + recording mocks — the directory-creation mock is ``None`` when + ``real_dir`` left the real routine in place. """ - monkeypatch.setattr(creation, "list_branch_refs", lambda: inventory) - monkeypatch.setattr(creation, "resolve_current_branch_name", lambda: current) + monkeypatch.setattr(ensuring, "check_branch_occupancy", mock.Mock(return_value=occupied)) + monkeypatch.setattr(ensuring, "check_slug_occupancy", mock.Mock(return_value=None)) create_and_switch = mock.Mock() - monkeypatch.setattr(creation, "create_and_switch_branch", create_and_switch) - return create_and_switch + monkeypatch.setattr(ensuring, "create_and_switch_branch", create_and_switch) + if real_dir: + return create_and_switch, None + + ensure_dir = mock.Mock() + monkeypatch.setattr(ensuring, "ensure_topic_dir", ensure_dir) + return create_and_switch, ensure_dir + + +def _wire_current(monkeypatch: pytest.MonkeyPatch, current: str | None) -> mock.Mock: + """Patch the current-branch read at its import point in ``ensuring``. + + Returns: + The reader as a recording mock answering the given branch name. + """ + resolver = mock.Mock(return_value=current) + monkeypatch.setattr(ensuring, "resolve_current_branch_name", resolver) + return resolver + + +def _wire_entry(monkeypatch: pytest.MonkeyPatch) -> mock.Mock: + """Patch the todo entry at its import point in ``ensuring``. + + Returns: + ``enter_topic_todo`` as a recording mock. + """ + entry = mock.Mock() + monkeypatch.setattr(ensuring, "enter_topic_todo", entry) + return entry def _non_interactive(monkeypatch: pytest.MonkeyPatch) -> None: - """Make stdin a non-terminal — the re-ask path must abort cleanly.""" + """Make stdin a non-terminal — the todo entry must abort cleanly.""" monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) +def _interactive(monkeypatch: pytest.MonkeyPatch) -> None: + """Make stdin a terminal — the todo entry of the ensure is reachable.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + + def _working_copy_topic(cwd: Path, year: str, slug: str, artifacts: list[str]) -> None: """Create the working-copy topic directory with its artifact files.""" for artifact in artifacts: @@ -130,121 +194,169 @@ def test_ensure_topic_is_importable_from_the_cell_facade(self) -> None: assert "ensure_topic" in cell.__all__ def test_ensure_topic_signature(self) -> None: - """``ensure_topic(identifier, year=None) -> str``.""" + """``ensure_topic(identifier, todo=False, year=None) -> str``.""" signature = inspect.signature(ensure_topic) - assert list(signature.parameters) == ["identifier", "year"] + assert list(signature.parameters) == ["identifier", "todo", "year"] assert all( parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) + assert signature.parameters["todo"].default is False assert signature.parameters["year"].default is None hints = typing.get_type_hints(ensure_topic) - assert hints == {"identifier": str, "year": str | None, "return": str} + assert hints == {"identifier": str, "todo": bool, "year": str | None, "return": str} + + def test_ensure_topic_single_argument_call_still_binds(self) -> None: + """The pipeline caller ``ensure_topic(topic)`` stays compatible.""" + inspect.signature(ensure_topic).bind("history-com") -# --- Logic tests --- +# --- Logic tests: the fast creation at zero candidates --- -class TestEnsureTopic: - def test_ensure_topic_zero_candidates_creates_fresh_work( +class TestEnsureTopicFastCreation: + def test_ensure_topic_fast_creation_from_current_head( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Nothing hosts the identifier: the branch named as entered comes + off the current HEAD, then the topic directory, then — under + ``todo`` — the entry; the line carries the name as entered and the + normalized slug.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + create_and_switch, ensure_dir = _wire_fast_creation(monkeypatch) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + order = mock.Mock() + order.attach_mock(create_and_switch, "create_and_switch") + order.attach_mock(ensure_dir, "ensure_topic_dir") + order.attach_mock(entry, "entry") + + result = ensure_topic("Feature/Foo_Bar", todo=True, year="2026") + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + create_and_switch.assert_called_once_with("Feature/Foo_Bar") + ensure_dir.assert_called_once_with("Feature/Foo_Bar", "2026") + entry.assert_called_once_with("Feature/Foo_Bar", "2026") + assert order.mock_calls == [ + mock.call.create_and_switch("Feature/Foo_Bar"), + mock.call.ensure_topic_dir("Feature/Foo_Bar", "2026"), + mock.call.entry("Feature/Foo_Bar", "2026"), + ] + # The fast creation is local-only and asks nothing: the publication + # primitives have no place here, and the old create_topic delegation + # must not leak back in. + assert not hasattr(ensuring, "resolve_ref_commit") + assert not hasattr(ensuring, "push_branch") + assert not hasattr(ensuring, "create_topic") + + def test_ensure_topic_fast_creation_writes_the_real_topic_directory( self, builtin_scale: StatusScale, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Nothing hosts the identifier: the fallback creates the branch as - entered and the topic directory of the year — the creation line.""" + """The fast creation creates the real topic directory of the year — + and without ``todo`` no entry runs (no terminal needed).""" monkeypatch.chdir(tmp_path) inventory = [BranchRef(name="main", remote=False)] trees = {"main": [".goga/history/2026/other/prd.md"]} _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") - _wire_mutations(monkeypatch, clean=True) - create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") + create_and_switch, _ensure_dir = _wire_fast_creation(monkeypatch, real_dir=True) + entry = _wire_entry(monkeypatch) - result = ensure_topic("prune-history-and-new-status", "2026") + result = ensure_topic("prune-history-and-new-status", year="2026") - assert result == "Created branch prune-history-and-new-status and topic 2026/prune-history-and-new-status" + assert result == ("Created branch prune-history-and-new-status and topic 2026/prune-history-and-new-status") create_and_switch.assert_called_once_with("prune-history-and-new-status") + entry.assert_not_called() assert (tmp_path / ".goga" / "history" / "2026" / "prune-history-and-new-status").is_dir() - def test_ensure_topic_remote_tracking_twin_is_occupied( + def test_ensure_topic_occupied_name_clean_error( self, builtin_scale: StatusScale, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """A remote-tracking twin of the name occupies it: clean error with - the board hint, nothing created.""" + """An occupancy conflict at zero candidates: a clean error carrying + the reason and the board hint — nothing created.""" monkeypatch.chdir(tmp_path) - inventory = [ - BranchRef(name="main", remote=False), - BranchRef(name="origin/new-work", remote=True), - ] + inventory = [BranchRef(name="main", remote=False)] trees = {"main": ["README.md"]} _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") - _wire_mutations(monkeypatch, clean=True) - create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") - _non_interactive(monkeypatch) + create_and_switch, ensure_dir = _wire_fast_creation(monkeypatch, occupied="branch 'x' exists") with pytest.raises(click.ClickException) as raised: - ensure_topic("new-work", "2026") + ensure_topic("x", year="2026") - assert raised.value.message == ( - "remote-tracking branch 'new-work' already exists — run 'goga topics board' to see the board" - ) + assert raised.value.message == "branch 'x' exists — run 'goga topics board' to see the board" create_and_switch.assert_not_called() + ensure_dir.assert_not_called() - def test_ensure_topic_single_candidate_switches_without_creation( + def test_ensure_topic_empty_slug_identifier_error( self, builtin_scale: StatusScale, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """A hosted identifier takes the plain switch — the fallback never runs.""" + """An identifier normalizing to an empty slug is a clean error — a + name git would accept but the history tree cannot address must not + create a branch that can never host a topic directory.""" monkeypatch.chdir(tmp_path) - inventory = [ - BranchRef(name="feat/a", remote=False), - BranchRef(name="main", remote=False), - ] - trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": ["README.md"]} _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") - _cleanliness, checkout, _remote_creation = _wire_mutations(monkeypatch, clean=True) - create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") + create_and_switch, ensure_dir = _wire_fast_creation(monkeypatch) + create_and_switch.side_effect = AssertionError("an empty-slug name must not create a branch") - result = ensure_topic("feat/a", "2026") + with pytest.raises(click.ClickException, match="empty topic slug"): + ensure_topic("???", year="2026") - assert result == "Switched to branch feat/a" - checkout.assert_called_once_with("feat/a") create_and_switch.assert_not_called() + ensure_dir.assert_not_called() + + +# --- Logic tests: the delegated switch at non-empty candidates --- + - def test_ensure_topic_idempotent_when_already_on_host( +class TestEnsureTopicSwitch: + def test_ensure_topic_single_candidate_switches_without_creation( self, builtin_scale: StatusScale, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Already on the hosting branch: idempotent success, no probe, no - mutation — creation included.""" + """A hosted identifier takes the delegated switch without the entry + — the fast creation never runs.""" monkeypatch.chdir(tmp_path) - _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) - _wire_resolution(monkeypatch, builtin_scale, _twin_inventory(), _twin_trees(), "feat/a") - cleanliness, checkout, _remote_creation = _wire_mutations(monkeypatch, clean=True) - create_and_switch = _wire_creation_boundary(monkeypatch, _twin_inventory(), "feat/a") + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + switch = _wire_switch(monkeypatch, "Switched to branch feat/a") + create_and_switch, _ensure_dir = _wire_fast_creation(monkeypatch) - result = ensure_topic("feat/a") + result = ensure_topic("feat/a", year="2026") - assert result == "Already on branch feat/a" - cleanliness.assert_not_called() - checkout.assert_not_called() + assert result == "Switched to branch feat/a" + switch.assert_called_once_with("feat/a", todo=False, year="2026") create_and_switch.assert_not_called() - def test_ensure_topic_multiple_candidates_fail_with_list_not_creation( + def test_ensure_topic_multiple_candidates_delegates_the_choice_to_switch( self, builtin_scale: StatusScale, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Several candidates without a terminal fail with the numbered list — - ambiguity never escapes into creation.""" + """Several candidates: the delegated switch orchestration owns the + numbered choice — its non-terminal abort propagates, and ambiguity + never escapes into creation.""" monkeypatch.chdir(tmp_path) inventory = [ BranchRef(name="feat/a", remote=False), @@ -257,38 +369,154 @@ def test_ensure_topic_multiple_candidates_fail_with_list_not_creation( "main": ["README.md"], } _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") - cleanliness, checkout, _remote_creation = _wire_mutations(monkeypatch, clean=True) - create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") + _cleanliness, checkout, _remote_creation = _wire_mutations(monkeypatch, clean=True) + create_and_switch, _ensure_dir = _wire_fast_creation(monkeypatch) _non_interactive(monkeypatch) with pytest.raises(click.ClickException) as raised: - ensure_topic("feat", "2026") + ensure_topic("feat", year="2026") assert "1)" in raised.value.message assert "2)" in raised.value.message - cleanliness.assert_not_called() checkout.assert_not_called() create_and_switch.assert_not_called() - def test_ensure_topic_empty_slug_identifier_clean_error( + +# --- Logic tests: the todo entry of the ensured work --- + + +class TestEnsureTopicTodo: + def test_ensure_topic_switch_branch_without_topic_creates_dir_then_enters( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``todo`` on a branch without a topic: the delegated switch runs + without the entry, then the topic directory is created and the fresh + entry follows — the fast process is never interrupted.""" + monkeypatch.chdir(tmp_path) + candidate = SwitchCandidate(branch="feature-foo", topic=None, statuses=[], current=True, remote=False) + _wire_resolver(monkeypatch, [candidate]) + switch = _wire_switch(monkeypatch, "Already on branch feature-foo") + _wire_current(monkeypatch, "feature-foo") + ensure_dir = mock.Mock() + monkeypatch.setattr(ensuring, "ensure_topic_dir", ensure_dir) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + order = mock.Mock() + order.attach_mock(ensure_dir, "ensure_topic_dir") + order.attach_mock(entry, "entry") + + result = ensure_topic("feature-foo", todo=True, year="2026") + + assert result == "Already on branch feature-foo" + switch.assert_called_once_with("feature-foo", todo=False, year="2026") + ensure_dir.assert_called_once_with("feature-foo", "2026") + entry.assert_called_once_with("feature-foo", "2026") + assert order.mock_calls == [ + mock.call.ensure_topic_dir("feature-foo", "2026"), + mock.call.entry("feature-foo", "2026"), + ] + + def test_ensure_topic_todo_enters_resolved_topic_not_branch_slug( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The hosted topic comes from the resolution candidate — a topic + merged into another branch is entered as itself; no directory of + the hosting branch's name is ever created.""" + monkeypatch.chdir(tmp_path) + candidate = SwitchCandidate(branch="main", topic="feature-x", statuses=["todo"], current=False, remote=False) + _wire_resolver(monkeypatch, [candidate]) + _wire_switch(monkeypatch, "Switched to branch main") + _wire_current(monkeypatch, "main") + ensure_dir = mock.Mock() + monkeypatch.setattr(ensuring, "ensure_topic_dir", ensure_dir) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + + result = ensure_topic("feature-x", todo=True, year="2026") + + assert result == "Switched to branch main" + entry.assert_called_once_with("feature-x", "2026") + ensure_dir.assert_not_called() + assert not (tmp_path / ".goga" / "history" / "2026" / "main").exists() + + def test_ensure_topic_todo_matches_remote_candidate_by_short_name( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A remote-tracking candidate matches the current branch by its + short name — the local branch the switch created.""" + monkeypatch.chdir(tmp_path) + candidate = SwitchCandidate( + branch="origin/feature-x", topic="feature-x", statuses=["todo"], current=False, remote=True + ) + _wire_resolver(monkeypatch, [candidate]) + _wire_switch(monkeypatch, "Created branch feature-x from origin/feature-x") + _wire_current(monkeypatch, "feature-x") + ensure_dir = mock.Mock() + monkeypatch.setattr(ensuring, "ensure_topic_dir", ensure_dir) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + + result = ensure_topic("feature-x", todo=True, year="2026") + + assert result == "Created branch feature-x from origin/feature-x" + entry.assert_called_once_with("feature-x", "2026") + ensure_dir.assert_not_called() + + def test_ensure_topic_todo_empty_slug_branch_without_topic_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A no-topic branch whose name normalizes to an empty slug is a + clean error — the history facade's ``ValueError`` never escapes the + module, and nothing is created or entered.""" + monkeypatch.chdir(tmp_path) + candidate = SwitchCandidate(branch="Тема", topic=None, statuses=[], current=False, remote=False) + _wire_resolver(monkeypatch, [candidate]) + _wire_switch(monkeypatch, "Switched to branch Тема") + _wire_current(monkeypatch, "Тема") + ensure_dir = mock.Mock() + monkeypatch.setattr(ensuring, "ensure_topic_dir", ensure_dir) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + + with pytest.raises(click.ClickException) as raised: + ensure_topic("Тема", todo=True, year="2026") + + assert raised.value.message == "branch name 'Тема' normalizes to an empty topic slug" + ensure_dir.assert_not_called() + entry.assert_not_called() + + def test_ensure_topic_todo_idempotent_enters_the_hosted_topic( self, builtin_scale: StatusScale, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """An identifier normalizing to an empty slug is a clean error — no - branch, no topic directory.""" + """Already on the host: the delegated switch returns the idempotent + line and the entry runs for the hosted topic of the working copy.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, current_year(), "feat-a", ["plan.md"]) + _wire_resolution(monkeypatch, builtin_scale, _twin_inventory(), _twin_trees(), "feat/a") + _wire_switch(monkeypatch, "Already on branch feat/a") + _wire_current(monkeypatch, "feat/a") + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + + result = ensure_topic("feat/a", todo=True) + + assert result == "Already on branch feat/a" + entry.assert_called_once_with("feat-a", None) + + def test_ensure_topic_todo_non_tty_error_before_action( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``todo=True`` without a terminal: the clean error fires before + any action — ordering, not just the error.""" monkeypatch.chdir(tmp_path) - inventory = [BranchRef(name="main", remote=False)] - trees = {"main": ["README.md"]} - _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") - _wire_mutations(monkeypatch, clean=True) - create_and_switch = _wire_creation_boundary(monkeypatch, inventory, "main") _non_interactive(monkeypatch) + resolver = mock.Mock(side_effect=AssertionError("the resolution must not run")) + monkeypatch.setattr(ensuring, "resolve_switch_candidates", resolver) - with pytest.raises(click.ClickException) as raised: - ensure_topic("БББ", "2026") + with pytest.raises(click.ClickException, match="interactive"): + ensure_topic("anything", todo=True) - assert raised.value.message == "branch name 'БББ' normalizes to an empty topic slug" - create_and_switch.assert_not_called() - assert not (tmp_path / ".goga" / "history" / "2026").exists() + resolver.assert_not_called() From cb68fcf1eb42b52020eaa8832c24e6afbc84ba16 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 21:10:56 +0000 Subject: [PATCH 188/229] =?UTF-8?q?feat:=20rework=20create=5Ftopic=20?= =?UTF-8?q?=E2=80=94=20mandatory=20base,=20preflight-first,=20editor=20tod?= =?UTF-8?q?o,=20publication=20ask,=20call-time=20publish=20delegation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- goga/commands/topics/topics.py | 5 +- goga/topics/__init__.py | 24 +- goga/topics/creation.py | 281 ++++++---- tests/commands/topics/test_topics_command.py | 16 +- tests/integration/test_topic_workflows.py | 58 +- tests/topics/test_creation.py | 535 +++++++++++-------- 6 files changed, 555 insertions(+), 364 deletions(-) diff --git a/goga/commands/topics/topics.py b/goga/commands/topics/topics.py index eb56e8a0..28243b85 100644 --- a/goga/commands/topics/topics.py +++ b/goga/commands/topics/topics.py @@ -223,7 +223,10 @@ def create( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared CLI surface ) if not publish: - line = create_topic(branch_name, scope.year, todo) + # Interim delegation (superseded by the full CLI rework): the + # domain owns the todo resolution now, and "HEAD" reproduces the + # old behavior — a branch from the current HEAD. + line = create_topic(branch_name, "HEAD", todo, year=scope.year) click.echo(line) click.get_current_context().exit(0) diff --git a/goga/topics/__init__.py b/goga/topics/__init__.py index 94c39947..0651644b 100644 --- a/goga/topics/__init__.py +++ b/goga/topics/__init__.py @@ -2,15 +2,21 @@ The cross-branch topic inventory of one year with per-topic statuses, the switch-identifier resolution and switching orchestration, the fresh-work -creation procedure, the fast creation-and-publication cycle that builds a -one-commit branch off an explicit base through quarantined git plumbing -and pushes it to origin while the caller stays on their branch, and the -combined ensure orchestration that switches onto hosted work and creates -it when nothing hosts the identifier. Topic identity, addressing, and -statuses belong to the history facade; git access belongs to the nested -leaf cell ``goga.topics.git``. Mutations are local-only and happen -strictly after every decision is made — the publication push of the fast -cycle is the single network exception. +creation procedure off an explicit base with its editor-sourced todo, the +todo entry of an existing topic, the fast creation-and-publication cycle +that builds a one-commit branch off an explicit base through quarantined +git plumbing and pushes it to origin while the caller stays on their +branch, the combined ensure orchestration that switches onto hosted work +and creates it when nothing hosts the identifier, and the +identified-topic deletion — the read-only target resolution and the +confirmed removal of the local branch, the origin twin, and the topic +directory. Topic identity, addressing, and statuses belong to the +history facade; git access belongs to the nested leaf cell +``goga.topics.git``; the interactive text entry — the external-editor +session every todo flows through — belongs to the nested leaf cell +``goga.topics.editor``. Mutations are local-only and happen strictly +after every decision is made — the publication push of the fast cycle +and the deletion push of the removal are the two network exceptions. """ from .board import BoardRecord, collect_topic_board diff --git a/goga/topics/creation.py b/goga/topics/creation.py index 76769804..23667aa4 100644 --- a/goga/topics/creation.py +++ b/goga/topics/creation.py @@ -5,17 +5,21 @@ name, the branch-tree slug oracle that reads the topic directory of a slug across every branch tree of the inventory — without checkout, so a topic hosted only on a branch (or only on ``origin``) is visible — the -orchestrator that creates the branch — named exactly as entered -— together with its topic directory of the year and, when a non-empty -todo is given, its topic todo file, and the todo entry of a topic — the -editor session of the nested editor cell over the topic's todo.md and -the write of the saved text, without a commit. Topic identity and -addressing belong to the history facade; the bounded git mutation belongs -to the nested git cell; the editor session belongs to the nested editor -cell. Git infrastructure failures surface as ``click.ClickException`` — -the clean-error boundary of the domain; the interactive moments follow -the ``click`` practice. The status scale is never assembled here — -creation is not a status consumer. +orchestrator that creates fresh work off an explicit base — the branch +named exactly as entered, planted at the base commit and checked out, +together with its topic directory of the year and its topic todo file: +a given value or the editor session of the nested editor cell, every +decision read-only before the first input and the first mutation, every +conflict one clean error, and an optional publication ask that delegates +to the fast cycle of the publishing module — and the todo entry of a +topic — the editor session over the topic's todo.md and the write of +the saved text, without a commit. Topic identity and addressing belong +to the history facade; the bounded git mutation belongs to the nested +git cell; the editor session belongs to the nested editor cell. Git +infrastructure failures surface as ``click.ClickException`` — the +clean-error boundary of the domain; the interactive moments follow the +``click`` practice. The status scale is never assembled here — creation +is not a status consumer. """ from __future__ import annotations @@ -35,7 +39,13 @@ topic_exists, ) from .editor import edit_text -from .git import create_and_switch_branch, list_branch_refs, read_ref_tree_paths +from .git import ( + checkout_local_branch, + create_branch_at_commit, + list_branch_refs, + read_ref_tree_paths, + resolve_ref_commit, +) # The board hint of an occupancy conflict — where the occupied names are # visible to the user. @@ -129,71 +139,95 @@ def check_slug_occupancy(slug: str, year: str | None = None) -> str | None: raise click.ClickException(f"git is not available: {exc}") from exc -def create_topic( - branch_name: str, year: str | None = None, todo: str | None = None +def create_topic( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared signature + branch_name: str, + base_ref: str, + todo: str | None = None, + publish: bool = False, + commit_message: str | None = None, + year: str | None = None, ) -> str: - """Create fresh work — a branch with the name as entered, its topic - directory of the year, and an optional multi-line todo. + """Create fresh work — a branch off an explicit base with the name as + entered, checked out, with its topic directory of the year and an + optional todo; the publication ask may hand the work to the fast + publication cycle instead. Args: branch_name: Branch name as entered by the user. + base_ref: Base revision the branch starts from — any revision + string, resolved as git resolves it (``"HEAD"`` for the + current commit). + todo: Optional multi-line todo — a non-empty value is used as + given; without a value an interactive terminal opens the + editor entry (a cancelled session leaves no todo) and a + non-interactive terminal is a clean error naming the value + option. An empty string counts as no value. + publish: ``True`` takes the publication path without the ask. + commit_message: Commit message template of the publication; + ``None`` applies the publication's own built-in default. year: Optional year as four digits; ``None`` means the current year. - todo: Optional multi-line todo of the fresh work; ``None`` or an - empty string writes no todo.md. Returns: - One line describing the outcome — the created work, or the - idempotent success when the current branch already hosts the topic. + One line describing the outcome — the created work of the normal + path or the created and published work of the fast cycle. Algorithm: - 1. Normalize ``branch_name`` into a slug via ``normalize_topic_slug`` - 2. Empty slug -> print the reason, prompt for a new name on an - interactive terminal and restart, or fail with the reason + 1. Preflight — read-only, before any input: the empty-slug guard + via ``normalize_topic_slug``, the current-branch conflict (the + current branch already hosting the slug is a conflict — there + is no idempotent path), the occupancy oracles + ``check_branch_occupancy`` then ``check_slug_occupancy``, and + the base resolution via ``resolve_ref_commit`` + 2. Todo resolution — a non-empty value wins; without one an + interactive terminal opens the editor session via + ``edit_text`` (its cancellation leaves no todo), otherwise a + clean error naming the value option + 3. ``publish`` without a resolved todo -> clean error asking for + the todo + 4. The ask — an interactive terminal, ``publish`` not set, a todo + resolved: ``click.confirm`` offers the publication (an empty + answer reads the default no; Ctrl-C or EOF aborts); no ask otherwise - 3. The current branch — read via ``resolve_current_branch_name`` — - hosts the same slug -> the idempotent path: a non-empty ``todo`` - writes the topic todo file ``todo.md`` of the ensured topic - directory; no ``todo`` is a success without mutation; no - occupancy check, no switch - 4. ``check_branch_occupancy`` reports a conflict -> print the reason - with a hint to the board, prompt for a new name on an interactive - terminal and restart, or fail otherwise - 5. Free name -> create the branch named exactly as entered and - switch to it via ``create_and_switch_branch``, create the topic - directory via ``ensure_topic_dir`` of the year, and a non-empty - ``todo`` writes the todo file ``todo.md`` of the topic directory - 6. Return the single result line + 5. The normal path — ``create_branch_at_commit`` plants the + branch at the base commit, ``checkout_local_branch`` switches + to it, ``ensure_topic_dir`` creates the topic directory of the + year, and a resolved todo writes the todo file ``todo.md`` — + the write is the last action of the path + 6. The publication path — the fast cycle of ``publishing`` via a + call-time import; the cycle re-runs its own preflight — the + delegation is deliberately whole + 7. Return the single result line Requirements: + Every decision — the preflight, the todo, the ask — precedes the + first mutation; the read-only preflight precedes any input, so a + failing base never wastes an entered todo. The branch keeps the name as entered; the topic directory takes the slug — the two may deliberately differ. - The todo.md file carries ``todo`` as entered plus a single trailing + The todo.md file carries the todo as entered plus a single trailing newline, encoded UTF-8 — empty lines inside the text stay as entered. - The todo.md file is written only when a non-empty ``todo`` is - given — ``None`` or an empty string never creates and never - overwrites it; an explicit ``todo`` creates the file or overwrites - it. + The todo.md file is written only when a todo resolved. The topic directory exists before the todo.md file is written. - An aborted re-ask leaves the repository untouched. - The caller stays on the new branch. + The caller stays on the new branch on the normal path. Constraints: Do not validate branch-name characters — git owns name validity. - Do not auto-pick suffixed names on a conflict — the user re-asks or - aborts. + Do not re-ask a conflicted name — every conflict is one clean + error. Do not write artifact files other than the topic todo file inside the topic directory. Raises: - click.ClickException: an unresolved empty slug or occupancy conflict - without a terminal, a git infrastructure failure (its stderr - when git reports one, or a missing git binary). - click.Abort: Ctrl-C or EOF at the re-ask prompt — the repository is - left untouched. + click.ClickException: an empty slug, the current branch hosting + the slug, an occupancy conflict, an unresolvable base, no todo + without a terminal, ``publish`` without a todo, or a git + infrastructure failure (its stderr when git reports one, or a + missing git binary). + click.Abort: Ctrl-C or EOF at the publication ask. """ try: - return _create_topic(branch_name, year, todo) + return _create_topic(branch_name, base_ref, todo, publish, commit_message, year) except subprocess.CalledProcessError as exc: detail = (exc.stderr or "").strip() or str(exc) raise click.ClickException(f"git failed: {detail}") from exc @@ -302,47 +336,123 @@ def _slug_conflict(slug: str, year: str | None) -> str | None: return None -def _create_topic(branch_name: str, year: str | None, todo: str | None) -> str: +def _create_topic( # noqa: PLR0913, PLR0917 — the unwrapped mirror of the declared signature + branch_name: str, + base_ref: str, + todo: str | None, + publish: bool, + commit_message: str | None, + year: str | None, +) -> str: """Run the traced creation procedure — the unwrapped orchestration. Args: branch_name: Branch name as entered by the user. + base_ref: Base revision the branch starts from. + todo: The todo as entered, or ``None``/empty for no value. + publish: ``True`` takes the publication path without the ask. + commit_message: Commit message template of the publication; + ``None`` applies the publication's own default. year: Optional year as four digits; ``None`` means the current year. - todo: Optional multi-line todo of the fresh work; ``None`` or an - empty string writes no todo.md. Returns: The single result line of the outcome. """ resolved_year = year or current_year() - while True: - slug = normalize_topic_slug(branch_name) + # The preflight — read-only and before any input: a failing base or a + # conflicted name must never waste an entered todo. + slug = normalize_topic_slug(branch_name) + if slug == "": + raise click.ClickException( + f"branch name '{branch_name}' normalizes to an empty topic slug" + ) - if slug == "": - reason = f"branch name '{branch_name}' normalizes to an empty topic slug" - branch_name = _reask(reason) - continue + current = resolve_current_branch_name() + if current is not None and normalize_topic_slug(current) == slug: + raise click.ClickException( + f"branch {current} already hosts topic {resolved_year}/{slug}" + " — switch to it instead of re-creating it" + ) - current = resolve_current_branch_name() - if current is not None and normalize_topic_slug(current) == slug: - if todo: - ensure_topic_dir(branch_name, resolved_year) - _write_todo(branch_name, resolved_year, todo) - return f"Branch {current} already hosts topic {resolved_year}/{slug}" + conflict = check_branch_occupancy(branch_name, slug, year) + if conflict is None: + conflict = check_slug_occupancy(slug, year) + if conflict is not None: + raise click.ClickException(f"{conflict} — {_BOARD_HINT}") - conflict = check_branch_occupancy(branch_name, slug, resolved_year) - if conflict is not None: - branch_name = _reask(conflict, _BOARD_HINT) - continue + base_commit = resolve_ref_commit(base_ref) - create_and_switch_branch(branch_name) - ensure_topic_dir(branch_name, resolved_year) - if todo: - _write_todo(branch_name, resolved_year, todo) + resolved_todo = _resolve_todo(todo) + if publish and resolved_todo is None: + raise click.ClickException( + "the publication needs a todo — the board reads the topic through todo.md" + ) + + if not _publication_asked(publish, resolved_todo): + create_branch_at_commit(branch_name, base_commit) + checkout_local_branch(branch_name) + ensure_topic_dir(branch_name, year) + if resolved_todo is not None: + _write_todo(branch_name, resolved_year, resolved_todo) return f"Created branch {branch_name} and topic {resolved_year}/{slug}" + # The publication delegates to the fast cycle through a call-time + # import: publishing imports this module's occupancy oracles, so a + # module-level import would be circular and crash the facade load in + # either order. The cycle re-runs its own preflight — the delegation + # is deliberately whole, no partial pre-sharing of results. + from .publishing import publish_topic # noqa: PLC0415 — breaks the creation ↔ publishing import cycle + + return publish_topic(branch_name, resolved_todo, base_ref, commit_message, year) + + +def _resolve_todo(todo: str | None) -> str | None: + """Resolve the todo of the fresh work — the value, the editor, or an + error. + + A non-empty value wins; without one an interactive terminal opens the + editor session (its cancellation leaves no todo), and a + non-interactive terminal is a clean error naming the value option. + + Args: + todo: The todo as entered, or ``None``/empty for no value. + + Returns: + The resolved todo text, or ``None`` when no todo accompanies the + work. + + Raises: + click.ClickException: no value and no interactive terminal. + """ + if todo: + return todo + if not sys.stdin.isatty(): + raise click.ClickException( + "the todo needs a value — pass --todo/-t or run the creation on an interactive terminal" + ) + return edit_text() + + +def _publication_asked(publish: bool, todo: str | None) -> bool: + """Decide between the normal path and the publication — the ask. + + The ask runs only on an interactive terminal, without ``publish``, + and with a resolved todo: an empty answer reads the default no and + Ctrl-C or EOF aborts. Without the ask ``publish`` decides directly. + + Args: + publish: ``True`` takes the publication path without the ask. + todo: The resolved todo, or ``None``. + + Returns: + ``True`` when the work goes to the publication path. + """ + if not publish and todo is not None and sys.stdin.isatty(): + return click.confirm("Publish the branch to origin?") + return publish + def _enter_topic_todo(topic: str, year: str | None) -> bool: """Run the traced todo-entry procedure — the unwrapped orchestration. @@ -385,26 +495,3 @@ def _write_todo(name: str, year: str, todo: str) -> None: """ content = todo if todo.endswith("\n") else f"{todo}\n" resolve_topic_file(name, "todo.md", year).write_text(content, encoding="utf-8") - - -def _reask(reason: str, hint: str = "") -> str: - """Handle an unusable name: re-ask on a terminal, abort otherwise. - - Args: - reason: Human-readable reason the current name cannot be used. - hint: Optional next step appended to the non-terminal error. - - Returns: - The re-asked branch name — the caller restarts the procedure with it. - - Raises: - click.ClickException: without a terminal — the reason (and the hint - when given) go to the user as a non-terminal abort. - click.Abort: Ctrl-C or EOF at the prompt. - """ - if not sys.stdin.isatty(): - message = f"{reason} — {hint}" if hint else reason - raise click.ClickException(message) - - click.echo(reason, err=True) - return click.prompt("New branch name") diff --git a/tests/commands/topics/test_topics_command.py b/tests/commands/topics/test_topics_command.py index c7e0383d..f4c9bfc3 100644 --- a/tests/commands/topics/test_topics_command.py +++ b/tests/commands/topics/test_topics_command.py @@ -203,7 +203,7 @@ def test_topics_group_help_and_year_scope(self) -> None: mock_create.return_value = "Created branch X and topic 2025/x" scoped = runner.invoke(topics, ["--year", "2025", "create", "X"]) assert scoped.exit_code == 0 - mock_create.assert_called_once_with("X", "2025", None) + mock_create.assert_called_once_with("X", "HEAD", None, year="2025") @pytest.mark.parametrize("subcommand", ["board", "create", "switch"]) def test_subcommand_help_follows_the_cli_docstring_rule(self, subcommand: str) -> None: @@ -232,7 +232,7 @@ def test_year_defaults_to_none_for_the_domain(self) -> None: mock_create.return_value = "Created branch X and topic 2026/x" result = CliRunner().invoke(topics, ["create", "X"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("X", None, None) + mock_create.assert_called_once_with("X", "HEAD", None, year=None) class TestTopicsBoard: @@ -369,15 +369,15 @@ def test_create_echoes_the_domain_result_line(self) -> None: ) as mock_create: result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("Feature/Foo_Bar", None, None) + mock_create.assert_called_once_with("Feature/Foo_Bar", "HEAD", None, year=None) assert result.output.splitlines() == ["Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar"] def test_topics_create_todo_option_reaches_domain(self) -> None: - """-t hands the domain (name, scoped year, todo) verbatim.""" + """-t hands the domain (name, HEAD, todo, scoped year) verbatim.""" with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "-t", "Payment retry"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("Feature/Foo_Bar", None, "Payment retry") + mock_create.assert_called_once_with("Feature/Foo_Bar", "HEAD", "Payment retry", year=None) assert result.output == "line\n" def test_topics_create_todo_long_form_binds_the_same_value(self) -> None: @@ -385,7 +385,7 @@ def test_topics_create_todo_long_form_binds_the_same_value(self) -> None: with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: result = CliRunner().invoke(topics, ["create", "feat-a", "--todo", "T"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("feat-a", None, "T") + mock_create.assert_called_once_with("feat-a", "HEAD", "T", year=None) assert result.output == "line\n" @pytest.mark.parametrize( @@ -397,7 +397,7 @@ def test_create_flag_with_value_passes_todo(self, flag_form: list[str]) -> None: with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: result = CliRunner().invoke(topics, ["create", "feat-a", *flag_form]) assert result.exit_code == 0 - assert mock_create.call_args == mock.call("feat-a", None, "Payment retry") + assert mock_create.call_args == mock.call("feat-a", "HEAD", "Payment retry", year=None) @pytest.mark.parametrize("flag_form", [["--todo="], ["-t", ""]]) def test_create_explicit_empty_value_is_the_entry_marker(self, flag_form: list[str]) -> None: @@ -723,7 +723,7 @@ def test_create_default_path_never_reads_configuration( ): result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("Feature/Foo_Bar", None, None) + mock_create.assert_called_once_with("Feature/Foo_Bar", "HEAD", None, year=None) mock_load.assert_not_called() def test_create_publish_missing_config_counts_as_unset( diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index ed2f800a..fa4bb6a0 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -129,6 +129,26 @@ def _write(root: Path, relative: str) -> None: path.write_text("integration\n", encoding="utf-8") +def _export_editor(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, body: str) -> None: + """Export ``$EDITOR`` as an executable shell script running ``body``. + + The creation procedure opens the editor session on an interactive + terminal; the script stands in for the real editor — a no-op body + leaves the prefilled file untouched, which the session reads as a + cancellation. + + Args: + monkeypatch: The patcher owning the environment. + tmp_path: The throwaway directory the script is written to. + body: The shell body of the script — ``$1`` is the temp file. + """ + script = tmp_path / "editor-mock.sh" + script.write_text(f"#!/bin/sh\n{body}\n", encoding="utf-8") + script.chmod(0o755) + monkeypatch.delenv("VISUAL", raising=False) + monkeypatch.setenv("EDITOR", str(script)) + + def _current_branch(root: Path) -> str: """Read the checked-out branch of the throwaway repository. @@ -524,17 +544,27 @@ class TestCreateTopicRealGit: def test_create_topic_creates_branch_and_topic_directory( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A free name creates the branch verbatim and the topic directory of the year.""" + """A free name creates the branch verbatim and the topic directory of the year. + + The editor entry runs on the mocked terminal and is cancelled — + the no-op editor leaves the prefilled file untouched — so the + branch off HEAD, the checkout, and the directory are the outcome + and no todo.md is written. + """ _init_topic_repo(tmp_path) monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + _export_editor(monkeypatch, tmp_path, "exit 0") - line = create_topic("Feature/Foo_Bar", year="2025") + line = create_topic("Feature/Foo_Bar", "HEAD", year="2025") assert line == "Created branch Feature/Foo_Bar and topic 2025/feature-foo-bar" assert _current_branch(tmp_path) == "Feature/Foo_Bar" - assert (tmp_path / ".goga" / "history" / "2025" / "feature-foo-bar").is_dir() + topic_dir = tmp_path / ".goga" / "history" / "2025" / "feature-foo-bar" + assert topic_dir.is_dir() + assert not (topic_dir / "todo.md").exists() - def test_create_topic_occupied_local_branch_reasks_non_interactively( + def test_create_topic_occupied_local_branch_errors_non_interactively( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """An existing branch name is a clean occupancy error — nothing is created.""" @@ -545,25 +575,27 @@ def test_create_topic_occupied_local_branch_reasks_non_interactively( ) with pytest.raises(click.ClickException, match="already exists"): - create_topic("feat-b", year="2025") + create_topic("feat-b", "HEAD", year="2025") assert _current_branch(tmp_path) == "feat-a" assert not (tmp_path / ".goga" / "history" / "2025" / "feat-b").exists() - def test_create_topic_empty_todo_writes_no_file( + def test_create_topic_empty_todo_without_terminal_is_clean_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """An empty todo creates the branch and the topic directory — and no todo.md.""" + """An empty todo value counts as no value — without a terminal it + is a clean error and nothing is created.""" _init_topic_repo(tmp_path) monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + sys, "stdin", mock.Mock(**{"isatty.return_value": False}) + ) - line = create_topic("feat-empty", year="2025", todo="") + with pytest.raises(click.ClickException, match="--todo"): + create_topic("feat-empty", "HEAD", todo="", year="2025") - assert line == "Created branch feat-empty and topic 2025/feat-empty" - assert _current_branch(tmp_path) == "feat-empty" - topic_dir = tmp_path / ".goga" / "history" / "2025" / "feat-empty" - assert topic_dir.is_dir() - assert not (topic_dir / "todo.md").exists() + assert _current_branch(tmp_path) == "feat-a" + assert not (tmp_path / ".goga" / "history" / "2025" / "feat-empty").exists() @requires_git diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index e049c377..f46c50a7 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -5,8 +5,9 @@ occupancy check of a fresh-work name - ``check_slug_occupancy(slug, year)`` — the branch-tree occupancy oracle of a topic slug -- ``create_topic(branch_name, year, todo)`` — the fresh-work creation - procedure with its optional topic todo file +- ``create_topic(branch_name, base_ref, todo, publish, commit_message, + year)`` — the fresh-work creation procedure off an explicit base with + its editor-sourced todo and its publication ask - ``enter_topic_todo(topic, year)`` — the editor session over the topic's todo.md and the write of the saved text, without a commit @@ -37,6 +38,7 @@ create_topic, creation, enter_topic_todo, + publishing, ) from goga.topics.git import BranchRef @@ -47,35 +49,39 @@ def _wire_inventory( monkeypatch: pytest.MonkeyPatch, inventory: list[BranchRef], current: str | None = None, +) -> None: + """Patch creation's import points: the inventory and the current branch.""" + monkeypatch.setattr(creation, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(creation, "resolve_current_branch_name", lambda: current) + + +def _wire_creation( + monkeypatch: pytest.MonkeyPatch, + current: str = "main", + base_commit: str = "c0ffee", ) -> mock.Mock: - """Patch creation's import points: the inventory and the create mutation. + """Patch creation's import points: a free inventory, the current + branch, the base resolution, and the create/checkout mutations. Returns: - The create-and-switch mutation as a recording mock — the only git - mutation of the procedure. + A recording parent mock whose ``resolve_ref_commit``, + ``create_branch``, and ``checkout`` children are the wired + touchpoints — ``mock_calls`` captures the procedure's order. """ - monkeypatch.setattr(creation, "list_branch_refs", lambda: inventory) - monkeypatch.setattr(creation, "resolve_current_branch_name", lambda: current) - create_and_switch = mock.Mock() - monkeypatch.setattr(creation, "create_and_switch_branch", create_and_switch) - return create_and_switch + wired = mock.Mock() + wired.resolve_ref_commit.return_value = base_commit + _wire_inventory(monkeypatch, [], current) + monkeypatch.setattr(creation, "resolve_ref_commit", wired.resolve_ref_commit) + monkeypatch.setattr(creation, "create_branch_at_commit", wired.create_branch) + monkeypatch.setattr(creation, "checkout_local_branch", wired.checkout) + return wired def _non_interactive(monkeypatch: pytest.MonkeyPatch) -> None: - """Make stdin a non-terminal — the re-ask path must abort cleanly.""" + """Make stdin a non-terminal — the value-less todo must abort cleanly.""" monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) -def _interactive( - monkeypatch: pytest.MonkeyPatch, answers: list[str] -) -> mock.Mock: - """Make stdin a terminal and answer the re-ask prompts in order.""" - monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) - prompt = mock.Mock(side_effect=answers) - monkeypatch.setattr(click, "prompt", prompt) - return prompt - - def _topic_dir(cwd: Path, year: str, slug: str) -> Path: """Create the working-copy topic directory of the oracle scenarios.""" path = cwd / ".goga" / "history" / year / slug @@ -197,22 +203,36 @@ def test_enter_topic_todo_signature(self) -> None: signature.bind("feature-foo", year="2026") def test_create_topic_signature(self) -> None: - """``create_topic(branch_name, year=None, todo=None) -> str``.""" + """``create_topic(branch_name, base_ref, todo=None, publish=False, commit_message=None, year=None) -> str``.""" signature = inspect.signature(create_topic) - assert list(signature.parameters) == ["branch_name", "year", "todo"] + assert list(signature.parameters) == [ + "branch_name", + "base_ref", + "todo", + "publish", + "commit_message", + "year", + ] assert all( parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) - assert signature.parameters["year"].default is None assert signature.parameters["todo"].default is None + assert signature.parameters["publish"].default is False + assert signature.parameters["commit_message"].default is None + assert signature.parameters["year"].default is None hints = typing.get_type_hints(create_topic) assert hints == { "branch_name": str, - "year": str | None, + "base_ref": str, "todo": str | None, + "publish": bool, + "commit_message": str | None, + "year": str | None, "return": str, } + signature.bind("b", "origin/main", todo="t", publish=False, commit_message=None, year="2026") + signature.bind("b", "HEAD") def test_no_cleanliness_probe_in_creation(self) -> None: """Creation owns no cleanliness policy — no probe is imported.""" @@ -424,283 +444,342 @@ def test_check_slug_occupancy_default_year_is_current( class TestCreateTopic: - def test_create_topic_creates_branch_and_dir( + def test_create_topic_normal_path_order( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A free name: verbatim branch creation plus the slug directory.""" + """The normal path runs its actions in the fixed order. + + The branch is planted at the base commit the preflight resolved, + the checkout follows, then the topic directory, and the todo + write is the last action of the path — the declined publication + ask keeps the work local. + """ monkeypatch.chdir(tmp_path) - create_and_switch = _wire_inventory(monkeypatch, [], current="main") + wired = _wire_creation(monkeypatch, current="main", base_commit="c0ffee") + wired.ensure_topic_dir.side_effect = lambda name, year: _topic_dir( + tmp_path, year, name + ) + monkeypatch.setattr(creation, "ensure_topic_dir", wired.ensure_topic_dir) + wired.write_todo.side_effect = creation._write_todo + monkeypatch.setattr(creation, "_write_todo", wired.write_todo) + _tty(monkeypatch) + monkeypatch.setattr(click, "confirm", mock.Mock(return_value=False)) - result = create_topic("Feature/Foo_Bar", year="2025") + result = create_topic("feature-foo", "origin/main", todo="Fix.", year="2026") - assert result == "Created branch Feature/Foo_Bar and topic 2025/feature-foo-bar" - create_and_switch.assert_called_once_with("Feature/Foo_Bar") - assert (tmp_path / ".goga" / "history" / "2025" / "feature-foo-bar").is_dir() + assert result == "Created branch feature-foo and topic 2026/feature-foo" + assert wired.mock_calls == [ + mock.call.resolve_ref_commit("origin/main"), + mock.call.create_branch("feature-foo", "c0ffee"), + mock.call.checkout("feature-foo"), + mock.call.ensure_topic_dir("feature-foo", "2026"), + mock.call.write_todo("feature-foo", "2026", "Fix."), + ] + todo_file = tmp_path / ".goga" / "history" / "2026" / "feature-foo" / "todo.md" + assert todo_file.read_text(encoding="utf-8") == "Fix.\n" - def test_create_topic_default_year_is_current( + def test_create_topic_base_passed_to_the_plant( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Without a year the topic directory lands in the current one.""" + """The base is resolved once and the branch is planted at that commit.""" monkeypatch.chdir(tmp_path) - create_and_switch = _wire_inventory(monkeypatch, [], current="main") - monkeypatch.setattr(creation, "current_year", lambda: "2026") + wired = _wire_creation(monkeypatch, base_commit="abc123") - result = create_topic("Feature/Foo_Bar") + create_topic("feat-a", "origin/main", todo="T", year="2026") - assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" - create_and_switch.assert_called_once_with("Feature/Foo_Bar") - assert (tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar").is_dir() + wired.resolve_ref_commit.assert_called_once_with("origin/main") + wired.create_branch.assert_called_once_with("feat-a", "abc123") - def test_create_topic_with_todo_fresh_path( + def test_create_topic_publication_ask_yes_delegates( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A free name with a todo: the branch, the directory, the todo file.""" + """An accepted ask delegates the whole work to the publication cycle. + + The delegation reaches ``publish_topic`` at its definition site — + the call-time import resolves the patched attribute — with the + name, the resolved todo, the base, the template, and the year; + none of the local mutations runs. + """ monkeypatch.chdir(tmp_path) - create_and_switch = _wire_inventory(monkeypatch, [], current="main") + wired = _wire_creation(monkeypatch) + confirm = mock.Mock(return_value=True) + monkeypatch.setattr(click, "confirm", confirm) + published = mock.Mock(return_value="published line") + monkeypatch.setattr(publishing, "publish_topic", published) + _tty(monkeypatch) - result = create_topic("Feature/Foo_Bar", "2026", "Payment retry") + result = create_topic("feature-foo", "origin/main", todo="Fix.", year="2026") - assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" - create_and_switch.assert_called_once_with("Feature/Foo_Bar") - todo_file = ( - tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" / "todo.md" + assert result == "published line" + confirm.assert_called_once_with("Publish the branch to origin?") + published.assert_called_once_with( + "feature-foo", "Fix.", "origin/main", None, "2026" ) - assert todo_file.read_bytes() == b"Payment retry\n" + wired.create_branch.assert_not_called() + wired.checkout.assert_not_called() - def test_create_topic_writes_multiline_todo( + def test_create_topic_publication_ask_empty_answer_is_no( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A multi-line todo: the file carries the text verbatim plus one newline.""" + """An empty answer at the ask reads the default no — the work stays local.""" monkeypatch.chdir(tmp_path) - create_and_switch = _wire_inventory(monkeypatch, [], current="main") - - result = create_topic( - "Feature/Foo_Bar", - year="2026", - todo="Fix payment retries.\n\nRetries ignore the cap.", + wired = _wire_creation(monkeypatch) + _tty(monkeypatch) + monkeypatch.setattr( + click.termui, "visible_prompt_func", mock.Mock(return_value="") ) - assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" - create_and_switch.assert_called_once_with("Feature/Foo_Bar") - topic_dir = tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" - # Empty lines inside the text stay as entered; one trailing newline. - assert (topic_dir / "todo.md").read_bytes() == ( - b"Fix payment retries.\n\nRetries ignore the cap.\n" - ) - # The todo file is the single artifact of the topic directory. - assert [path.name for path in topic_dir.iterdir()] == ["todo.md"] + result = create_topic("feature-foo", "origin/main", todo="Fix.", year="2026") - def test_create_topic_whitespace_todo_writes_verbatim( + assert result == "Created branch feature-foo and topic 2026/feature-foo" + wired.create_branch.assert_called_once_with("feature-foo", "c0ffee") + + def test_create_topic_editor_todo_on_tty( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A whitespace-only todo is a non-empty text — it passes the gate and is written verbatim.""" + """Without a value the terminal opens the editor; the saved text is + written with exactly one trailing newline. + + The editor's read-back already ends with a newline — the shared + write helper must not double it. + """ monkeypatch.chdir(tmp_path) - create_and_switch = _wire_inventory(monkeypatch, [], current="main") + _wire_creation(monkeypatch) + _editor_script(monkeypatch, tmp_path, "printf 'From editor.\\n' > \"$1\"") + _tty(monkeypatch) + monkeypatch.setattr(click, "confirm", mock.Mock(return_value=False)) - result = create_topic("feat-a", year="2026", todo=" ") + result = create_topic("feature-foo", "HEAD", year="2026") - assert result == "Created branch feat-a and topic 2026/feat-a" - create_and_switch.assert_called_once_with("feat-a") - topic_dir = tmp_path / ".goga" / "history" / "2026" / "feat-a" - assert (topic_dir / "todo.md").read_bytes() == b" \n" + assert result == "Created branch feature-foo and topic 2026/feature-foo" + todo_file = tmp_path / ".goga" / "history" / "2026" / "feature-foo" / "todo.md" + assert todo_file.read_text(encoding="utf-8") == "From editor.\n" - def test_create_topic_idempotent_current_host( + def test_create_topic_base_resolved_in_preflight_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """The current branch already hosting the slug: success, no mutation.""" + """An unresolvable base is a preflight error — before any input. + + The editor sentinel never launches: a failing base must not waste + an entered todo, and no mutation runs. + """ monkeypatch.chdir(tmp_path) - create_and_switch = _wire_inventory( - monkeypatch, [], current="feature-foo-bar" - ) - ensure_dir = mock.Mock( - side_effect=lambda name, _year: _topic_dir(tmp_path, "2026", name.lower()) + wired = _wire_creation(monkeypatch) + wired.resolve_ref_commit.side_effect = subprocess.CalledProcessError( + 128, ["git", "rev-parse", "no-such-ref"], stderr=b"fatal: bad revision" ) - monkeypatch.setattr(creation, "ensure_topic_dir", ensure_dir) - monkeypatch.setattr(creation, "current_year", lambda: "2026") + marker = tmp_path / "editor-launched" + _editor_script(monkeypatch, tmp_path, f"touch '{marker}'") + _tty(monkeypatch) - result = create_topic("feature-foo-bar") + with pytest.raises(click.ClickException, match="bad revision"): + create_topic("feature-foo", "no-such-ref", year="2026") - assert result == "Branch feature-foo-bar already hosts topic 2026/feature-foo-bar" - create_and_switch.assert_not_called() - ensure_dir.assert_not_called() + assert not marker.exists() + wired.create_branch.assert_not_called() + wired.checkout.assert_not_called() - def test_create_topic_without_todo_writes_no_todo_file( + def test_create_topic_empty_slug_preflight_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A free name without a todo: the topic directory carries no todo file.""" + """An empty slug is the first preflight error — nothing else runs. + + The current-branch check, the occupancy oracles, the base + resolution, and the editor all stay untouched. + """ monkeypatch.chdir(tmp_path) - _wire_inventory(monkeypatch, [], current="main") - monkeypatch.setattr(creation, "current_year", lambda: "2026") + wired = _wire_creation(monkeypatch) + probes = mock.Mock() + monkeypatch.setattr( + creation, "resolve_current_branch_name", probes.current_branch + ) + monkeypatch.setattr(creation, "check_branch_occupancy", probes.branch_oracle) + monkeypatch.setattr(creation, "check_slug_occupancy", probes.slug_oracle) + marker = tmp_path / "editor-launched" + _editor_script(monkeypatch, tmp_path, f"touch '{marker}'") + _tty(monkeypatch) - result = create_topic("Feature/Foo_Bar") + with pytest.raises(click.ClickException, match="empty topic slug"): + create_topic("???", "origin/main", todo="x", year="2026") - assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" - topic_dir = tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" - assert topic_dir.is_dir() - assert not (topic_dir / "todo.md").exists() + probes.assert_not_called() + wired.resolve_ref_commit.assert_not_called() + wired.create_branch.assert_not_called() + assert not marker.exists() - def test_create_topic_empty_string_todo_writes_nothing( + def test_create_topic_todo_non_tty_clean_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """An empty todo string writes no file — truthiness, not ``is not None``.""" + """No todo value without a terminal is a clean error naming the + value option — before any mutation.""" monkeypatch.chdir(tmp_path) - # A genuinely free name over tmp_path: the inventory is empty and the - # real topic oracle finds no directory. - create_and_switch = _wire_inventory(monkeypatch, [], current="main") + wired = _wire_creation(monkeypatch) + marker = tmp_path / "editor-launched" + _editor_script(monkeypatch, tmp_path, f"touch '{marker}'") + _non_interactive(monkeypatch) - result = create_topic("feat-a", year="2026", todo="") + with pytest.raises(click.ClickException, match="--todo"): + create_topic("feature-foo", "origin/main", year="2026") - assert result == "Created branch feat-a and topic 2026/feat-a" - create_and_switch.assert_called_once_with("feat-a") - topic_dir = tmp_path / ".goga" / "history" / "2026" / "feat-a" - assert topic_dir.is_dir() - # The empty string never creates the file — no bare-newline todo.md. - assert not (topic_dir / "todo.md").exists() + assert not marker.exists() + wired.create_branch.assert_not_called() + wired.checkout.assert_not_called() - @pytest.mark.parametrize("todo", [None, ""]) - def test_create_topic_idempotent_without_todo_leaves_file( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, todo: str | None + def test_create_topic_publish_without_todo_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """The idempotent path without a todo: an existing todo file stays verbatim. - - ``None`` and the empty string behave alike — neither creates nor - overwrites; the regression guard against the old - ``if title is not None`` condition, where ``""`` wiped the file. - """ + """The publication path with a cancelled editor entry is a clean + error asking for the todo — a todo-less publish never happens.""" monkeypatch.chdir(tmp_path) - topic_dir = _topic_dir(tmp_path, "2026", "feat-a") - (topic_dir / "todo.md").write_text("Old\n", encoding="utf-8") - create_and_switch = _wire_inventory(monkeypatch, [], current="feat-a") + wired = _wire_creation(monkeypatch) + _editor_script(monkeypatch, tmp_path, "exit 0") + _tty(monkeypatch) - result = create_topic("feat-a", year="2026", todo=todo) + with pytest.raises(click.ClickException, match="needs a todo"): + create_topic("feature-foo", "origin/main", publish=True, year="2026") - assert result == "Branch feat-a already hosts topic 2026/feat-a" - create_and_switch.assert_not_called() - assert (topic_dir / "todo.md").read_text(encoding="utf-8") == "Old\n" + wired.create_branch.assert_not_called() + wired.checkout.assert_not_called() - def test_create_topic_idempotent_overwrites_todo( + def test_create_topic_current_branch_same_slug_is_conflict( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """The current host with an explicit todo: ensure, overwrite, no switch.""" + """The current branch hosting the slug is a conflict — the + idempotent path is abolished; no input, no mutation.""" monkeypatch.chdir(tmp_path) - topic_dir = _topic_dir(tmp_path, "2026", "feat-a") - (topic_dir / "todo.md").write_text("Old\n", encoding="utf-8") - create_and_switch = _wire_inventory(monkeypatch, [], current="feat-a") + wired = _wire_creation(monkeypatch, current="feature-foo") + marker = tmp_path / "editor-launched" + _editor_script(monkeypatch, tmp_path, f"touch '{marker}'") + _tty(monkeypatch) - result = create_topic("feat-a", year="2026", todo="New summary") + with pytest.raises(click.ClickException, match="already hosts"): + create_topic("feature-foo", "origin/main", todo="x") - assert result == "Branch feat-a already hosts topic 2026/feat-a" - create_and_switch.assert_not_called() - assert (topic_dir / "todo.md").read_text(encoding="utf-8") == "New summary\n" + assert not marker.exists() + wired.create_branch.assert_not_called() - def test_create_topic_occupied_non_interactive_clean_error( + def test_create_topic_occupied_name_error_no_reask( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """An occupancy conflict without a terminal: the reason and the hint.""" + """An occupancy conflict is one clean error with the board hint — + the abolished re-ask must not resurrect.""" monkeypatch.chdir(tmp_path) - _non_interactive(monkeypatch) - create_and_switch = _wire_inventory(monkeypatch, [], current="main") - inventory = [BranchRef(name="feat/x", remote=False)] - monkeypatch.setattr(creation, "list_branch_refs", lambda: inventory) + _tty(monkeypatch) + _editor_script(monkeypatch, tmp_path, "exit 0") + prompt = mock.Mock() + monkeypatch.setattr(click, "prompt", prompt) + wired = _wire_creation(monkeypatch, current="main") + monkeypatch.setattr( + creation, + "list_branch_refs", + lambda: [BranchRef(name="feat/x", remote=False)], + ) with pytest.raises(click.ClickException) as raised: - create_topic("feat/x") + create_topic("feat/x", "HEAD") assert raised.value.message == ( "branch 'feat/x' already exists — run 'goga topics board' to see the board" ) - create_and_switch.assert_not_called() + prompt.assert_not_called() + wired.create_branch.assert_not_called() assert not (tmp_path / ".goga" / "history").exists() - def test_create_topic_empty_slug_non_interactive_clean_error( + def test_create_topic_creates_branch_and_dir_with_cancelled_entry( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A name that normalizes to nothing: the reason, no prompt, no work.""" + """A free name with a cancelled editor entry: the verbatim branch + and the slug directory — and no todo file.""" monkeypatch.chdir(tmp_path) - _non_interactive(monkeypatch) - create_and_switch = _wire_inventory(monkeypatch, [], current="main") - prompt = mock.Mock() - monkeypatch.setattr(click, "prompt", prompt) + wired = _wire_creation(monkeypatch, current="main") + _editor_script(monkeypatch, tmp_path, "exit 0") + _tty(monkeypatch) - with pytest.raises(click.ClickException) as raised: - create_topic("🚀") + result = create_topic("Feature/Foo_Bar", "HEAD", year="2025") - assert raised.value.message == ( - "branch name '🚀' normalizes to an empty topic slug" - ) - prompt.assert_not_called() - create_and_switch.assert_not_called() + assert result == "Created branch Feature/Foo_Bar and topic 2025/feature-foo-bar" + wired.create_branch.assert_called_once_with("Feature/Foo_Bar", "c0ffee") + wired.checkout.assert_called_once_with("Feature/Foo_Bar") + topic_dir = tmp_path / ".goga" / "history" / "2025" / "feature-foo-bar" + assert topic_dir.is_dir() + assert not (topic_dir / "todo.md").exists() - def test_create_topic_empty_slug_reask( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], + def test_create_topic_default_year_is_current( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """An unusable name on a terminal: the cycle restarts until a good one.""" + """Without a year the topic directory lands in the current one.""" monkeypatch.chdir(tmp_path) - prompt = _interactive(monkeypatch, ["???", "good-name"]) - create_and_switch = _wire_inventory(monkeypatch, [], current="main") + _wire_creation(monkeypatch, current="main") + _editor_script(monkeypatch, tmp_path, "exit 0") + _tty(monkeypatch) monkeypatch.setattr(creation, "current_year", lambda: "2026") - result = create_topic("!!!") + result = create_topic("Feature/Foo_Bar", "HEAD") - assert result == "Created branch good-name and topic 2026/good-name" - assert prompt.call_count == 2 - assert prompt.call_args.args[0] == "New branch name" - create_and_switch.assert_called_once_with("good-name") - assert (tmp_path / ".goga" / "history" / "2026" / "good-name").is_dir() - stderr = capsys.readouterr().err - assert "branch name '!!!' normalizes to an empty topic slug" in stderr - assert "branch name '???' normalizes to an empty topic slug" in stderr + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + assert (tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar").is_dir() - def test_create_topic_occupied_reask_creates_second_name( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], + def test_create_topic_with_todo_value( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """An occupied name on a terminal: the conflict goes to stderr, then re-ask.""" + """A free name with a todo value: the branch, the directory, the todo file.""" monkeypatch.chdir(tmp_path) - _interactive(monkeypatch, ["feat/other"]) - inventory = [BranchRef(name="feat/x", remote=False)] - create_and_switch = _wire_inventory( - monkeypatch, inventory, current="main" + _wire_creation(monkeypatch, current="main") + + result = create_topic( + "Feature/Foo_Bar", "HEAD", todo="Payment retry", year="2026" ) - monkeypatch.setattr(creation, "current_year", lambda: "2026") - result = create_topic("feat/x") + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + todo_file = ( + tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" / "todo.md" + ) + assert todo_file.read_bytes() == b"Payment retry\n" - assert result == "Created branch feat/other and topic 2026/feat-other" - create_and_switch.assert_called_once_with("feat/other") - stderr = capsys.readouterr().err - assert "branch 'feat/x' already exists" in stderr + def test_create_topic_writes_multiline_todo( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A multi-line todo: the file carries the text verbatim plus one newline.""" + monkeypatch.chdir(tmp_path) + _wire_creation(monkeypatch, current="main") - def test_create_topic_reask_abort_leaves_repository_untouched( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, + result = create_topic( + "Feature/Foo_Bar", + "HEAD", + year="2026", + todo="Fix payment retries.\n\nRetries ignore the cap.", + ) + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + topic_dir = tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" + # Empty lines inside the text stay as entered; one trailing newline. + assert (topic_dir / "todo.md").read_bytes() == ( + b"Fix payment retries.\n\nRetries ignore the cap.\n" + ) + # The todo file is the single artifact of the topic directory. + assert [path.name for path in topic_dir.iterdir()] == ["todo.md"] + + def test_create_topic_whitespace_todo_writes_verbatim( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Ctrl-C at the re-ask prompt propagates as ``click.Abort`` — nothing is created.""" + """A whitespace-only todo is a non-empty text — it is written verbatim.""" monkeypatch.chdir(tmp_path) - monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) - monkeypatch.setattr(click, "prompt", mock.Mock(side_effect=click.Abort())) - create_and_switch = _wire_inventory(monkeypatch, [], current="main") + _wire_creation(monkeypatch, current="main") - with pytest.raises(click.Abort): - create_topic("!!!") + result = create_topic("feat-a", "HEAD", year="2026", todo=" ") - create_and_switch.assert_not_called() - assert not (tmp_path / ".goga").exists() + assert result == "Created branch feat-a and topic 2026/feat-a" + topic_dir = tmp_path / ".goga" / "history" / "2026" / "feat-a" + assert (topic_dir / "todo.md").read_bytes() == b" \n" def test_create_topic_todo_write_failure_is_clean_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A failing todo write becomes the generalized clean error.""" monkeypatch.chdir(tmp_path) - create_and_switch = _wire_inventory(monkeypatch, [], current="main") + wired = _wire_creation(monkeypatch, current="main") monkeypatch.setattr( creation, "resolve_topic_file", @@ -708,32 +787,14 @@ def test_create_topic_todo_write_failure_is_clean_error( ) with pytest.raises(click.ClickException) as raised: - create_topic("Feature/Foo_Bar", "2026", "T") + create_topic("Feature/Foo_Bar", "HEAD", todo="T", year="2026") assert ( "cannot create the topic directory or write the todo file" in raised.value.message ) - # The traced order — the branch mutation runs before the todo write. - create_and_switch.assert_called_once_with("Feature/Foo_Bar") - - def test_create_topic_todo_survives_reask( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """The todo is a procedure parameter — a re-asked name keeps it.""" - monkeypatch.chdir(tmp_path) - prompt = _interactive(monkeypatch, ["Feature/Foo_Bar"]) - create_and_switch = _wire_inventory(monkeypatch, [], current="main") - - result = create_topic("ББ", "2026", "T") - - assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" - create_and_switch.assert_called_once_with("Feature/Foo_Bar") - assert prompt.call_count == 1 - todo_file = ( - tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" / "todo.md" - ) - assert todo_file.read_text(encoding="utf-8") == "T\n" + # The traced order — the branch mutations run before the todo write. + wired.create_branch.assert_called_once_with("Feature/Foo_Bar", "c0ffee") # --- Logic tests: the todo entry of a topic --- @@ -876,21 +937,20 @@ def test_missing_git_binary_surfaces_as_clean_error( def test_create_mutation_failure_surfaces_as_clean_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A failing create-and-switch becomes a ``ClickException``.""" + """A failing branch plant becomes a ``ClickException``.""" monkeypatch.chdir(tmp_path) + _wire_creation(monkeypatch, current="main") failure = subprocess.CalledProcessError( returncode=128, - cmd=["git", "switch", "-c", "feat/x"], + cmd=["git", "branch", "feat/x"], stderr="fatal: invalid branch name", ) monkeypatch.setattr( - creation, "create_and_switch_branch", mock.Mock(side_effect=failure) + creation, "create_branch_at_commit", mock.Mock(side_effect=failure) ) - monkeypatch.setattr(creation, "list_branch_refs", lambda: []) - monkeypatch.setattr(creation, "resolve_current_branch_name", lambda: "main") with pytest.raises(click.ClickException) as raised: - create_topic("feat/x", year="2026") + create_topic("feat/x", "HEAD", todo="T", year="2026") assert "fatal: invalid branch name" in raised.value.message assert not (tmp_path / ".goga" / "history" / "2026" / "feat-x").exists() @@ -900,13 +960,15 @@ def test_missing_git_binary_at_creation_surfaces_as_clean_error( ) -> None: """A missing git binary during the create mutation is a clean error.""" monkeypatch.chdir(tmp_path) - _wire_inventory(monkeypatch, [], current="main") + _wire_creation(monkeypatch, current="main") monkeypatch.setattr( - creation, "create_and_switch_branch", mock.Mock(side_effect=FileNotFoundError("git")) + creation, + "create_branch_at_commit", + mock.Mock(side_effect=FileNotFoundError("git")), ) with pytest.raises(click.ClickException) as raised: - create_topic("feat/x", year="2026") + create_topic("feat/x", "HEAD", todo="T", year="2026") assert "git" in raised.value.message @@ -916,23 +978,24 @@ def test_stray_file_at_topic_path_surfaces_as_clean_error( """A stray file named like the slug occupies no topic — the mkdir failure is a clean error. The history oracle counts directories only, so the name is free and - the branch is created first; ``ensure_topic_dir`` then fails on the - file, and the boundary turns the ``OSError`` into a clean error + the branch mutations run first; ``ensure_topic_dir`` then fails on + the file, and the boundary turns the ``OSError`` into a clean error instead of a traceback. """ monkeypatch.chdir(tmp_path) year_dir = tmp_path / ".goga" / "history" / "2026" year_dir.mkdir(parents=True) (year_dir / "feat-x").write_text("not a topic", encoding="utf-8") - create_and_switch = _wire_inventory(monkeypatch, [], current="main") + wired = _wire_creation(monkeypatch, current="main") with pytest.raises(click.ClickException) as raised: - create_topic("feat-x", year="2026") + create_topic("feat-x", "HEAD", todo="T", year="2026") assert ( "cannot create the topic directory or write the todo file" in raised.value.message ) assert "feat-x" in raised.value.message - # The traced order — the branch mutation runs before the directory. - create_and_switch.assert_called_once_with("feat-x") + # The traced order — the branch mutations run before the directory. + wired.create_branch.assert_called_once_with("feat-x", "c0ffee") + wired.checkout.assert_called_once_with("feat-x") From 9f4cadc0cfe4a55c8266322b5538e0e6707dd0eb Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 21:23:21 +0000 Subject: [PATCH 189/229] =?UTF-8?q?feat:=20rework=20goga=20topics=20CLI=20?= =?UTF-8?q?=E2=80=94=20value-only=20--todo,=20base/template=20matrix,=20--?= =?UTF-8?q?from-current,=20switch=20--todo,=20delete=20subcommand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- goga/commands/topics/topics.py | 244 ++++--- tests/commands/topics/test_topics_command.py | 711 +++++++++++-------- tests/integration/test_topic_workflows.py | 1 + 3 files changed, 518 insertions(+), 438 deletions(-) diff --git a/goga/commands/topics/topics.py b/goga/commands/topics/topics.py index 28243b85..4fe4bfe7 100644 --- a/goga/commands/topics/topics.py +++ b/goga/commands/topics/topics.py @@ -1,17 +1,20 @@ """The ``goga topics`` command group — the CLI surface of the topics domain. The click group declared in the cell CODEMANIFEST with ``location: -topics.py``: the ``board``/``create``/``switch`` subcommands over the -topics domain. The group carries the year scope every subcommand shares and -is a thin wrapper — it resolves the inputs, delegates every computation to -the domain routines of ``goga.topics``, and renders the board through the -``render`` module. The todo of the fresh work is resolved at this layer: -the ``--todo/-t`` value acts as given, a bare flag starts the interactive -multi-line entry. The fast creation-and-publication mode of ``create`` -resolves its own inputs at this layer: a flag beats the ``topics`` section -of the project configuration, which beats the built-in default. No -inventory walking, no switch resolution, and no git access live here; -domain errors surface as clean CLI errors. +topics.py``: the ``board``/``create``/``switch``/``delete`` subcommands +over the topics domain. The group carries the year scope every subcommand +shares and is a thin wrapper — it resolves the inputs, delegates every +computation to the domain routines of ``goga.topics``, and renders the +board through the ``render`` module. The creation inputs resolve their +values at this layer: the base — ``--base-ref``, the ``topics`` section +of the project configuration, the current HEAD under ``--from-current`` +— and the commit message template — ``--commit/-c``, the ``topics`` +section, the built-in default of the domain; the configuration is read +lazily, only for values no flag provided. The deletion is confirmed at +this layer — one confirmation for the whole resolved list. No inventory +walking, no switch resolution, no git access, and no editor session +live here — the todo value passes through and the entry belongs to the +domain. Domain errors surface as clean CLI errors. """ from __future__ import annotations @@ -22,16 +25,17 @@ import click import yaml -from click import termui from ...config import TopicsConfig, load_project_config -from ...topics import collect_topic_board, create_topic, publish_topic, switch_topic +from ...topics import ( + collect_topic_board, + create_topic, + delete_topics, + resolve_delete_targets, + switch_topic, +) from .render import render_topic_board -# The built-in template of the publish path — the lowest row of the -# flag > topics section > default resolution matrix. -_DEFAULT_PUBLISH_COMMIT = "goga: create topic {slug}" - @dataclass(kw_only=True) class _TopicsScope: @@ -56,51 +60,6 @@ def _topics_section() -> TopicsConfig | None: raise click.ClickException(str(exc)) from exc -def _prompt_multiline(label: str) -> str | None: - """Collect a multi-line text interactively — one input per line. - - The prompt states the rule itself: a lone ``.`` line or Ctrl+D (EOF) - finishes the entry, every entered line continues the text, and an empty - line is an allowed text line — paragraphs survive. No line entered - cancels the entry and returns None — an empty text is never produced; - the emptiness check runs on the joined text, so a single blank line - cancels the entry the same way. A non-interactive terminal is a clean - error raised before the first prompt; a Ctrl+C aborts the command — - it is not a terminator. - - Args: - label: the human name of the collected value — used in the prompt - and in the non-interactive error. - - Returns: - The joined text — paragraphs separated by single newlines — or None - when the entry was cancelled. - """ - if not sys.stdin.isatty(): - raise click.ClickException(f"{label} entry needs an interactive terminal") - - click.echo(f"Enter the {label}. Finish with a lone '.' line or Ctrl+D.") - lines: list[str] = [] - - while True: - try: - # Resolved through the module attribute at call time — a - # from-imported binding would never see the CliRunner patch. - line = termui.visible_prompt_func("") - except EOFError: - break - except KeyboardInterrupt: - raise click.Abort() from None - - if line == ".": - break - - lines.append(line) - - text = "\n".join(lines) - return text if text else None - - @click.group() @click.option( "--year", @@ -154,29 +113,33 @@ def board(scope: _TopicsScope, remote: bool = False, info: bool = False) -> None "-t", "todo", default=None, - is_flag=False, - flag_value="", metavar="[TEXT]", - help="Todo of the fresh work; without a value — interactive entry", + help="Todo of the fresh work; an empty value counts as absent; without a value the editor opens on a terminal.", ) @click.option( "--publish", "-p", is_flag=True, default=False, - help="Create the work off an explicit base and publish it to origin without switching.", + help="Create the work off the base and publish it to origin without switching and without the ask.", ) @click.option( "--base-ref", default=None, - help="Base revision of the published branch; beats topics.base_ref of .goga/config.yml.", + help="Base of the branch; beats topics.base_ref of .goga/config.yml, which beats --from-current.", +) +@click.option( + "--from-current", + is_flag=True, + default=False, + help="Base the branch on the current HEAD.", ) @click.option( "--commit", "-c", "commit_message", default=None, - help="Commit message template; beats topics.publish_commit — {slug} takes the topic slug.", + help="Commit message template, publication-only; beats topics.publish_commit — {slug} takes the topic slug.", ) @click.pass_obj def create( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared CLI surface @@ -185,90 +148,125 @@ def create( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared CLI surface todo: str | None = None, publish: bool = False, base_ref: str | None = None, + from_current: bool = False, commit_message: str | None = None, ) -> None: - """Create fresh work — a branch with the name as entered and its topic directory. + """Create fresh work — a branch off the resolved base with its topic directory. The branch name is taken verbatim; the topic directory of the scoped - year is created from its slug. An explicit --todo/-t also writes the - topic todo file todo.md — the multi-line text as entered plus one - trailing newline; the flag given without a value starts the - interactive multi-line entry on a terminal (a lone '.' line or Ctrl+D - finishes, nothing entered cancels). Without a todo no todo file is - written. The current branch already hosting the same slug is an - idempotent success. Occupied names and empty slugs re-ask on an - interactive terminal and fail with a clean error otherwise. One - result line on stdout. - - --publish/-p is the fast mode: the branch is created off an explicit - base — --base-ref, otherwise topics.base_ref of .goga/config.yml — - carrying one commit with the topic todo file — the message template - from --commit/-c, otherwise topics.publish_commit, otherwise the - built-in default — and is pushed to origin without switching. The - todo is required in this mode — the board reads the topic through - todo.md — and a failed publication rolls back fully: the planted - branch is deleted and one clean error names the reason. + year is created from its slug. The base resolves as --base-ref, then + topics.base_ref of .goga/config.yml, then the current HEAD under + --from-current; no base at all is a clean error naming the flag and + the configuration line. An explicit --todo/-t value is the todo — an + empty value counts as absent; without a value a terminal opens the + external editor and without a terminal the command is a clean error + naming the option. On a terminal without --publish the publication + ask appears once a todo is resolved; declining takes the normal path + — the branch off the base, the switch, the topic directory, then + todo.md. --publish/-p publishes to origin without switching and + without the ask; a failed publication rolls back fully. --commit/-c + — the message template; topics.publish_commit; the built-in default + lives in the domain — is publication-only. One result line on + stdout. """ - if not publish and (base_ref is not None or commit_message is not None): - raise click.ClickException("--base-ref and --commit act only together with --publish") + if commit_message is not None and not publish: + raise click.ClickException("--commit is publication-only — it acts only together with --publish") - # The empty string is the entry marker of the bare flag, never a - # written value; a cancelled entry continues as without the flag. + # The empty --todo value counts as an absent option; the entry and + # the write belong to the domain. if todo == "": - todo = _prompt_multiline("todo") - - if publish and todo is None: - raise click.ClickException( - "--publish needs a todo — pass --todo/-t; the board reads the topic through todo.md" - ) - - if not publish: - # Interim delegation (superseded by the full CLI rework): the - # domain owns the todo resolution now, and "HEAD" reproduces the - # old behavior — a branch from the current HEAD. - line = create_topic(branch_name, "HEAD", todo, year=scope.year) - click.echo(line) - click.get_current_context().exit(0) + todo = None # The configuration is read lazily — only when a value no flag - # provided has to come from it; both flags given means zero reads. + # provided has to come from it; both values given means zero reads. section = _topics_section() if base_ref is None or commit_message is None else None - base = base_ref if base_ref is not None else (section.base_ref if section is not None else None) + base = base_ref + if base is None and section is not None: + base = section.base_ref + if base is None and from_current: + base = "HEAD" if base is None: raise click.ClickException( - "no base for the published branch — set topics.base_ref in .goga/config.yml or pass --base-ref:\n" - "topics:\n base_ref: origin/main" + "no base for the branch — pass --base-ref or --from-current, or set " + "topics.base_ref in .goga/config.yml:\ntopics:\n base_ref: origin/main" ) - template = ( - commit_message - if commit_message is not None - else ( - section.publish_commit - if section is not None and section.publish_commit is not None - else _DEFAULT_PUBLISH_COMMIT - ) - ) + template = commit_message + if template is None and section is not None: + template = section.publish_commit - line = publish_topic(branch_name, todo, base, template, scope.year) + line = create_topic(branch_name, base, todo, publish, template, scope.year) click.echo(line) click.get_current_context().exit(0) @topics.command("switch") @click.argument("identifier") +@click.option( + "--todo", + is_flag=True, + default=False, + help="Open the editor with the switched topic's todo.md after the switch.", +) @click.pass_obj -def switch(scope: _TopicsScope, identifier: str) -> None: +def switch(scope: _TopicsScope, identifier: str, todo: bool = False) -> None: """Bring the repository onto the branch hosting the requested work. IDENTIFIER is a branch name, a topic slug, or their prefix — resolved in that order. Several candidates offer a numbered list and a prompt on an interactive terminal; already being on the host is an idempotent success, and a dirty working tree is a clean error when a mutation is - needed. One result line on stdout; no pipeline is launched — - continuation is a separate command. + needed. With --todo the external editor opens with the switched + topic's todo.md after the switch — saving overwrites the file without + a commit, cancelling leaves it untouched; the flag needs an + interactive terminal and a topic on the host branch. One result line + on stdout; no pipeline is launched — continuation is a separate + command. """ - line = switch_topic(identifier, year=scope.year) + line = switch_topic(identifier, todo, scope.year) + click.echo(line) + click.get_current_context().exit(0) + + +@topics.command("delete") +@click.argument("identifiers", nargs=-1, required=True) +@click.option( + "--yes", + "-y", + is_flag=True, + default=False, + help="Skip the confirmation; sits after the subcommand token, unlike the group -y year.", +) +@click.pass_obj +def delete(scope: _TopicsScope, identifiers: tuple[str, ...], yes: bool = False) -> None: + """Delete identified topics — the branch, its origin twin, and the directory. + + Every IDENTIFIER resolves first — a branch name, a topic slug, or + their prefix; an unknown or ambiguous identifier is a clean error and + nothing is deleted. The resolved list prints one line per target — + the topic, then its branch, its remote twin, or (directory only) — + and one confirmation covers the whole list; a declined answer exits + 0 with nothing deleted. --yes/-y skips the confirmation; without it a + non-interactive terminal is a clean error. The deletion removes each + topic's local branch, its origin twin, and its topic directory; the + current branch hosting a target is a clean error — switch away + first. One result line on stdout. + """ + targets = resolve_delete_targets(list(identifiers), scope.year) + + if not yes: + if not sys.stdin.isatty(): + raise click.ClickException( + "the deletion confirmation needs an interactive terminal — pass --yes/-y to skip it" + ) + + for target in targets: + click.echo(f"{target.topic} -> {target.branch or target.remote or '(directory only)'}") + + if not click.confirm(f"Delete {len(targets)} topic(s)?"): + click.get_current_context().exit(0) + + line = delete_topics(targets, scope.year) click.echo(line) click.get_current_context().exit(0) diff --git a/tests/commands/topics/test_topics_command.py b/tests/commands/topics/test_topics_command.py index f4c9bfc3..de633c42 100644 --- a/tests/commands/topics/test_topics_command.py +++ b/tests/commands/topics/test_topics_command.py @@ -1,29 +1,31 @@ """Contract and logic tests for the entity declared in ``goga/commands/topics/CODEMANIFEST`` with ``location: topics.py``: -the ``topics`` click group with the ``board``/``create``/``switch`` -subcommands. - -The group is a thin wrapper: the ``--year/-y`` option builds the scope every -subcommand shares, and each subcommand delegates its computation to the -``goga.topics`` domain — the board collection and rendering for ``board`` -(the ``--info/-i`` flag adds the todo column to the rendered table), the -creation (``--todo/-t`` writes the topic todo file, a bare flag starting -the interactive multi-line entry) and switching procedures for -``create``/``switch``. ``create`` also carries the fast -creation-and-publication mode — ``--publish/-p`` with ``--base-ref`` and -``--commit/-c`` — whose values resolve as flag beats the ``topics`` section -of ``.goga/config.yml`` beats the built-in default, the configuration being -read on the publish path only. The logic tests mock the domain at its -import site in the command module and drive the CLI surface through -``CliRunner``; the interactive entry cycle — ``_prompt_multiline`` — is -driven by direct calls with a TTY-mocked stdin, since the CliRunner stdin -is never a TTY; a pinned ``COLUMNS`` keeps the measured terminal width -deterministic. +the ``topics`` click group with the ``board``/``create``/``switch``/ +``delete`` subcommands. + +The group is a thin wrapper: the ``--year/-y`` option builds the scope +every subcommand shares, and each subcommand delegates its computation +to the ``goga.topics`` domain — the board collection and rendering for +``board`` (the ``--info/-i`` flag adds the todo column to the rendered +table), the creation and switching procedures for ``create``/``switch`` +(``--todo/-t`` is a plain value option whose empty value counts as +absent; the editor entry itself belongs to the domain), and the +resolution plus confirmed removal for ``delete`` (one confirmation for +the whole list). The creation inputs resolve at this layer: the base — +``--base-ref``, the ``topics`` section of ``.goga/config.yml``, +``--from-current`` — and the message template — ``--commit/-c``, the +``topics`` section, the domain default — the configuration being read +lazily, only for values no flag provided. The logic tests mock the +domain at its import site in the command module and drive the CLI +surface through ``CliRunner``; a pinned ``COLUMNS`` keeps the measured +terminal width deterministic, and the configuration cases run against a +``tmp_path`` cwd. """ from __future__ import annotations import inspect +import io import os import shutil import sys @@ -34,7 +36,7 @@ import pytest from click.testing import CliRunner from goga.commands.topics import render_topic_board, topics -from goga.topics import BoardRecord +from goga.topics import BoardRecord, DeleteTarget # goga.commands.topics.topics is shadowed in the package __init__ by the # topics click group, so attribute access through the package gives the @@ -43,6 +45,19 @@ # The facade __all__ lives on the cell package itself. _topics_facade = sys.modules["goga.commands.topics"] +class _TtyStdin(io.BytesIO): + """A CliRunner input whose isatty() is True — models the confirm gate's terminal. + + CliRunner's isolation replaces ``sys.stdin`` around every invoke, so a + patched ``sys.stdin`` never survives into the command; an ``input=`` + stream does — click accepts it as the binary reader directly and the + TextIOWrapper it builds delegates ``isatty()`` to it. + """ + + def isatty(self) -> bool: + return True + + # --- Contract tests --- @@ -61,9 +76,9 @@ def test_topics_is_a_click_group(self) -> None: """topics is a click.Group container for the subcommands.""" assert isinstance(topics, click.Group) - def test_topics_registers_three_subcommands(self) -> None: - """The group carries exactly the three declared subcommands.""" - assert sorted(topics.commands) == ["board", "create", "switch"] + def test_topics_registers_four_subcommands(self) -> None: + """The group carries exactly the four declared subcommands.""" + assert sorted(topics.commands) == ["board", "create", "delete", "switch"] def test_topics_group_carries_the_year_option(self) -> None: """The group owns the shared --year/-y option, defaulting to None.""" @@ -119,16 +134,16 @@ def test_create_carries_the_name_positional(self) -> None: assert argument.required is True def test_create_todo_option_surface(self) -> None: - """create: --todo/-t takes an optional value — a bare flag passes the entry marker.""" + """create: --todo/-t is a plain value option — no optional-value flag.""" command = topics.commands["create"] todo_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "todo") assert "-t" in todo_option.opts assert "--todo" in todo_option.opts assert todo_option.is_flag is False assert todo_option.default is None - assert todo_option.flag_value == "" - option_flags = {opt for p in command.params if isinstance(p, click.Option) for opt in p.opts} - assert "--title" not in option_flags + # No optional-value flag: a value-less --todo is a usage error, not + # an entry marker (click keeps an UNSET sentinel here, not a value). + assert not todo_option.secondary_opts def test_create_carries_the_publish_flag(self) -> None: """create: --publish/-p flag, defaulting to False.""" @@ -147,6 +162,16 @@ def test_create_carries_the_base_ref_option(self) -> None: assert base_ref_option.is_flag is False assert base_ref_option.default is None + def test_create_carries_the_from_current_flag(self) -> None: + """create: --from-current flag, long form only, defaulting to False.""" + command = topics.commands["create"] + from_current_option = next( + p for p in command.params if isinstance(p, click.Option) and p.name == "from_current" + ) + assert from_current_option.opts == ["--from-current"] + assert from_current_option.is_flag is True + assert from_current_option.default is False + def test_create_carries_the_commit_option_with_the_explicit_param_name(self) -> None: """create: --commit/-c bound to the param name ``commit_message``.""" command = topics.commands["create"] @@ -156,7 +181,7 @@ def test_create_carries_the_commit_option_with_the_explicit_param_name(self) -> assert commit_option.default is None def test_create_callback_signature(self) -> None: - """``create(scope, branch_name, todo=None, publish=False, base_ref=None, commit_message=None)``.""" + """``create(scope, branch_name, todo, publish, base_ref, from_current, commit_message)``.""" callback = topics.commands["create"].callback signature = inspect.signature(callback) assert list(signature.parameters) == [ @@ -165,11 +190,13 @@ def test_create_callback_signature(self) -> None: "todo", "publish", "base_ref", + "from_current", "commit_message", ] assert signature.parameters["todo"].default is None assert signature.parameters["publish"].default is False assert signature.parameters["base_ref"].default is None + assert signature.parameters["from_current"].default is False assert signature.parameters["commit_message"].default is None def test_switch_carries_the_identifier_positional(self) -> None: @@ -178,34 +205,74 @@ def test_switch_carries_the_identifier_positional(self) -> None: argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "identifier") assert argument.required is True + def test_switch_carries_the_todo_flag(self) -> None: + """switch: --todo flag, long form only, defaulting to False.""" + command = topics.commands["switch"] + todo_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "todo") + assert todo_option.opts == ["--todo"] + assert todo_option.is_flag is True + assert todo_option.default is False + def test_switch_callback_signature(self) -> None: - """``switch(scope, identifier)``.""" + """``switch(scope, identifier, todo=False)``.""" callback = topics.commands["switch"].callback - assert list(inspect.signature(callback).parameters) == ["scope", "identifier"] + signature = inspect.signature(callback) + assert list(signature.parameters) == ["scope", "identifier", "todo"] + assert signature.parameters["todo"].default is False + + def test_delete_carries_the_identifiers_positionals(self) -> None: + """delete: the required variadic identifiers positional.""" + command = topics.commands["delete"] + argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "identifiers") + assert argument.required is True + assert argument.nargs == -1 + + def test_delete_carries_the_yes_flag(self) -> None: + """delete: --yes/-y flag, defaulting to False.""" + command = topics.commands["delete"] + yes_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "yes") + assert "-y" in yes_option.opts + assert "--yes" in yes_option.opts + assert yes_option.is_flag is True + assert yes_option.default is False + + def test_delete_callback_signature(self) -> None: + """``delete(scope, identifiers, yes=False)``.""" + callback = topics.commands["delete"].callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["scope", "identifiers", "yes"] + assert signature.parameters["yes"].default is False + + def test_prompt_multiline_and_default_publish_commit_are_gone(self) -> None: + """The abolished CLI-layer entry, template constant, and publish edge no longer exist.""" + assert not hasattr(_topics_module, "_prompt_multiline") + assert not hasattr(_topics_module, "_DEFAULT_PUBLISH_COMMIT") + assert not hasattr(_topics_module, "publish_topic") # --- Logic tests --- class TestTopicsGroupSurface: - def test_topics_group_help_and_year_scope(self) -> None: + def test_topics_group_help_and_year_scope(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """--help lists the subcommands and --year/-y; the scope reaches the domain.""" + monkeypatch.chdir(tmp_path) runner = CliRunner() result = runner.invoke(topics, ["--help"]) assert result.exit_code == 0 assert "Work with the topics of one year." in result.output - for subcommand in ("board", "create", "switch"): + for subcommand in ("board", "create", "switch", "delete"): assert subcommand in result.output assert "--year" in result.output assert "-y" in result.output with mock.patch.object(_topics_module, "create_topic") as mock_create: mock_create.return_value = "Created branch X and topic 2025/x" - scoped = runner.invoke(topics, ["--year", "2025", "create", "X"]) + scoped = runner.invoke(topics, ["--year", "2025", "create", "X", "--from-current"]) assert scoped.exit_code == 0 - mock_create.assert_called_once_with("X", "HEAD", None, year="2025") + mock_create.assert_called_once_with("X", "HEAD", None, False, None, "2025") - @pytest.mark.parametrize("subcommand", ["board", "create", "switch"]) + @pytest.mark.parametrize("subcommand", ["board", "create", "switch", "delete"]) def test_subcommand_help_follows_the_cli_docstring_rule(self, subcommand: str) -> None: """The rendered help carries no Args/Returns/Raises sections.""" result = CliRunner().invoke(topics, [subcommand, "--help"]) @@ -215,7 +282,7 @@ def test_subcommand_help_follows_the_cli_docstring_rule(self, subcommand: str) - assert section not in result.output def test_create_help_lists_the_new_flags(self) -> None: - """create --help lists --todo/-t, --publish/-p, --base-ref, and --commit/-c.""" + """create --help lists --todo/-t, --publish/-p, --base-ref, --from-current, and --commit/-c.""" result = CliRunner().invoke(topics, ["create", "--help"]) assert result.exit_code == 0 assert "--todo" in result.output @@ -223,16 +290,26 @@ def test_create_help_lists_the_new_flags(self) -> None: assert "--publish" in result.output assert "-p" in result.output assert "--base-ref" in result.output + assert "--from-current" in result.output assert "--commit" in result.output assert "-c" in result.output - def test_year_defaults_to_none_for_the_domain(self) -> None: + def test_delete_help_lists_the_surface(self) -> None: + """delete --help lists --yes/-y and the IDENTIFIERS argument.""" + result = CliRunner().invoke(topics, ["delete", "--help"]) + assert result.exit_code == 0 + assert "--yes" in result.output + assert "-y" in result.output + assert "IDENTIFIERS" in result.output + + def test_year_defaults_to_none_for_the_domain(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Without --year the subcommands hand the domain the current-year None.""" + monkeypatch.chdir(tmp_path) with mock.patch.object(_topics_module, "create_topic") as mock_create: mock_create.return_value = "Created branch X and topic 2026/x" - result = CliRunner().invoke(topics, ["create", "X"]) + result = CliRunner().invoke(topics, ["create", "X", "--from-current"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("X", "HEAD", None, year=None) + mock_create.assert_called_once_with("X", "HEAD", None, False, None, None) class TestTopicsBoard: @@ -359,6 +436,13 @@ def test_board_domain_error_surfaces_clean(self) -> None: assert result.stdout == "" +def _write_config(tmp_path: Path, body: str) -> None: + """Write ``.goga/config.yml`` with the given body under tmp_path.""" + goga_dir = tmp_path / ".goga" + goga_dir.mkdir(exist_ok=True) + (goga_dir / "config.yml").write_text(body, encoding="utf-8") + + class TestTopicsCreateAndSwitch: def test_create_echoes_the_domain_result_line(self) -> None: """create echoes the single result line and exits 0.""" @@ -367,25 +451,28 @@ def test_create_echoes_the_domain_result_line(self) -> None: "create_topic", return_value="Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar", ) as mock_create: - result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar"]) + result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "--base-ref", "origin/main"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("Feature/Foo_Bar", "HEAD", None, year=None) + mock_create.assert_called_once_with("Feature/Foo_Bar", "origin/main", None, False, None, None) assert result.output.splitlines() == ["Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar"] - def test_topics_create_todo_option_reaches_domain(self) -> None: - """-t hands the domain (name, HEAD, todo, scoped year) verbatim.""" + def test_topics_create_todo_option_reaches_domain( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """-t hands the domain (name, HEAD, todo, publish, template, year) verbatim.""" + monkeypatch.chdir(tmp_path) with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: - result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "-t", "Payment retry"]) + result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "--from-current", "-t", "Payment retry"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("Feature/Foo_Bar", "HEAD", "Payment retry", year=None) + mock_create.assert_called_once_with("Feature/Foo_Bar", "HEAD", "Payment retry", False, None, None) assert result.output == "line\n" def test_topics_create_todo_long_form_binds_the_same_value(self) -> None: """--todo behaves exactly like -t.""" with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: - result = CliRunner().invoke(topics, ["create", "feat-a", "--todo", "T"]) + result = CliRunner().invoke(topics, ["create", "feat-a", "--base-ref", "origin/main", "--todo", "T"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("feat-a", "HEAD", "T", year=None) + mock_create.assert_called_once_with("feat-a", "origin/main", "T", False, None, None) assert result.output == "line\n" @pytest.mark.parametrize( @@ -395,50 +482,31 @@ def test_topics_create_todo_long_form_binds_the_same_value(self) -> None: def test_create_flag_with_value_passes_todo(self, flag_form: list[str]) -> None: """Every flag form carrying a value hands the domain the todo verbatim.""" with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: - result = CliRunner().invoke(topics, ["create", "feat-a", *flag_form]) + result = CliRunner().invoke(topics, ["create", "feat-a", "--base-ref", "origin/main", *flag_form]) assert result.exit_code == 0 - assert mock_create.call_args == mock.call("feat-a", "HEAD", "Payment retry", year=None) + assert mock_create.call_args == mock.call("feat-a", "origin/main", "Payment retry", False, None, None) - @pytest.mark.parametrize("flag_form", [["--todo="], ["-t", ""]]) - def test_create_explicit_empty_value_is_the_entry_marker(self, flag_form: list[str]) -> None: - """An explicitly empty value is indistinguishable from the bare flag — the entry runs.""" - with ( - mock.patch.object(_topics_module, "_prompt_multiline", return_value="entered") as mock_entry, - mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create, - ): - result = CliRunner().invoke(topics, ["create", "feat-a", *flag_form]) - assert result.exit_code == 0 - mock_entry.assert_called_once_with("todo") - assert mock_create.call_args.args[2] == "entered" - - def test_create_bare_flag_resolves_todo_through_entry(self) -> None: - """A bare -t resolves to the entry marker and its text reaches the domain.""" - with ( - mock.patch.object(_topics_module, "_prompt_multiline", return_value="line one\n\nline two") as mock_entry, - mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create, - ): - result = CliRunner().invoke(topics, ["create", "feat-a", "-t"]) - assert result.exit_code == 0 - mock_entry.assert_called_once_with("todo") - assert mock_create.call_args.args[2] == "line one\n\nline two" + def test_create_empty_todo_value_counts_as_absent( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An explicitly empty --todo value is None at the call — never an entry marker. - def test_create_bare_flag_entry_cancel_continues_without_todo(self) -> None: - """A cancelled entry continues as without the flag — the domain gets None.""" - with ( - mock.patch.object(_topics_module, "_prompt_multiline", return_value=None), - mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create, - ): - result = CliRunner().invoke(topics, ["create", "feat-a", "-t"]) + The CliRunner stdin is never a TTY, which is the point: without a + value option there is no CLI-side entry that could need one. + """ + monkeypatch.chdir(tmp_path) + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + result = CliRunner().invoke(topics, ["create", "feat-a", "--from-current", "--todo", ""]) assert result.exit_code == 0 - assert mock_create.call_args.args[2] is None + mock_create.assert_called_once_with("feat-a", "HEAD", None, False, None, None) - def test_create_bare_flag_non_interactive_clean_error(self) -> None: - """A bare -t without a TTY is a clean error before any delegation.""" + @pytest.mark.parametrize("flag_form", [["--todo"], ["-t"]]) + def test_create_bare_todo_flag_is_usage_error(self, flag_form: list[str]) -> None: + """A value-less --todo is click's own usage error — no optional-value flag reappears.""" with mock.patch.object(_topics_module, "create_topic") as mock_create: - result = CliRunner().invoke(topics, ["create", "feat-a", "-t"], input="irrelevant\n") - assert result.exit_code == 1 - assert "todo entry needs an interactive terminal" in result.stderr - assert "Traceback" not in result.stderr + result = CliRunner().invoke(topics, ["create", "feat-a", "--base-ref", "origin/main", *flag_form]) + assert result.exit_code == 2 + assert "requires an argument" in result.output mock_create.assert_not_called() def test_switch_echoes_the_domain_result_line(self) -> None: @@ -450,7 +518,7 @@ def test_switch_echoes_the_domain_result_line(self) -> None: ) as mock_switch: result = CliRunner().invoke(topics, ["switch", "feat-a"]) assert result.exit_code == 0 - mock_switch.assert_called_once_with("feat-a", year=None) + mock_switch.assert_called_once_with("feat-a", False, None) assert result.output.splitlines() == ["Switched to branch feat/a"] def test_switch_receives_the_scoped_year(self) -> None: @@ -458,43 +526,135 @@ def test_switch_receives_the_scoped_year(self) -> None: with mock.patch.object(_topics_module, "switch_topic", return_value="Already on branch feat/a") as mock_switch: result = CliRunner().invoke(topics, ["--year", "2025", "switch", "feat-a"]) assert result.exit_code == 0 - mock_switch.assert_called_once_with("feat-a", year="2025") + mock_switch.assert_called_once_with("feat-a", False, "2025") assert result.output.splitlines() == ["Already on branch feat/a"] - @pytest.mark.parametrize(("subcommand", "argument"), [("create", "branch_name"), ("switch", "identifier")]) - def test_missing_positional_is_usage_error(self, subcommand: str, argument: str) -> None: + @pytest.mark.parametrize( + ("argv", "argument"), + [ + (["create"], "branch_name"), + (["switch"], "identifier"), + (["delete"], "identifiers"), + ], + ) + def test_missing_positional_is_usage_error(self, argv: list[str], argument: str) -> None: """A missing positional is click's own usage error — exit 2, no domain call.""" with ( mock.patch.object(_topics_module, "create_topic") as mock_create, mock.patch.object(_topics_module, "switch_topic") as mock_switch, + mock.patch.object(_topics_module, "resolve_delete_targets") as mock_resolve, ): - result = CliRunner().invoke(topics, [subcommand]) + result = CliRunner().invoke(topics, argv) assert result.exit_code == 2 assert argument.upper() in result.output mock_create.assert_not_called() mock_switch.assert_not_called() + mock_resolve.assert_not_called() - @pytest.mark.parametrize(("subcommand", "routine"), [("create", "create_topic"), ("switch", "switch_topic")]) - def test_domain_error_surfaces_clean(self, subcommand: str, routine: str) -> None: + @pytest.mark.parametrize( + ("argv", "routine"), + [ + (["create", "x", "--base-ref", "origin/main"], "create_topic"), + (["switch", "x"], "switch_topic"), + ], + ) + def test_domain_error_surfaces_clean(self, argv: list[str], routine: str) -> None: """A domain ClickException propagates as stderr + exit 1, no traceback.""" with mock.patch.object(_topics_module, routine, side_effect=click.ClickException("working tree is dirty")): - result = CliRunner().invoke(topics, [subcommand, "x"]) + result = CliRunner().invoke(topics, argv) assert result.exit_code == 1 assert "working tree is dirty" in result.stderr assert "Traceback" not in result.stderr assert result.stdout == "" -def _write_config(tmp_path: Path, body: str) -> None: - """Write ``.goga/config.yml`` with the given body under tmp_path.""" - goga_dir = tmp_path / ".goga" - goga_dir.mkdir(exist_ok=True) - (goga_dir / "config.yml").write_text(body, encoding="utf-8") +class TestTopicsCreateBaseResolution: + def test_create_base_ref_flag_beats_config_beats_from_current( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The base matrix: --base-ref beats topics.base_ref beats --from-current.""" + monkeypatch.chdir(tmp_path) + _write_config( + tmp_path, + "language: python\ntopics:\n base_ref: origin/config-base\n publish_commit: cfg tpl\n", + ) + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + flag_base = CliRunner().invoke(topics, ["create", "n1", "--base-ref", "origin/flag-base"]) + config_base = CliRunner().invoke(topics, ["create", "n2"]) + assert flag_base.exit_code == 0 + assert config_base.exit_code == 0 + # n1: the base flag wins; the template still comes from the config. + assert mock_create.call_args_list[0] == mock.call("n1", "origin/flag-base", None, False, "cfg tpl", None) + assert mock_create.call_args_list[1] == mock.call("n2", "origin/config-base", None, False, "cfg tpl", None) + + # A config without topics.base_ref: --from-current yields the HEAD. + _write_config(tmp_path, "language: python\n") + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + from_current = CliRunner().invoke(topics, ["create", "n3", "--from-current"]) + assert from_current.exit_code == 0 + assert mock_create.call_args == mock.call("n3", "HEAD", None, False, None, None) + + # A --commit flag beats the config template (publication-only, so + # under --publish). + _write_config( + tmp_path, + "language: python\ntopics:\n base_ref: origin/config-base\n publish_commit: cfg tpl\n", + ) + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + flag_template = CliRunner().invoke( + topics, ["create", "n4", "--publish", "-t", "T", "--commit", "x {slug}"] + ) + assert flag_template.exit_code == 0 + assert mock_create.call_args == mock.call("n4", "origin/config-base", "T", True, "x {slug}", None) + + # A missing configuration file counts as unset — the lazy read + # tolerates it and --from-current still yields the HEAD. + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + monkeypatch.chdir(empty_dir) + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + missing = CliRunner().invoke(topics, ["create", "n5", "--from-current"]) + assert missing.exit_code == 0 + assert mock_create.call_args == mock.call("n5", "HEAD", None, False, None, None) + + def test_create_no_base_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Nothing set: the error names --base-ref, --from-current, and the config line.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object(_topics_module, "create_topic") as mock_create: + result = CliRunner().invoke(topics, ["create", "name"]) + assert result.exit_code == 1 + assert "--base-ref" in result.stderr + assert "--from-current" in result.stderr + assert "topics.base_ref" in result.stderr + assert "Traceback" not in result.stderr + mock_create.assert_not_called() + + def test_create_from_current_passes_head(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """--from-current passes the literal string HEAD — no CLI-side resolution.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + result = CliRunner().invoke(topics, ["create", "name", "--from-current"]) + assert result.exit_code == 0 + assert mock_create.call_args.args[1] == "HEAD" + + def test_create_commit_without_publish_error(self) -> None: + """--commit without --publish is a clean error; --base-ref alone is not.""" + with mock.patch.object(_topics_module, "create_topic") as mock_create: + result = CliRunner().invoke(topics, ["create", "--base-ref", "origin/main", "--commit", "x", "name"]) + assert result.exit_code == 1 + assert "--commit" in result.stderr + assert "publication-only" in result.stderr + mock_create.assert_not_called() + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + base_alone = CliRunner().invoke(topics, ["create", "--base-ref", "origin/main", "name"]) + assert base_alone.exit_code == 0 + mock_create.assert_called_once_with("name", "origin/main", None, False, None, None) -class TestTopicsCreatePublish: - def test_create_publish_flag_beats_config_section(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Both publication flags given: the flag values win and no config read happens.""" + def test_create_both_values_given_reads_no_configuration( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Both --base-ref and --commit given: the flag values win and no config read happens.""" monkeypatch.chdir(tmp_path) _write_config( tmp_path, @@ -502,12 +662,7 @@ def test_create_publish_flag_beats_config_section(self, tmp_path: Path, monkeypa ) with ( mock.patch.object(_topics_module, "load_project_config") as mock_load, - mock.patch.object(_topics_module, "create_topic") as mock_create, - mock.patch.object( - _topics_module, - "publish_topic", - return_value="Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar", - ) as mock_publish, + mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create, ): result = CliRunner().invoke( topics, @@ -515,7 +670,7 @@ def test_create_publish_flag_beats_config_section(self, tmp_path: Path, monkeypa "create", "Feature/Foo_Bar", "--publish", - "--todo", + "-t", "T", "--base-ref", "origin/flag-base", @@ -524,64 +679,33 @@ def test_create_publish_flag_beats_config_section(self, tmp_path: Path, monkeypa ], ) assert result.exit_code == 0 - mock_publish.assert_called_once_with("Feature/Foo_Bar", "T", "origin/flag-base", "flag: {slug}", None) + mock_create.assert_called_once_with("Feature/Foo_Bar", "origin/flag-base", "T", True, "flag: {slug}", None) mock_load.assert_not_called() - mock_create.assert_not_called() - assert result.output.splitlines() == ["Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar"] - - def test_create_publish_resolves_config_and_default(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """No flags: the base comes from the config, the template from the built-in default.""" - monkeypatch.chdir(tmp_path) - _write_config(tmp_path, "language: python\ntopics:\n base_ref: origin/config-base\n") - with mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish: - result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "--publish", "--todo", "T"]) - assert result.exit_code == 0 - mock_publish.assert_called_once_with( - "Feature/Foo_Bar", "T", "origin/config-base", "goga: create topic {slug}", None - ) - assert result.output == "line\n" - def test_create_publish_config_template_beats_default( + def test_create_publish_config_template_beats_domain_default( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """``topics.publish_commit`` wins over the built-in default template.""" + """``topics.publish_commit`` wins over the domain default template.""" monkeypatch.chdir(tmp_path) _write_config( tmp_path, "language: python\ntopics:\n base_ref: origin/config-base\n publish_commit: 'config: {slug}'\n", ) - with mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish: - result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "--publish", "--todo", "T"]) + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t", "T"]) assert result.exit_code == 0 - mock_publish.assert_called_once_with( - "Feature/Foo_Bar", "T", "origin/config-base", "config: {slug}", None - ) + mock_create.assert_called_once_with("X", "origin/config-base", "T", True, "config: {slug}", None) - def test_create_publish_flag_base_with_config_template( + def test_create_publish_no_template_anywhere_passes_none( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A flag base with a config template — each value resolves on its own row.""" + """No --commit and no topics.publish_commit: the template is None — the domain default.""" monkeypatch.chdir(tmp_path) - _write_config( - tmp_path, - "language: python\ntopics:\n base_ref: origin/config-base\n publish_commit: 'config: {slug}'\n", - ) - with ( - mock.patch.object( - _topics_module, "load_project_config", wraps=_topics_module.load_project_config - ) as mock_load, - mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish, - ): - result = CliRunner().invoke( - topics, - ["create", "Feature/Foo_Bar", "--publish", "--todo", "T", "--base-ref", "origin/flag-base"], - ) + _write_config(tmp_path, "language: python\ntopics:\n base_ref: origin/config-base\n") + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t", "T"]) assert result.exit_code == 0 - mock_publish.assert_called_once_with( - "Feature/Foo_Bar", "T", "origin/flag-base", "config: {slug}", None - ) - # The template flag is absent, so the config is read for it. - mock_load.assert_called_once_with() + mock_create.assert_called_once_with("X", "origin/config-base", "T", True, None, None) def test_create_publish_flag_template_with_config_base( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -593,107 +717,41 @@ def test_create_publish_flag_template_with_config_base( mock.patch.object( _topics_module, "load_project_config", wraps=_topics_module.load_project_config ) as mock_load, - mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish, + mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create, ): result = CliRunner().invoke( - topics, - ["create", "Feature/Foo_Bar", "--publish", "--todo", "T", "--commit", "flag: {slug}"], + topics, ["create", "X", "--publish", "-t", "T", "--commit", "flag: {slug}"] ) assert result.exit_code == 0 - mock_publish.assert_called_once_with( - "Feature/Foo_Bar", "T", "origin/config-base", "flag: {slug}", None - ) + mock_create.assert_called_once_with("X", "origin/config-base", "T", True, "flag: {slug}", None) # The base flag is absent, so the config is read for it. mock_load.assert_called_once_with() - @pytest.mark.parametrize("extra", [["--commit", "m"], ["--base-ref", "origin/main"]]) - def test_create_publication_flags_without_publish_are_clean_error(self, extra: list[str]) -> None: - """--base-ref or --commit without --publish is a clean error; no domain routine runs.""" - with ( - mock.patch.object(_topics_module, "load_project_config") as mock_load, - mock.patch.object(_topics_module, "create_topic") as mock_create, - mock.patch.object(_topics_module, "publish_topic") as mock_publish, - ): - result = CliRunner().invoke(topics, ["create", "X", *extra]) - assert result.exit_code == 1 - assert "--base-ref and --commit act only together with --publish" in result.stderr - mock_load.assert_not_called() - mock_create.assert_not_called() - mock_publish.assert_not_called() - - def test_create_publish_without_todo_is_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """--publish without a todo is a clean error asking for it; the domain is untouched.""" - monkeypatch.chdir(tmp_path) - with mock.patch.object(_topics_module, "publish_topic") as mock_publish: - result = CliRunner().invoke(topics, ["create", "X", "--publish"]) - assert result.exit_code == 1 - assert "--publish needs a todo" in result.stderr - assert "--todo/-t" in result.stderr - assert "todo.md" in result.stderr - mock_publish.assert_not_called() - - def test_create_publish_delegates_resolved_todo(self) -> None: - """The publish path hands publish_topic the resolved todo and the resolved template.""" - with mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish: + def test_create_publish_delegation(self) -> None: + """The publish path delegates through create_topic with publish=True.""" + with mock.patch.object( + _topics_module, + "create_topic", + return_value="Created branch X and published topic 2026/x", + ) as mock_create: result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t", "T", "--base-ref", "origin/main"]) assert result.exit_code == 0 - assert mock_publish.call_args == mock.call("X", "T", "origin/main", "goga: create topic {slug}", None) - assert result.output == "line\n" - - def test_create_publish_bare_flag_resolves_todo_through_entry(self) -> None: - """A bare -t under --publish resolves through the entry; the entered text is published.""" - with ( - mock.patch.object( - _topics_module, "_prompt_multiline", return_value="line one\n\nline two" - ) as mock_entry, - mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish, - ): - result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t", "--base-ref", "origin/main"]) - assert result.exit_code == 0 - mock_entry.assert_called_once_with("todo") - assert mock_publish.call_args == mock.call( - "X", "line one\n\nline two", "origin/main", "goga: create topic {slug}", None - ) - - def test_create_publish_bare_flag_entry_cancel_is_clean_error(self) -> None: - """A cancelled entry under --publish hits the publish gate — never a todo-less publish.""" - with ( - mock.patch.object(_topics_module, "_prompt_multiline", return_value=None), - mock.patch.object(_topics_module, "publish_topic") as mock_publish, - ): - result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t"]) - assert result.exit_code == 1 - assert "--publish needs a todo" in result.stderr - mock_publish.assert_not_called() - - def test_create_publish_without_base_names_config_and_flag( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Nothing set: the error names the configuration line, the flag, and a yaml example.""" - monkeypatch.chdir(tmp_path) - _write_config(tmp_path, "language: python\n") - with mock.patch.object(_topics_module, "publish_topic") as mock_publish: - result = CliRunner().invoke(topics, ["create", "X", "--publish", "--todo", "T"]) - assert result.exit_code == 1 - assert "topics.base_ref" in result.stderr - assert "--base-ref" in result.stderr - assert "topics:" in result.stderr - assert "base_ref: origin/main" in result.stderr - mock_publish.assert_not_called() + assert mock_create.call_args == mock.call("X", "origin/main", "T", True, None, None) + assert result.output == "Created branch X and published topic 2026/x\n" - def test_create_publish_invalid_config_surfaces_its_own_error( + def test_create_invalid_config_surfaces_its_own_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A malformed topics section surfaces the loader's error, not a 'no base' guess.""" monkeypatch.chdir(tmp_path) _write_config(tmp_path, "language: python\ntopics: 5\n") - with mock.patch.object(_topics_module, "publish_topic") as mock_publish: - result = CliRunner().invoke(topics, ["create", "X", "--publish", "--todo", "T"]) + with mock.patch.object(_topics_module, "create_topic") as mock_create: + result = CliRunner().invoke(topics, ["create", "X", "--from-current"]) assert result.exit_code == 1 assert "'topics' must be a mapping in .goga/config.yml" in result.stderr - mock_publish.assert_not_called() + mock_create.assert_not_called() - def test_create_publish_unreadable_config_surfaces_clean_error( + def test_create_unreadable_config_surfaces_clean_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """An unreadable configuration file (a directory in its place) @@ -703,100 +761,123 @@ def test_create_publish_unreadable_config_surfaces_clean_error( monkeypatch.chdir(tmp_path) (tmp_path / ".goga").mkdir() (tmp_path / ".goga" / "config.yml").mkdir() - with mock.patch.object(_topics_module, "publish_topic") as mock_publish: - result = CliRunner().invoke(topics, ["create", "X", "--publish", "--todo", "T"]) + with mock.patch.object(_topics_module, "create_topic") as mock_create: + result = CliRunner().invoke(topics, ["create", "X", "--from-current"]) assert result.exit_code == 1 assert "Is a directory" in result.stderr assert not isinstance(result.exception, IsADirectoryError) - mock_publish.assert_not_called() + mock_create.assert_not_called() - def test_create_default_path_never_reads_configuration( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Without --publish the configuration is never read — a config-less repository works.""" - monkeypatch.chdir(tmp_path) - with ( - mock.patch.object(_topics_module, "load_project_config") as mock_load, - mock.patch.object( - _topics_module, "create_topic", return_value="Created branch Feature/Foo_Bar and topic 2026/x" - ) as mock_create, - ): - result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar"]) - assert result.exit_code == 0 - mock_create.assert_called_once_with("Feature/Foo_Bar", "HEAD", None, year=None) - mock_load.assert_not_called() - def test_create_publish_missing_config_counts_as_unset( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """A missing configuration file counts as unset — the flag and the default act.""" - monkeypatch.chdir(tmp_path) - with mock.patch.object(_topics_module, "publish_topic", return_value="line") as mock_publish: - result = CliRunner().invoke( - topics, ["create", "X", "--publish", "--todo", "T", "--base-ref", "origin/main"] - ) +class TestTopicsSwitchTodo: + def test_switch_todo_flag_forwarded(self) -> None: + """--todo forwards the flag verbatim to switch_topic.""" + with mock.patch.object( + _topics_module, + "switch_topic", + return_value="Switched to branch feature-foo", + ) as mock_switch: + result = CliRunner().invoke(topics, ["switch", "feature-foo", "--todo"]) assert result.exit_code == 0 - mock_publish.assert_called_once_with("X", "T", "origin/main", "goga: create topic {slug}", None) + mock_switch.assert_called_once_with("feature-foo", True, None) + assert result.output.splitlines() == ["Switched to branch feature-foo"] + def test_switch_todo_with_scoped_year(self) -> None: + """--todo and --year travel together to the domain.""" + with mock.patch.object(_topics_module, "switch_topic", return_value="line") as mock_switch: + result = CliRunner().invoke(topics, ["--year", "2025", "switch", "feature-foo", "--todo"]) + assert result.exit_code == 0 + mock_switch.assert_called_once_with("feature-foo", True, "2025") -class TestMultilineEntry: - """The interactive entry cycle — driven by direct calls, never CliRunner. - - Under CliRunner ``sys.stdin`` is an isolated stream whose ``isatty()`` is - always False, so the cycle itself is exercised through the two seams of - the practice: a TTY-mocked stdin and a ``visible_prompt_func`` with a - ``side_effect`` list of lines — ``EOFError`` in the list models Ctrl+D, - a ``"."`` line the terminator. - """ - - def _tty(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Mock sys.stdin as an interactive terminal.""" - monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) - def test_entry_collects_paragraphs( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] - ) -> None: - """Entered lines join with single newlines — an empty line stays a text line.""" - self._tty(monkeypatch) - with mock.patch.object( - click.termui, "visible_prompt_func", side_effect=["line one", "", "line two", "."] +class TestTopicsDelete: + def test_delete_confirmed_delegates_and_echoes(self) -> None: + """A confirmed list prints the pairs, asks once, delegates with a list, echoes the line.""" + targets = [ + DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True), + DeleteTarget(topic="release-1-3-0", branch=None, remote=None, has_dir=True), + ] + with ( + mock.patch.object(click, "confirm", return_value=True) as mock_confirm, + mock.patch.object(_topics_module, "resolve_delete_targets", return_value=targets) as mock_resolve, + mock.patch.object( + _topics_module, + "delete_topics", + return_value="Deleted 2 topic(s) of 2026: feature-foo, release-1-3-0", + ) as mock_delete, ): - assert _topics_module._prompt_multiline("todo") == "line one\n\nline two" - # The rule is stated in the prompt itself — before the first input. - assert "Enter the todo. Finish with a lone '.' line or Ctrl+D." in capsys.readouterr().out - - def test_entry_eof_terminator_returns_collected_text(self, monkeypatch: pytest.MonkeyPatch) -> None: - """EOF (Ctrl+D) finishes the entry exactly like the dot terminator.""" - self._tty(monkeypatch) - with mock.patch.object(click.termui, "visible_prompt_func", side_effect=["a", EOFError()]): - assert _topics_module._prompt_multiline("todo") == "a" - - def test_entry_keyboard_interrupt_aborts(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Ctrl+C aborts the command — it is not a terminator.""" - self._tty(monkeypatch) + result = CliRunner().invoke(topics, ["delete", "feature-foo", "release-1-3-0"], input=_TtyStdin()) + assert result.exit_code == 0 + # The click nargs=-1 tuple becomes a list at the boundary. + mock_resolve.assert_called_once_with(["feature-foo", "release-1-3-0"], None) + # One confirmation for the whole list — never per topic. + mock_confirm.assert_called_once_with("Delete 2 topic(s)?") + mock_delete.assert_called_once_with(targets, None) + assert "feature-foo -> feature-foo" in result.output + assert "release-1-3-0 -> (directory only)" in result.output + assert "Deleted 2 topic(s) of 2026: feature-foo, release-1-3-0" in result.output + + def test_delete_declined_confirmation_exits_zero(self) -> None: + """A declined confirmation exits 0 with nothing deleted.""" + targets = [DeleteTarget(topic="feature-foo", branch="feature-foo", remote=None, has_dir=True)] with ( - mock.patch.object(click.termui, "visible_prompt_func", side_effect=["a", KeyboardInterrupt()]), - pytest.raises(click.Abort), + mock.patch.object(click, "confirm", return_value=False) as mock_confirm, + mock.patch.object(_topics_module, "resolve_delete_targets", return_value=targets) as mock_resolve, + mock.patch.object(_topics_module, "delete_topics") as mock_delete, ): - _topics_module._prompt_multiline("todo") - - @pytest.mark.parametrize("side_effect", [["."], ["", "."]]) - def test_entry_single_blank_line_cancels(self, side_effect: list[str], monkeypatch: pytest.MonkeyPatch) -> None: - """An empty assembly cancels the entry — None, never an empty text. + result = CliRunner().invoke(topics, ["delete", "feature-foo"], input=_TtyStdin()) + assert result.exit_code == 0 + mock_confirm.assert_called_once_with("Delete 1 topic(s)?") + mock_resolve.assert_called_once_with(["feature-foo"], None) + mock_delete.assert_not_called() + assert "Deleted" not in result.output + + def test_delete_requires_terminal_without_yes(self) -> None: + """A non-TTY without --yes is a clean error — after the read-only resolution.""" + targets = [DeleteTarget(topic="feature-foo", branch="feature-foo", remote=None, has_dir=True)] + with ( + mock.patch.object(click, "confirm") as mock_confirm, + mock.patch.object(_topics_module, "resolve_delete_targets", return_value=targets) as mock_resolve, + mock.patch.object(_topics_module, "delete_topics") as mock_delete, + ): + result = CliRunner().invoke(topics, ["delete", "feature-foo"]) + assert result.exit_code == 1 + assert "interactive terminal" in result.stderr + assert "Traceback" not in result.stderr + mock_resolve.assert_called_once_with(["feature-foo"], None) + mock_confirm.assert_not_called() + mock_delete.assert_not_called() - The emptiness check runs on the joined text, so a single blank line - cancels the entry the same way as entering nothing at all. - """ - self._tty(monkeypatch) - with mock.patch.object(click.termui, "visible_prompt_func", side_effect=side_effect): - assert _topics_module._prompt_multiline("todo") is None + def test_delete_yes_short_form_scoped_to_subcommand(self) -> None: + """``topics -y 2025 delete -y x``: the group -y binds the year, the subcommand -y the skip.""" + targets = [DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True)] + with ( + mock.patch.object(click, "confirm") as mock_confirm, + mock.patch.object(_topics_module, "resolve_delete_targets", return_value=targets) as mock_resolve, + mock.patch.object( + _topics_module, "delete_topics", return_value="Deleted 1 topic(s) of 2025: feature-foo" + ) as mock_delete, + ): + result = CliRunner().invoke(topics, ["-y", "2025", "delete", "-y", "feature-foo"]) + assert result.exit_code == 0 + mock_resolve.assert_called_once_with(["feature-foo"], "2025") + mock_confirm.assert_not_called() + mock_delete.assert_called_once_with(targets, "2025") + assert "Deleted 1 topic(s) of 2025: feature-foo" in result.output - def test_entry_non_interactive_terminal_is_clean_error(self, monkeypatch: pytest.MonkeyPatch) -> None: - """A stdin without a TTY is a clean error before the first prompt.""" - monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + def test_delete_resolution_error_surfaces_clean(self) -> None: + """A resolution error is clean and deletes nothing.""" with ( - mock.patch.object(click.termui, "visible_prompt_func") as mock_prompt, - pytest.raises(click.ClickException, match="todo entry needs an interactive terminal"), + mock.patch.object( + _topics_module, + "resolve_delete_targets", + side_effect=click.ClickException("no topic matches 'nope' — run 'goga topics board'"), + ) as mock_resolve, + mock.patch.object(_topics_module, "delete_topics") as mock_delete, ): - _topics_module._prompt_multiline("todo") - mock_prompt.assert_not_called() + result = CliRunner().invoke(topics, ["delete", "nope"]) + assert result.exit_code == 1 + assert "no topic matches" in result.stderr + assert "Traceback" not in result.stderr + mock_resolve.assert_called_once_with(["nope"], None) + mock_delete.assert_not_called() diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index fa4bb6a0..97b4ee12 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -657,6 +657,7 @@ def test_create_todo_then_board_info_shows_summary_and_todo_status( "2025", "create", "feat-new", + "--from-current", "--todo", "###\n# Pay retry cap\n\nRetries ignore the cap.", ], From 1be54a3e5ff68f1819230c154d3a868c79238553 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 21:36:25 +0000 Subject: [PATCH 190/229] =?UTF-8?q?feat:=20add=20--todo=20option=20to=20pi?= =?UTF-8?q?peline=20CLI=20=E2=80=94=20run-form=20guard,=20ensure=5Ftopic(t?= =?UTF-8?q?opic,=20todo)=20delegation,=20silent=20info-form=20skip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- goga/commands/pipeline/pipeline.py | 50 +++-- .../pipeline/test_pipeline_command.py | 179 ++++++++++++++++++ .../pipeline/test_pipeline_dispatch.py | 6 +- 3 files changed, 217 insertions(+), 18 deletions(-) diff --git a/goga/commands/pipeline/pipeline.py b/goga/commands/pipeline/pipeline.py index 1fbbbfa0..7ea98e81 100644 --- a/goga/commands/pipeline/pipeline.py +++ b/goga/commands/pipeline/pipeline.py @@ -39,6 +39,14 @@ "creating it when nothing hosts it (branch name, topic slug, or prefix; " "run form only)", ) +@click.option( + "--todo", + "todo", + is_flag=True, + default=False, + help="Open the editor with the topic's todo.md after the switch or fast creation " + "(run form only; requires --topic)", +) @click.option( "-e", "--env", @@ -110,6 +118,7 @@ def pipeline( # noqa: C901, PLR0912, PLR0913, PLR0917 list_requested: bool, info: bool, topic: str | None, + todo: bool, extra_env: tuple[str, ...], proxy: str | None, add_host: tuple[str, ...], @@ -133,7 +142,9 @@ def pipeline( # noqa: C901, PLR0912, PLR0913, PLR0917 With -t/--topic: bring the repository onto the requested work (a branch name, a topic slug, or their prefix) before the run — creating it when - nothing hosts the identifier. + nothing hosts the identifier. With --todo: open the editor with the + topic's todo.md after the switch or the fast creation (run form only; + requires --topic). All forms launch the goga Docker container and delegate there — the host never reads pipeline files directly. @@ -205,20 +216,29 @@ def pipeline( # noqa: C901, PLR0912, PLR0913, PLR0917 raise click.ClickException(f"workflow '{workflow}' not found at {workflow_path}") # Step 3 — topic procedure (run form only: `name` given, no --list, no - # --info, and -t/--topic given). Every git action happens here on the - # host, AFTER every step-2 form check and BEFORE any docker activity — a - # form error or a topic error never refreshes, builds, or launches an - # image. The flat list, overview, and card forms skip the procedure - # silently: passing -t there is not an error and has no effect. The single - # result line of `ensure_topic` (a switch, a fresh branch created from a - # remote-tracking ref, the already-on-host confirmation, or the creation - # of fresh work — branch plus topic directory — when nothing hosts the - # identifier) is echoed to stdout exactly once, immediately after the - # procedure and before the dispatch — and never forwarded into a launcher: - # the container sees the branch through the mounted project. - if topic is not None and name is not None and not list_requested and not info: - line = ensure_topic(topic) - click.echo(line) + # --info). Every git action happens here on the host, AFTER every step-2 + # form check and BEFORE any docker activity — a form error or a topic + # error never refreshes, builds, or launches an image. `--todo` without + # `-t/--topic` is a clean error here: the entry needs requested work, and + # the abort precedes any git or docker activity. The flat list, overview, + # and card forms skip the procedure silently: passing -t or --todo there + # is not an error and has no effect. The single result line of + # `ensure_topic` (a switch, a fresh branch created from a remote-tracking + # ref, the already-on-host confirmation, or the creation of fresh work — + # branch plus topic directory — when nothing hosts the identifier) is + # echoed to stdout exactly once, immediately after the procedure and + # before the dispatch — and never forwarded into a launcher: the container + # sees the branch through the mounted project. Under --todo the external + # editor opens with the topic's todo.md inside the domain orchestration — + # after the switch or the fast creation, and only on an interactive + # terminal (the domain aborts cleanly otherwise, still before any docker + # activity). + if name is not None and not list_requested and not info: + if todo and topic is None: + raise click.ClickException("--todo acts only together with --topic") + if topic is not None: + line = ensure_topic(topic, todo) + click.echo(line) # Step 4 — dispatch. The info forms receive hosts from the config ONLY: # --add-host is a run-form surface (an info container is read-only, so diff --git a/tests/commands/pipeline/test_pipeline_command.py b/tests/commands/pipeline/test_pipeline_command.py index d65ab4a9..51def009 100644 --- a/tests/commands/pipeline/test_pipeline_command.py +++ b/tests/commands/pipeline/test_pipeline_command.py @@ -19,6 +19,7 @@ from __future__ import annotations +import inspect import sys import typing from pathlib import Path @@ -130,6 +131,68 @@ def test_help_lists_list_and_info_flags(self) -> None: assert "--info" in result.output +# --- Contract tests: the --todo flag --- + + +class TestPipelineTodoOptionContract: + """The ``--todo`` option — a flag with no short form, bound as ``todo`` + directly after ``topic`` in the callback signature (contract order). + """ + + def test_pipeline_has_todo_option(self) -> None: + """The pipeline command registers a ``todo`` click Option (``--todo``).""" + param_names = [p.name for p in pipeline.params] + assert "todo" in param_names + + def test_pipeline_todo_option_is_flag_and_defaults_false(self) -> None: + """``--todo`` is a flag defaulting to False.""" + todo_param = next(p for p in pipeline.params if p.name == "todo") + assert isinstance(todo_param, click.Option) + assert todo_param.is_flag is True + assert todo_param.default is False + + def test_pipeline_todo_option_has_no_short_form(self) -> None: + """``--todo`` is long-form only — ``-t`` stays bound to ``--topic``.""" + todo_param = next(p for p in pipeline.params if p.name == "todo") + assert todo_param.opts == ["--todo"] + assert not todo_param.secondary_opts + + topic_param = next(p for p in pipeline.params if p.name == "topic") + assert set(topic_param.opts) == {"-t", "--topic"} + + def test_pipeline_todo_parses_as_flag(self) -> None: + """``--todo`` on the command line binds ``todo=True``.""" + _todo_parse_probe = {} + + @click.command() + @click.option("--topic", "topic", type=str, default=None) + @click.option("--todo", "todo", is_flag=True, default=False) + def _probe(topic: str | None, todo: bool) -> None: + _todo_parse_probe["topic"] = topic + _todo_parse_probe["todo"] = todo + + runner = CliRunner() + result = runner.invoke(_probe, ["--topic", "x", "--todo"]) + assert result.exit_code == 0 + assert _todo_parse_probe["topic"] == "x" + assert _todo_parse_probe["todo"] is True + + def test_pipeline_callback_declares_todo_right_after_topic(self) -> None: + """The callback signature carries ``todo: bool`` directly after ``topic``.""" + parameters = list(inspect.signature(pipeline.callback).parameters) + assert parameters.index("todo") == parameters.index("topic") + 1 + + hints = typing.get_type_hints(pipeline.callback) + assert hints["todo"] is bool + + def test_help_lists_todo_flag(self) -> None: + """``--help`` advertises ``--todo``.""" + runner = CliRunner() + result = runner.invoke(pipeline, ["--help"]) + assert result.exit_code == 0 + assert "--todo" in result.output + + # --- Contract obligation --- @@ -591,6 +654,122 @@ def test_pipeline_info_forms_ignore_run_flags(self, tmp_path: Path, monkeypatch: assert mock_info.call_args.kwargs["hosts"] == {} +# --- Logic tests: the --todo flag (the step-3 topic procedure) --- + + +class TestPipelineTodoFlag: + """The ``--todo`` surface — the D2 error gate, the verbatim forwarding, + the D7 silent-skip matrix, and the non-TTY abort ordering. + """ + + def test_pipeline_todo_without_topic_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``--todo`` without ``--topic`` in the run form is a clean error (fix D2). + + Step 2 passes (a name is given), step 3 errors: exit 1 with the exact + message naming ``--topic``, and neither launcher nor the domain + procedure is ever reached — the abort precedes any git or docker + activity. + """ + _write_config(tmp_path) + monkeypatch.chdir(tmp_path) + runner = CliRunner() + with ( + mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure, + mock.patch.object(_pipeline_module, "run_pipeline_container") as mock_run, + mock.patch.object(_pipeline_module, "run_pipeline_info_container") as mock_info, + ): + result = runner.invoke(pipeline, ["development", "--todo"]) + + assert result.exit_code == 1 + assert result.output == "Error: --todo acts only together with --topic\n" + mock_ensure.assert_not_called() + mock_run.assert_not_called() + mock_info.assert_not_called() + + def test_pipeline_todo_forwarded_to_ensure_topic( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``--todo`` is forwarded verbatim; the result line echoes once before the dispatch.""" + _write_config(tmp_path) + monkeypatch.chdir(tmp_path) + ensure_line = "Created branch history-com and topic 2026/history-com" + mock_ensure = mock.Mock(return_value=ensure_line) + mock_run = mock.Mock(return_value=3) + order = mock.Mock() + order.attach_mock(mock_ensure, "ensure_topic") + order.attach_mock(mock_run, "run_container") + runner = CliRunner() + with ( + mock.patch.object(_pipeline_module, "ensure_topic", mock_ensure), + mock.patch.object(_pipeline_module, "run_pipeline_container", mock_run), + ): + result = runner.invoke(pipeline, ["development", "--topic", "history-com", "--todo"]) + + assert result.exit_code == 3 + mock_ensure.assert_called_once_with("history-com", True) + # Exactly one topic line on stdout, and it precedes the docker dispatch. + assert result.stdout.count(ensure_line) == 1 + assert order.method_calls[0] == mock.call.ensure_topic("history-com", True) + assert order.method_calls[1][0] == "run_container" + + @pytest.mark.parametrize( + "argv", + [ + ["--list", "--todo"], + ["development", "--info", "--todo"], + ], + ids=["flat-list", "card"], + ) + def test_pipeline_todo_silently_ignored_in_info_forms( + self, argv: list[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The flat-list and card forms silently skip ``--todo`` (fix D7). + + No error, no topic line, no ``ensure_topic`` call — the flag names no + procedure outside the run form. (The card form ``NAME --info`` is an + info form, not a run form.) + """ + _write_config(tmp_path) + monkeypatch.chdir(tmp_path) + runner = CliRunner() + with ( + mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure, + mock.patch.object(_pipeline_module, "run_pipeline_info_container", return_value=0) as mock_info, + ): + result = runner.invoke(pipeline, argv) + + assert result.exit_code == 0 + assert result.output == "" + mock_ensure.assert_not_called() + mock_info.assert_called_once() + + def test_pipeline_todo_non_tty_aborts_before_docker( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``--todo`` on a non-terminal aborts inside the domain before docker. + + The REAL ``ensure_topic`` runs (unmocked): the CliRunner stdin is + never a TTY, so the domain's terminal check fires before any git + action and aborts the command — the entry failure never reaches + docker (no launcher marker touched). + """ + _write_config(tmp_path) + monkeypatch.chdir(tmp_path) + runner = CliRunner() + with ( + mock.patch.object(_pipeline_module, "run_pipeline_container") as mock_run, + mock.patch.object(_pipeline_module, "run_pipeline_info_container") as mock_info, + ): + result = runner.invoke(pipeline, ["development", "--topic", "x", "--todo"]) + + assert result.exit_code == 1 + assert "interactive terminal" in result.output + mock_run.assert_not_called() + mock_info.assert_not_called() + + # --- Facade contract: goga/commands/pipeline exports the full contract API --- # The five names declared in the cell CODEMANIFEST — the pipeline command, the diff --git a/tests/commands/pipeline/test_pipeline_dispatch.py b/tests/commands/pipeline/test_pipeline_dispatch.py index 9ecc9410..5e48ba89 100644 --- a/tests/commands/pipeline/test_pipeline_dispatch.py +++ b/tests/commands/pipeline/test_pipeline_dispatch.py @@ -158,7 +158,7 @@ def test_pipeline_topic_option_contract_both_forms_one_option(self) -> None: result = runner.invoke(pipeline, argv) assert result.exit_code == 0 - mock_ensure.assert_called_once_with("x") + mock_ensure.assert_called_once_with("x", False) # --- Logic tests (positive) --- @@ -331,12 +331,12 @@ def test_pipeline_topic_option_switches_before_docker(self) -> None: assert result.exit_code == 0 # Exactly one topic line on stdout, verbatim from ensure_topic. assert result.stdout.count(switch_line) == 1 - mock_ensure.assert_called_once_with("feat/x") + mock_ensure.assert_called_once_with("feat/x", False) mock_run.assert_called_once() assert mock_run.call_args.kwargs["name"] == "development" # The topic procedure precedes the docker activity, and the topic # identifier never crosses the docker boundary. - assert order.method_calls[0] == mock.call.ensure_topic("feat/x") + assert order.method_calls[0] == mock.call.ensure_topic("feat/x", False) assert order.method_calls[1][0] == "run_container" assert "topic" not in mock_run.call_args.kwargs assert "feat/x" not in mock_run.call_args.kwargs.values() From 2741871eb947bf3e42994d458f66d30a2fbac72c Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 22:11:24 +0000 Subject: [PATCH 191/229] fix: address code review findings --- README.md | 12 +- docs/cli/index.md | 2 +- docs/cli/pipeline.md | 12 +- docs/cli/topics.md | 105 +++++++---- docs/configuration/project.md | 12 +- goga/topics/creation.py | 38 +++- goga/topics/deletion.py | 15 +- goga/topics/ensuring.py | 14 +- goga/topics/switching.py | 24 +-- .../pipeline/test_pipeline_command.py | 30 +-- tests/integration/test_topic_workflows.py | 176 +++++++++++++++++- tests/topics/git/test_publish.py | 17 ++ tests/topics/test_creation.py | 119 ++++++++++++ tests/topics/test_deletion.py | 143 ++++++++++++++ tests/topics/test_ensuring.py | 34 ++++ 15 files changed, 655 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index d3b709eb..7b408864 100644 --- a/README.md +++ b/README.md @@ -144,21 +144,23 @@ Work is organized as **topics** — one directory per piece of work under `.goga goga topics board # the board: every topic of the year across branches goga topics board --remote # same board over remote-tracking refs goga topics board --info # the board with the todo column (the todo summary of todo.md) -goga topics create feat/x # fresh work: the branch verbatim + its topic directory +goga topics create feat/x --from-current # fresh work off the current HEAD: the branch verbatim + its topic directory goga topics create feat/x -t "Payment retry" # same, and writes todo.md (status: todo) -goga topics create feat/x -t # same, then an interactive multi-line todo entry +goga topics create feat/x -t # same, then the todo entry in your $EDITOR goga topics create feat/x -p -t "Payment retry" # same, committed + pushed to origin, no switch goga topics switch feat-x # onto the branch hosting that work (branch, slug, or prefix) -goga topics --year 2025 status # the board of an explicit year +goga topics switch feat-x --todo # same, then edit the topic's todo.md in your $EDITOR +goga topics delete feat-x # delete the branch, its origin twin, and the directory +goga topics --year 2025 board # the board of an explicit year ``` -`--publish`/`-p` is the fast mode: it builds the branch off an explicit base (`--base-ref`, or `topics.base_ref` in `.goga/config.yml`) with a single `todo.md` commit and pushes it to `origin` without switching — your working copy, index, and HEAD stay untouched, and a failed push rolls the branch back. See [`goga topics`](https://qarium.github.io/goga/cli/topics/). +Every `create` needs a base: `--base-ref`, or `topics.base_ref` in `.goga/config.yml`, or the current HEAD under `--from-current`. Without a `-t` value a terminal opens the external editor for the todo (empty or unchanged cancels), and on a terminal the command asks once whether to publish. `--publish`/`-p` is the fast mode: it builds the branch off the resolved base with a single `todo.md` commit and pushes it to `origin` without switching — your working copy, index, and HEAD stay untouched, and a failed push rolls the branch back. See [`goga topics`](https://qarium.github.io/goga/cli/topics/). The board is a three-column table — topic, branch, statuses, plus a todo column under `--info` — with `*` marking the current branch and a local branch absorbing its remote twin. Each topic carries its **maximal statuses** in scale order: `empty → todo → defined → discovered → backlog → designed → specified → planned → done`, deepening as `todo.md`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. A topic can carry several statuses at once (`goga history status` prints them; `-s` filters by any of them). Topics no branch hosts anymore are orphans — `goga history prune --dry-run` lists the orphans of a year, and `goga history prune [YEAR]` deletes them (irreversibly: the history tree is not in git). -To resume work inside a pipeline, pass the identifier to the run — `goga pipeline development -t feat/x` switches to the hosting branch first (creating a local branch from its remote-tracking ref when needed) and is an idempotent no-op when you are already on it. Fresh work is started with `goga topics create`, not `-t`. +To resume work inside a pipeline, pass the identifier to the run — `goga pipeline development -t feat/x` switches to the hosting branch first (creating a local branch from its remote-tracking ref when needed) and is an idempotent no-op when you are already on it; adding `--todo` opens the topic's `todo.md` in your editor after the switch. Fresh work is started with `goga topics create`, not `-t`. ## Pipelines diff --git a/docs/cli/index.md b/docs/cli/index.md index 3ad27939..96e3b5db 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -37,7 +37,7 @@ python -m goga --help | [`goga usages`](usages.md) | Sync cell-level usages from declared git dependencies and check their status against the remote | | [`goga pipeline`](pipeline.md) | Run a goga pipeline, or inspect the available ones (`--list`, `--info`) | | [`goga history`](history.md) | Work with the `.goga/history/` tree (`list`, `status`, `path`, `ensure`, `prune`) | -| [`goga topics`](topics.md) | Work with the topics of one year (`status` board, `create`, `switch`) | +| [`goga topics`](topics.md) | Work with the topics of one year (`board`, `create`, `switch`, `delete`) | | [`goga tool`](tool.md) | Dynamic tool package invocation | | [`goga hooks`](hooks.md) | Inspect the hooks registered by installed tool packages | diff --git a/docs/cli/pipeline.md b/docs/cli/pipeline.md index 71e7a9f6..ff39a888 100644 --- a/docs/cli/pipeline.md +++ b/docs/cli/pipeline.md @@ -12,6 +12,7 @@ goga pipeline --list --info # overview: one bullet block per pipeline with goga pipeline <name> --info # card: name, description, stages in execution order goga pipeline <name> # run: execute the pipeline (in-container) goga pipeline <name> -t <topic> # run: first switch onto the branch hosting the work (host-side) +goga pipeline <name> -t <topic> --todo # same, then open the topic's todo.md in the editor ``` ## Forms @@ -103,18 +104,20 @@ The identifier resolves through three tiers, and the first tier with a match win 2. **exact topic slug** — a branch hosting the topic `.goga/history/<YYYY>/<slug>/` whose slug equals the normalized input (lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed: `Feature/Foo_Bar` → `feature-foo-bar`); local branches come before remote-tracking refs; 3. **prefix** — a branch whose name, or whose hosted slug, starts with the input. -Within a tier, several candidates may match (a branch chain carries several topics). On an interactive terminal goga prints the numbered list with each candidate's statuses and prompts for a number; with no terminal (CI/scripts) the numbered list itself becomes a clean error and the command exits 1 — no image refresh, build, or launch happens. No candidate at all creates fresh work instead of failing: the branch is created with the name as entered, the repository switches to it, and the topic directory of the year is created from its slug (`Created branch <name> and topic <year>/<slug>`). An unusable name — one that normalizes to an empty slug, or one already occupied by an existing branch, a remote-tracking twin, or the topic directory of the year — re-asks on an interactive terminal and exits 1 with the reason otherwise. +Within a tier, several candidates may match (a branch chain carries several topics). On an interactive terminal goga prints the numbered list with each candidate's statuses and prompts for a number; with no terminal (CI/scripts) the numbered list itself becomes a clean error and the command exits 1 — no image refresh, build, or launch happens. No candidate at all creates fresh work instead of failing: the branch is created with the name as entered off the current HEAD, the repository switches to it, and the topic directory of the year is created from its slug (`Created branch <name> and topic <year>/<slug>`). An unusable name — one that normalizes to an empty slug, or one already occupied by an existing branch, a remote-tracking twin, or the topic directory of the year — is one clean error (exit 1) with the reason; there is no re-ask. The outcome: - already on the hosting branch → idempotent success, nothing is touched and the working tree is not even probed; - a local host → `git switch <branch>`; - a remote-only host → the local branch is created from the remote-tracking ref (`git switch -c <branch> <remote>/<branch>`); -- nothing hosts the identifier → the branch is created as entered and the topic directory of the year appears (uncommitted changes carry onto the fresh branch, exactly like `goga topics create`). +- nothing hosts the identifier → the branch is created as entered from the current HEAD and the topic directory of the year appears (uncommitted changes carry onto the fresh branch; `goga topics create` instead plants the branch at an explicit or configured base). A switch that would mutate checks the working tree first: a dirty tree exits 1 with `working tree is dirty — commit or stash before switching` before anything is touched. Every git action happens on the host, after every form check and before any docker activity. The single result line (`Switched to branch <name>`, `Created branch <name> from <remote>/<name>`, `Already on branch <name>`, or `Created branch <name> and topic <year>/<slug>`) is echoed to stdout once, before the launch. The branch name is never forwarded into the container — the container sees the branch through the mounted project, and goga does not switch back after the launch. -The flat list, overview, and card forms silently ignore `-t` — passing it there is not an error and has no effect. +With `--todo`, the topic procedure opens the external editor with the ensured work's `todo.md` after the switch or the fresh creation — saving overwrites the file (no commit), cancelling leaves it untouched, exactly like `goga topics switch --todo`. The flag needs an interactive terminal and acts only together with `--topic`: `--todo` without `--topic` in the run form is a clean error (`--todo acts only together with --topic`, exit 1) fired before any git or docker activity. + +The flat list, overview, and card forms silently ignore `-t` and `--todo` — passing them there is not an error and has no effect. ## Prerequisites @@ -189,6 +192,7 @@ stages: | `-l`, `--list` | flag | off | List available pipelines (flat list). Add `--info` for a one-line description per pipeline | | `-i`, `--info` | flag | off | With `--list`: print the overview. With `NAME`: print the pipeline card instead of running it | | `-t`, `--topic` | string | — | Bring the repository onto the requested work before the run — a branch name, a topic slug, or their prefix, created fresh when nothing hosts it; see [Topic switch](#topic-switch). Run form only — the list/info forms silently ignore it | +| `--todo` | flag | off | Open the editor with the ensured work's `todo.md` after the switch or the fresh creation (run form only; requires `--topic` and an interactive terminal; the info forms silently ignore it; `--todo` without `--topic` is a clean error) | | `-e`, `--env` | string (repeatable) | — | Additional environment variable (`KEY=VALUE`) forwarded into the container env-file. Run form only | | `--proxy` | string | config | HTTP/HTTPS proxy URL; overrides `pipeline.proxy`. Adds `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` to the container env-file. Run form only | | `--add-host` | string (repeatable) | -- | Add a `docker run --add-host HOST:IP` entry; merges on top of `pipeline.hosts` (CLI wins on key conflict). Run form only — the info forms receive the configured `pipeline.hosts` only | @@ -277,7 +281,7 @@ Host side (all forms): | Code | Meaning | |------|---------| | `0` | The operation completed (container exit 0) | -| `1` | A `ClickException`: a form error (bare invocation, `--list` + name, `--workflow` + `--no-workflow`), the `pipeline` section missing in `.goga/config.yml`, an explicit `--workflow <name>` naming a file that does not exist or escaping the workflows dir, a topic-procedure failure (several candidates without a terminal, a dirty working tree on a switch, an unusable — empty-slug or occupied — name without a terminal, a failed `git switch` or ref listing, or a missing git binary — see [Topic switch](#topic-switch)), or a fatal image build/refresh. Or the pre-launch version check refusing the launch (a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version — a stderr message plus `SystemExit`, see [Pre-launch version check](#pre-launch-version-check)) | +| `1` | A `ClickException`: a form error (bare invocation, `--list` + name, `--workflow` + `--no-workflow`, `--todo` without `--topic` in the run form), the `pipeline` section missing in `.goga/config.yml`, an explicit `--workflow <name>` naming a file that does not exist or escaping the workflows dir, a topic-procedure failure (several candidates without a terminal, a dirty working tree on a switch, an unusable — empty-slug or occupied — name, `--todo` without a terminal, a failed `git switch` or ref listing, or a missing git binary — see [Topic switch](#topic-switch)), or a fatal image build/refresh. Or the pre-launch version check refusing the launch (a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version — a stderr message plus `SystemExit`, see [Pre-launch version check](#pre-launch-version-check)) | | other| The container's exit code, propagated unchanged (including the run-mode codes below) | Container side, run form: diff --git a/docs/cli/topics.md b/docs/cli/topics.md index 59710b9b..09199596 100644 --- a/docs/cli/topics.md +++ b/docs/cli/topics.md @@ -1,15 +1,16 @@ # goga topics -Work with the topics of one year — the cross-branch inventory, fresh-work creation, and switching. +Work with the topics of one year — the cross-branch inventory, fresh-work creation, switching, and deletion. -`goga topics` is a Click group with three subcommands (`status`, `create`, `switch`) over the topics domain. It is host-side and git-driven: the board reads branch trees without checkout, and creation and switching perform bounded local git mutations. `create --publish` is the one exception on the network: it pushes the branch to `origin` (the only network operation of the group — no fetch ever happens); every other mutation is local. +`goga topics` is a Click group with four subcommands (`board`, `create`, `switch`, `delete`) over the topics domain. It is host-side and git-driven: the board and the deletion resolution read branch trees without checkout, and creation and switching perform bounded local git mutations. Two subcommands touch the network, each exactly once: `create --publish` pushes the new branch to `origin`, and `delete` pushes the branch deletion to `origin` (no fetch ever happens); every other mutation is local. ## Synopsis ```bash goga topics [--year YYYY] board [--remote] [--info] -goga topics [--year YYYY] create BRANCH_NAME [--todo [TEXT]] [--publish] [--base-ref REF] [--commit TEMPLATE] -goga topics [--year YYYY] switch IDENTIFIER +goga topics [--year YYYY] create BRANCH_NAME [--todo TEXT] [--publish] [--base-ref REF] [--from-current] [--commit TEMPLATE] +goga topics [--year YYYY] switch IDENTIFIER [--todo] +goga topics [--year YYYY] delete IDENTIFIER... [--yes] ``` `--year`/`-y` scopes every subcommand to one four-digit year (default: the current year). The year is never printed. @@ -39,46 +40,47 @@ The statuses are the topic's **maximal present statuses** in scale order — `em ## `goga topics create` -Creates fresh work — a branch named exactly as entered, plus the topic directory of the scoped year: +Creates fresh work — a branch named exactly as entered, planted at a base commit and checked out, plus the topic directory of the scoped year: ```bash -goga topics create Feature/Foo_Bar +goga topics create Feature/Foo_Bar --from-current # Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar -goga topics create Feature/Foo_Bar --todo "Payment retry" +goga topics create Feature/Foo_Bar --base-ref origin/main --todo "Payment retry" # Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar # (.goga/history/2026/feature-foo-bar/todo.md now carries "Payment retry") ``` -- The branch name is taken verbatim (`git switch -c`); git itself rejects invalid names. -- The topic directory is `.goga/history/<YYYY>/<slug>/`, where the slug is the normalized name (lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed: `Feature/Foo_Bar` → `feature-foo-bar`). No artifact file is written unless a non-empty `--todo` is given. -- `-t`/`--todo` writes the topic todo file `todo.md` in the topic directory — the multi-line text as entered plus one trailing newline, UTF-8 — which marks the topic `todo` on the status scale and feeds the `--info` column of the board. An empty todo — `--todo ""`, `--todo=`, `-t ""` — is not a written value: it starts the interactive entry like the bare flag, and no todo.md is ever created empty. -- The current branch already hosting the same slug is an idempotent success — `Branch <name> already hosts topic <YYYY>/<slug>` — with nothing touched, except that an explicit non-empty `--todo` creates or overwrites the todo file. -- Occupancy is probed against three oracles in order: a local branch with the entered name, a remote-tracking branch with the entered name (local refs only — no network), and an existing `.goga/history/<YYYY>/<slug>/` directory (only a directory occupies a topic). -- An occupied name or a name that normalizes to an empty slug (a fully non-ASCII name) prints the reason and prompts for a new name on an interactive terminal, restarting with it; with no terminal it exits 1 with the reason (and a hint to `goga topics board` for occupied names). Ctrl-C at the prompt aborts with nothing created. +- The branch name is taken verbatim; git itself rejects invalid names. The branch is planted at the resolved base commit (`git update-ref --stdin`), then checked out (`git switch`) — a failed checkout rolls the planted branch back so the name never strands. +- The base resolves as `--base-ref` > `topics.base_ref` in `.goga/config.yml` > the current HEAD under `--from-current` > clean error. With nothing set, exit 1 with a message naming the flag, the flag alternative, and the configuration line, including a two-line YAML example (see [Project Configuration](../configuration/project.md#topics)). +- The topic directory is `.goga/history/<YYYY>/<slug>/`, where the slug is the normalized name (lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed: `Feature/Foo_Bar` → `feature-foo-bar`). No artifact file is written unless a todo resolves. +- `-t`/`--todo` takes the todo value on the command line — the multi-line text as entered plus one trailing newline, UTF-8 — which marks the topic `todo` on the status scale and feeds the `--info` column of the board. An empty value — `--todo ""`, `--todo=`, `-t ""` — counts as absent: no `todo.md` is ever created empty. +- The current branch already hosting the same slug is a clean error (exit 1) — `branch <name> already hosts topic <YYYY>/<slug> — switch to it instead of re-creating it`. There is no idempotent path. +- Occupancy is probed against three oracles in order: a local branch with the entered name, a remote-tracking branch with the entered name (local refs only — no network), and an existing `.goga/history/<YYYY>/<slug>/` directory (only a directory occupies a topic). A fourth oracle applies to every creation: any branch tree of the inventory — local and remote-tracking refs — hosting the topic directory of the slug (`topic '<slug>' of <YYYY> is already hosted by branch '<branch>'`). +- An occupied name, an unresolvable base, or a name that normalizes to an empty slug (a fully non-ASCII name) is one clean error (exit 1) with the reason and a hint to `goga topics board` for occupied names — there is no re-ask. Every read-only decision (the preflight) runs before the first input, so a failing base never wastes an entered todo. -### Interactive todo entry +### Editor todo entry -`--todo` given without a value (a bare `-t`) starts an interactive multi-line entry instead of taking the text from the command line: +`--todo` given without a value opens the external editor instead of taking the text from the command line: ``` -$ goga topics create feat/x -t -Enter the todo. Finish with a lone '.' line or Ctrl+D. -Fix payment retries. - -Retries ignore the cap. -. +$ goga topics create feat/x --todo +Enter the text. An empty or unchanged file cancels the entry. +# (the editor opens; saving writes todo.md, cancelling leaves nothing) # Created branch feat/x and topic 2026/feat-x ``` -- One line per input; every entered line continues the text, and an empty line stays in it as a paragraph separator. -- A lone `.` line or Ctrl+D (EOF) finishes the entry; Ctrl+C aborts the command — it is not a terminator. -- Entering nothing at all cancels the entry — the command continues as without the flag, and no `todo.md` is written. -- A terminal without a TTY is a clean error before any mutation: `todo entry needs an interactive terminal` (exit 1). +- The editor resolves through `$VISUAL` → `$EDITOR` → the system default (`vi`); the session edits a temporary file outside the project. +- Saving a blank file — or a file unchanged from its prefill — cancels the entry: the command continues as without the flag, and no `todo.md` is written. A failed editor run is a clean error with nothing mutated. +- Without an interactive terminal the value-less `--todo` is a clean error before any mutation: `the todo needs a value — pass --todo/-t or run the creation on an interactive terminal` (exit 1). + +### The publication ask + +On an interactive terminal, without `--publish`, once a todo is resolved, the command asks once: `Publish the branch to origin?`. An empty answer reads the default no — the normal local path runs; answering yes takes the publication path below; Ctrl-C or EOF aborts with nothing created. Without a terminal, or with a cancelled todo entry, no ask happens and the normal path runs. ### `--publish` — create and publish in one step -`-p`/`--publish` builds the branch off an explicit base, commits only the topic's `todo.md` on it, and pushes it to `origin` — while you stay on your branch: +`-p`/`--publish` takes the publication path without the ask: it builds the branch off the resolved base, commits only the topic's `todo.md` on it, and pushes it to `origin` — while you stay on your branch: ```bash goga topics create Feature/Foo_Bar --publish --todo "Payment retry" @@ -87,12 +89,8 @@ goga topics create Feature/Foo_Bar --publish --todo "Payment retry" - The working copy, the index, and HEAD stay untouched — the commit is built through quarantined git plumbing, so a dirty tree and a detached HEAD do not interfere; the topic directory is never created on disk. - The branch carries exactly one commit — the todo file at `.goga/history/<YYYY>/<slug>/todo.md` — and is pushed to `origin` with upstream binding (`git push -u`, exactly that one branch). The topic appears on the remote board with the `todo` status. -- `-t`/`--todo` is **required** under `--publish` (the board reads the topic through the todo file). The bare flag — or an explicitly empty value — resolves through the interactive entry first; without a TTY that entry is a clean error. A missing or cancelled todo exits 1 with `--publish needs a todo — pass --todo/-t; the board reads the topic through todo.md`, and an empty todo reaching the domain is a clean error before any mutation: `the fast path needs a non-empty todo — pass the text or enter it interactively`. -- Base resolution: `--base-ref` > `topics.base_ref` in `.goga/config.yml` > error. With nothing set, exit 1 with a message naming both the configuration line and the flag, including a two-line YAML example (see [Project Configuration](../configuration/project.md#topics)). -- Commit template: `--commit`/`-c` > `topics.publish_commit` > the built-in default `goga: create topic {slug}`. `{slug}` is replaced with the topic slug; a template without the placeholder is used verbatim. -- `--base-ref` or `--commit` without `--publish` is a clean error (exit 1) — they act only together with `--publish`. -- Occupancy under `--publish` adds a fourth oracle on top of the three above: any branch tree of the inventory — local and remote-tracking refs — hosting the topic directory of the slug. The conflict reads `topic '<slug>' of <YYYY> is already hosted by branch '<branch>'`, with the same re-ask/exit-1 behavior as the other oracles. -- The current branch already hosting the slug is a clean error (exit 1) — the fast path is only for fresh work; use the default `create` for the idempotent case. +- A todo is **required** under `--publish` (the board reads the topic through the todo file). A value-less `--todo` resolves through the editor entry first; a missing or cancelled todo exits 1 with `the publication needs a todo — the board reads the topic through todo.md`. +- Commit template: `--commit`/`-c` > `topics.publish_commit` > the built-in default `goga: create topic {slug}`. `{slug}` is replaced with the topic slug; a template without the placeholder is used verbatim. `--commit` without `--publish` is a clean error (exit 1) — it acts only together with `--publish`. - `origin` must be configured (exit 1 otherwise, before any mutation). The repository git identity must be set — `commit-tree` needs an author. - A failed push rolls back fully: the planted branch is deleted, nothing else was ever mutated, and git's push reason surfaces as one clean error (`git failed: <git stderr>`, exit 1). A re-run with the same name then succeeds. @@ -103,6 +101,10 @@ Brings the repository onto the branch hosting the requested work: ```bash goga topics switch feat-x # Switched to branch feat/x + +goga topics switch feat-x --todo +# Switched to branch feat/x +# (the editor opens with the topic's todo.md; saving overwrites it, no commit) ``` IDENTIFIER resolves through three tiers — the first tier with a match wins, so a unique identifier never reaches a prompt: @@ -117,17 +119,48 @@ IDENTIFIER resolves through three tiers — the first tier with a match wins, so - A local host is checked out (`git switch <branch>`); a remote-only host creates the local branch from the remote-tracking ref (`git switch -c <branch> <remote>/<branch>`, reported as `Created branch <branch> from <remote>/<branch>`). - A switch that would mutate first probes the working tree; a dirty tree exits 1 with `working tree is dirty — commit or stash before switching` before anything is touched. -The same resolution backs the switch half of `goga pipeline <name> -t <identifier>` — there, an identifier nothing hosts creates fresh work instead of failing (see [pipeline](pipeline.md#topic-switch)). +### `--todo` — enter the topic's todo after the switch + +With `--todo` the external editor opens with the switched topic's `todo.md` after the switch — the same editor session as the create entry, prefilled with the existing todo. Saving overwrites the file as entered plus one trailing newline, without a commit; cancelling (no save, blank, or unchanged) leaves it untouched. + +- The flag needs an interactive terminal — without one it is a clean error before any resolution (exit 1). +- The chosen candidate must host a topic — `branch '<name>' hosts no topic — switching creates nothing` (exit 1); switching never creates anything. +- Already sitting on the hosting branch still enters the todo — the idempotent switch carries the entry. + +The same resolution backs the switch half of `goga pipeline <name> -t <identifier>` — there, an identifier nothing hosts creates fresh work instead of failing, and a sibling `--todo` flag opens the same entry (see [pipeline](pipeline.md#topic-switch)). + +## `goga topics delete` + +Deletes identified topics — the local branch, its origin twin, and the topic directory: + +```bash +goga topics delete feature-foo +# feature-foo -> Feature/Foo_Bar +# Delete 1 topic(s)? y +# Deleted 1 topic(s) of 2026: feature-foo + +goga topics delete feature-foo feature-bar --yes +# Deleted 2 topic(s) of 2026: feature-foo, feature-bar +``` + +Every IDENTIFIER resolves first — a branch name, a topic slug, or their prefix (the same tier order as `switch`), plus topic directories of the year no branch hosts: + +- An identifier nothing hosts, an ambiguous identifier, merged work, or the current branch hosting a target is a clean error (exit 1) and nothing is deleted — the resolution is all-or-nothing. +- A local branch and its `origin` twin collapse into one target; repeated identifiers naming one topic collapse too. A tracking ref of another remote is not a twin — the deletion push targets `origin` only. +- Merged work is out of scope: a topic hosted by a branch that is not its own topic branch (the post-merge state) is a clean error naming the hosting branch — `topic '<topic>' is hosted by <branches> as merged work — remove it from the hosting branch's tree instead of deleting`. A topic directory no branch hosts stays deletable (directory only). +- The resolved list prints one line per target — `<topic> -> <branch>` (or the twin, or `(directory only)`) — and one confirmation covers the whole list; a declined answer exits 0 with nothing deleted. `--yes`/`-y` skips the confirmation; without it a non-interactive terminal is a clean error naming the flag. +- The removal deletes each topic's local branch, its `origin` twin (a network push), and its topic directory. The current branch hosting a target — by branch name or by slug — is a clean error asking to switch away first. +- A rejected remote deletion restores the failing target's local branch at its captured commit and surfaces git's reason as one clean error; targets removed before the failure stay removed. ## Exit Codes | Code | Meaning | |------|---------| -| `0` | Success — the board printed, the work created, or the switch performed (including the idempotent outcomes) | -| `1` | A clean domain error: an unresolvable or ambiguous identifier, an occupied name without a terminal, a dirty working tree, a failed publication (`--publish`), a git infrastructure failure, or a broken `goga_tool_*` package failing to import during status-scale assembly | +| `0` | Success — the board printed, the work created or published, the switch performed, the deletion done (including the idempotent switch and a declined deletion) | +| `1` | A clean domain error: an unresolvable or ambiguous identifier, no base for a creation, an occupied name, a missing todo under `--publish`, a dirty working tree, merged work or the current branch hosting a deletion target, a failed publication or remote deletion, a git infrastructure failure, or a broken `goga_tool_*` package failing to import during status-scale assembly | | `2` | A usage error (unknown option, missing argument) | ## Notes -- Every mutation is local except the `--publish` push — no fetch ever happens, and `create --publish` is the only subcommand that pushes. +- Every mutation is local except the two `origin` pushes — the `--publish` push and the delete push; no fetch ever happens. - `goga history status` shows the same statuses scoped to the working copy of one year (see [history](history.md)). diff --git a/docs/configuration/project.md b/docs/configuration/project.md index 9d7410c6..2e1af1b9 100644 --- a/docs/configuration/project.md +++ b/docs/configuration/project.md @@ -78,9 +78,9 @@ codemanifest: # - .venv/ # - build/dist -# topics: optional — fast topic publication (`goga topics create --publish`) +# topics: optional — topic creation base and publication template (`goga topics create`) # topics: -# base_ref: origin/main # base of the published topic branches +# base_ref: origin/main # base of the created topic branches # publish_commit: "goga: create topic {slug}" # commit message template ({slug} optional) ``` @@ -100,7 +100,7 @@ codemanifest: | `tools` | mapping | No | goga-tool version declarations consumed by `goga install` in bulk mode. Keys are tool names (without the `goga-tool-` prefix); values are version-form strings. Values are stored verbatim — the four-form grammar (`1.0.x`, `1.x`, `1.0.1`, `latest`) is validated by `goga install`, not the loader. Defaults to `None` (absent); an empty mapping is `{}`. YAML-null values (`viewer:`) are rejected | | `usages` | mapping | No | Git dependencies whose cell-level `.usages/` files are synced into `.goga/usages/<group>/<dep>/` by [`goga usages sync`](../cli/usages.md) and checked for drift against the remote by [`goga usages status`](../cli/usages.md). Two-level mapping: `<group>` → `<dep>` → `{ git, ref, root }`. Defaults to `None` (absent), which makes `goga usages sync` a no-op (exit 0); an empty mapping is `{}`. `<group>` and `<dep>` keys are validated as filesystem path segments — empty, `.` / `..`, or any name containing `/` or `\` raise `ValueError` | | `lint` | mapping | No | Optional linter section consumed by [`goga lint`](../cli/lint.md). Currently holds `ignore`, a list of directory relative paths to prune from lint traversal. Defaults to `None` (absent); an empty mapping is equivalent to no ignore list. Structural type errors (non-mapping `lint`, non-list `lint.ignore`, or a non-string element) raise `ValueError` | -| `topics` | mapping | No | Fast topic publication section consumed by [`goga topics create --publish`](../cli/topics.md#--publish--create-and-publish-in-one-step). Defaults to `None` (absent); a present-but-empty mapping is a `TopicsConfig` with both fields `None`. A non-mapping value raises `ValueError` | +| `topics` | mapping | No | Topic creation base and publication template section consumed by [`goga topics create`](../cli/topics.md). Defaults to `None` (absent); a present-but-empty mapping is a `TopicsConfig` with both fields `None`. A non-mapping value raises `ValueError` | ### build @@ -186,12 +186,12 @@ When `lint` is absent, `config.lint` is `None` and `goga lint` lints every direc ### topics -Optional section consumed by [`goga topics create --publish`](../cli/topics.md#--publish--create-and-publish-in-one-step). Read on the publish path only — a run without `--publish` never touches it. +Optional section consumed by [`goga topics create`](../cli/topics.md). Read lazily — only when a value no CLI flag provided has to come from it. | Field | Type | Required | Description | |-------|------|----------|-------------| -| `topics.base_ref` | `string` | No | Base revision of a published topic branch — any revision string (branch, remote-tracking ref, tag, hash), stored verbatim with no resolvability check. Absent/YAML-null/empty/whitespace resolves to `None`; a non-string raises `ValueError`. Overridden by the `--base-ref` CLI option; when neither is set, `create --publish` exits 1 | -| `topics.publish_commit` | `string` | No | Commit message template of the published todo commit; the optional `{slug}` placeholder is replaced with the topic slug, and a template without it is used verbatim. Same normalization and typing rules as `base_ref`. Overridden by the `--commit`/`-c` CLI option; the built-in default is `goga: create topic {slug}` | +| `topics.base_ref` | `string` | No | Base revision of a created topic branch — any revision string (branch, remote-tracking ref, tag, hash), stored verbatim with no resolvability check. Absent/YAML-null/empty/whitespace resolves to `None`; a non-string raises `ValueError`. Overridden by the `--base-ref` CLI option; the base resolves as `--base-ref` > `topics.base_ref` > the current HEAD under `--from-current` — a creation with none of the three exits 1 | +| `topics.publish_commit` | `string` | No | Commit message template of the published todo commit; the optional `{slug}` placeholder is replaced with the topic slug, and a template without it is used verbatim. Same normalization and typing rules as `base_ref`. Overridden by the `--commit`/`-c` CLI option (publication-only); the built-in default is `goga: create topic {slug}` | When `topics` is absent, `config.topics` is `None` ("everything unset"). Unknown keys inside the mapping are ignored — the same stance as `lint` and `codemanifest`. diff --git a/goga/topics/creation.py b/goga/topics/creation.py index 23667aa4..e76c0f77 100644 --- a/goga/topics/creation.py +++ b/goga/topics/creation.py @@ -24,6 +24,7 @@ from __future__ import annotations +import contextlib import subprocess import sys @@ -42,6 +43,7 @@ from .git import ( checkout_local_branch, create_branch_at_commit, + delete_local_branch, list_branch_refs, read_ref_tree_paths, resolve_ref_commit, @@ -190,9 +192,11 @@ def create_topic( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared signat otherwise 5. The normal path — ``create_branch_at_commit`` plants the branch at the base commit, ``checkout_local_branch`` switches - to it, ``ensure_topic_dir`` creates the topic directory of the - year, and a resolved todo writes the todo file ``todo.md`` — - the write is the last action of the path + to it (a failed checkout rolls the plant back — the + ``publish_topic`` precedent), ``ensure_topic_dir`` creates the + topic directory of the year, and a resolved todo writes the + todo file ``todo.md`` — the write is the last action of the + path 6. The publication path — the fast cycle of ``publishing`` via a call-time import; the cycle re-runs its own preflight — the delegation is deliberately whole @@ -280,7 +284,10 @@ def enter_topic_todo(topic: str, year: str | None = None) -> bool: try: return _enter_topic_todo(topic, year) except OSError as exc: - raise click.ClickException(f"cannot write the todo file: {exc}") from exc + # The boundary covers the prefill read and the saved write alike. + raise click.ClickException( + f"cannot read or write the todo file: {exc}" + ) from exc def _occupancy_conflict( @@ -392,7 +399,19 @@ def _create_topic( # noqa: PLR0913, PLR0917 — the unwrapped mirror of the dec if not _publication_asked(publish, resolved_todo): create_branch_at_commit(branch_name, base_commit) - checkout_local_branch(branch_name) + try: + checkout_local_branch(branch_name) + except (subprocess.CalledProcessError, OSError): + # A failed checkout would strand the planted branch: the + # occupancy oracle blocks the retry ("already exists") and the + # deletion flow cannot remove it (a bare branch hosts no + # topic), so only a raw ``git branch -D`` recovers. Roll the + # plant back — the ``publish_topic`` precedent; a failure of + # the rollback itself is suppressed so the checkout reason + # surfaces. + with contextlib.suppress(subprocess.CalledProcessError, OSError): + delete_local_branch(branch_name) + raise ensure_topic_dir(branch_name, year) if resolved_todo is not None: _write_todo(branch_name, resolved_year, resolved_todo) @@ -468,7 +487,14 @@ def _enter_topic_todo(topic: str, year: str | None) -> bool: resolved_year = year or current_year() path = resolve_topic_file(topic, "todo.md", resolved_year) - initial = path.read_text(encoding="utf-8") if path.exists() else None + # The read is display data — the editor prefill — so a byte outside + # UTF-8 (a hand edit saved in another encoding) decodes with the + # replacement character: a ``UnicodeDecodeError`` is a ``ValueError``, + # it matches none of the module's handlers and would pierce the + # clean-error boundary — mirroring ``_run_git`` of the git cell. + initial = ( + path.read_text(encoding="utf-8", errors="replace") if path.exists() else None + ) saved = edit_text(initial) diff --git a/goga/topics/deletion.py b/goga/topics/deletion.py index f535d397..6b308b1a 100644 --- a/goga/topics/deletion.py +++ b/goga/topics/deletion.py @@ -383,7 +383,20 @@ def _assemble_target( ) branch = next((ref.name for ref in eligible if not ref.remote), None) - remote = next((_short_name(ref.name) for ref in eligible if ref.remote), None) + # The twin is the *origin* twin — the one remote the deletion push of + # the git cell addresses. A tracking ref of another remote stays an + # eligible host (never merged work), but it contributes no deletable + # twin: its short name would otherwise be pushed at origin — a wrong + # remote's branch deleted or a phantom "remote ref does not exist" + # after the local branch is already gone. + remote = next( + ( + _short_name(ref.name) + for ref in eligible + if ref.remote and ref.name.partition("/")[0] == "origin" + ), + None, + ) return DeleteTarget(topic=topic, branch=branch, remote=remote, has_dir=topic in disk) diff --git a/goga/topics/ensuring.py b/goga/topics/ensuring.py index 3a42aaf8..2d363fe8 100644 --- a/goga/topics/ensuring.py +++ b/goga/topics/ensuring.py @@ -93,8 +93,9 @@ def ensure_topic(identifier: str, todo: bool = False, year: str | None = None) - unusable (empty-slug) or occupied name of the fast creation, several candidates without an interactive terminal, a dirty working tree on a switch mutation, a git infrastructure failure - (its stderr when git reports one, or a missing git binary), or - the fatal ``ImportError`` of the scale assembly. + (its stderr when git reports one, or a missing git binary), an + OS failure of the topic-directory creation or the todo write, + or the fatal ``ImportError`` of the scale assembly. click.Abort: Ctrl-C or EOF at a selection prompt — the repository is left untouched. """ @@ -107,6 +108,15 @@ def ensure_topic(identifier: str, todo: bool = False, year: str | None = None) - raise click.ClickException(f"git is not available: {exc}") from exc except ImportError as exc: raise click.ClickException(str(exc)) from exc + except OSError as exc: + # ``ensure_topic_dir`` propagates the mkdir failures — the same + # boundary ``create_topic`` keeps for its directory creation and + # todo write, so the pipeline-driven path pierces no further than + # the CLI one (``FileNotFoundError``, an ``OSError`` subclass, is + # handled above as the missing git binary). + raise click.ClickException( + f"cannot create the topic directory or write the todo file: {exc}" + ) from exc def _ensure_topic(identifier: str, todo: bool, year: str | None) -> str: diff --git a/goga/topics/switching.py b/goga/topics/switching.py index a06c15d0..d7cbf0f3 100644 --- a/goga/topics/switching.py +++ b/goga/topics/switching.py @@ -6,8 +6,7 @@ board, and the orchestrator that brings the repository onto the chosen host branch by purely switching — with the todo flag it enters the todo of the switched topic through the entry of ``creation.py`` after the -switch. The shared switch tail also serves the -ensure orchestration of ``ensuring.py``. Topic identity and statuses belong to the +switch. Topic identity and statuses belong to the history facade; the bounded git mutations belong to the nested git cell. Git infrastructure failures and the fatal scale-assembly ``ImportError`` surface as ``click.ClickException`` — the clean-error boundary of the @@ -360,25 +359,6 @@ def _switch_topic(identifier: str, todo: bool, year: str | None) -> str: return line -def _switch_to_candidate(candidates: list[SwitchCandidate]) -> str: - """Take the resolved candidates onto the working copy — the shared switch - tail of the ensure orchestration of ``ensuring.py`` (until its own - rework): the candidate choice followed by the mutation tail. - - Args: - candidates: The non-empty candidate list of the resolution. - - Returns: - The single result line of the outcome. - - Raises: - click.ClickException: several candidates without a terminal, or a - dirty working tree when a mutation is needed. - click.Abort: Ctrl-C or EOF at the selection prompt. - """ - return _apply_candidate(_take_candidate(candidates)) - - def _take_candidate(candidates: list[SwitchCandidate]) -> SwitchCandidate: """Narrow the resolved candidates to the chosen one. @@ -394,7 +374,7 @@ def _take_candidate(candidates: list[SwitchCandidate]) -> SwitchCandidate: def _apply_candidate(chosen: SwitchCandidate) -> str: """Bring the working copy onto the chosen candidate — the mutation tail - shared by ``switch_topic`` and ``_switch_to_candidate``. + of ``switch_topic``. Args: chosen: The chosen candidate of the resolution. diff --git a/tests/commands/pipeline/test_pipeline_command.py b/tests/commands/pipeline/test_pipeline_command.py index 51def009..572af868 100644 --- a/tests/commands/pipeline/test_pipeline_command.py +++ b/tests/commands/pipeline/test_pipeline_command.py @@ -160,22 +160,24 @@ def test_pipeline_todo_option_has_no_short_form(self) -> None: topic_param = next(p for p in pipeline.params if p.name == "topic") assert set(topic_param.opts) == {"-t", "--topic"} - def test_pipeline_todo_parses_as_flag(self) -> None: - """``--todo`` on the command line binds ``todo=True``.""" - _todo_parse_probe = {} - - @click.command() - @click.option("--topic", "topic", type=str, default=None) - @click.option("--todo", "todo", is_flag=True, default=False) - def _probe(topic: str | None, todo: bool) -> None: - _todo_parse_probe["topic"] = topic - _todo_parse_probe["todo"] = todo + def test_pipeline_todo_rejects_a_value( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``--todo`` binds as a flag on the real command — a value is a usage error. + A synthetic probe command cannot fail from a regression in the + pipeline registration, so the parse surface is pinned against the + real command: a flag fed a value exits 2 (click's usage error) + before any dispatch runs. + """ + _write_config(tmp_path) + monkeypatch.chdir(tmp_path) runner = CliRunner() - result = runner.invoke(_probe, ["--topic", "x", "--todo"]) - assert result.exit_code == 0 - assert _todo_parse_probe["topic"] == "x" - assert _todo_parse_probe["todo"] is True + with mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure: + result = runner.invoke(pipeline, ["development", "--topic", "x", "--todo=text"]) + + assert result.exit_code == 2 + mock_ensure.assert_not_called() def test_pipeline_callback_declares_todo_right_after_topic(self) -> None: """The callback signature carries ``todo: bool`` directly after ``topic``.""" diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index 97b4ee12..b3dc5558 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -29,6 +29,14 @@ pushed to a real bare ``origin``, and the failed-push scenario breaks the push URL to prove the full rollback of the planted branch. + resolve_delete_targets/delete_topics — the identified-topic deletion + over the real git cell: the tier resolution reads real + ``for-each-ref`` display names, the twin collapse assembles the local + branch and its origin twin, the merged-work and current-branch guards + block the dangerous states, and the removal deletes from a real bare + ``origin`` — the failed-remote scenario breaks the push URL to prove + the local branch is restored at the captured commit. + Git is real: the git-dependent scenarios run in a throwaway repository under ``tmp_path`` (``git init`` plus commits, with ``git update-ref`` manufacturing the remote-tracking twin) and skip when no git binary is @@ -59,7 +67,14 @@ from goga.commands.history import history from goga.commands.topics import topics from goga.history import assemble_status_scale, current_year -from goga.topics import create_topic, publish_topic, switch_topic +from goga.topics import ( + DeleteTarget, + create_topic, + delete_topics, + publish_topic, + resolve_delete_targets, + switch_topic, +) from goga.topics import switching as topics_switching # The scenarios drive real git — skip them where no git binary exists. @@ -1153,3 +1168,162 @@ def cycle() -> None: ) == "Payment retry" ) + + +def _init_delete_repo(root: Path) -> Path: + """Build the throwaway repository the deletion scenarios share. + + The ``_init_publish_repo`` base (``main`` over a bare ``origin``), + plus a ``Feature/Foo_Bar`` branch hosting the ``feature-foo-bar`` + topic of the current year committed and pushed — the origin twin — + the checkout back onto ``main``, and the topic directory re-created + on disk untracked: the full three-part target of the deletion flow + (local branch, origin twin, directory). + + Args: + root: The empty directory the repository is built in. + + Returns: + The path of the bare origin repository. + """ + origin = _init_publish_repo(root) + year = current_year() + _git(root, "switch", "-q", "-c", "Feature/Foo_Bar") + _write(root, f".goga/history/{year}/feature-foo-bar/todo.md") + _git(root, "add", ".goga") + _git(root, *_GIT_IDENTITY, "commit", "-qm", "topic feature-foo-bar") + _git(root, "push", "-q", "origin", "Feature/Foo_Bar") + _git(root, "switch", "-q", "main") + _write(root, f".goga/history/{year}/feature-foo-bar/todo.md") + return origin + + +@requires_git +class TestDeleteTopicsRealGit: + """``resolve_delete_targets``/``delete_topics`` over the real git cell. + + No domain routine and no git routine is mocked: the scenarios drive + the whole resolution → removal chain against a throwaway repository + with a real bare ``origin`` — the tier reading over real + ``for-each-ref`` display names, the twin collapse, the merged-work + and current-branch guards, and the capture-before-delete / + restore-on-failure dance of a rejected remote deletion. + """ + + def test_delete_end_to_end_removes_branch_twin_and_directory( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The full three-part deletion: local branch, origin twin, directory.""" + origin = _init_delete_repo(tmp_path) + monkeypatch.chdir(tmp_path) + year = current_year() + + targets = resolve_delete_targets(["feature-foo-bar"], year=year) + + assert targets == [ + DeleteTarget( + topic="feature-foo-bar", + branch="Feature/Foo_Bar", + remote="Feature/Foo_Bar", + has_dir=True, + ) + ] + + line = delete_topics(targets, year=year) + + assert line == f"Deleted 1 topic(s) of {year}: feature-foo-bar" + assert _git_out(tmp_path, "for-each-ref", "--format=%(refname)", "refs/heads") == "refs/heads/main" + # The delete push also drops the local remote-tracking twin. + assert ( + _git_out(tmp_path, "for-each-ref", "--format=%(refname)", "refs/remotes/origin") + == "refs/remotes/origin/main" + ) + assert not (tmp_path / ".goga" / "history" / year / "feature-foo-bar").exists() + # The bare origin truly lost the branch. + assert _git_out(origin, "for-each-ref", "--format=%(refname)", "refs/heads") == "refs/heads/main" + + def test_delete_failed_remote_restores_local_at_the_captured_commit( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A rejected remote deletion restores the local branch exactly — + the twin and the directory untouched, the push reason surfaced.""" + _init_delete_repo(tmp_path) + _git(tmp_path, "remote", "set-url", "--push", "origin", "../does-not-exist.git") + monkeypatch.chdir(tmp_path) + year = current_year() + commit = _git_out(tmp_path, "rev-parse", "refs/heads/Feature/Foo_Bar") + targets = resolve_delete_targets(["feature-foo-bar"], year=year) + + with pytest.raises(click.ClickException, match="git failed:"): + delete_topics(targets, year=year) + + assert _git_out(tmp_path, "rev-parse", "refs/heads/Feature/Foo_Bar") == commit + assert _git_out(tmp_path, "rev-parse", "--verify", "refs/remotes/origin/Feature/Foo_Bar") + assert (tmp_path / ".goga" / "history" / year / "feature-foo-bar").exists() + + def test_delete_merged_work_is_an_error_over_real_refs( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A topic merged into ``main`` with its own branch gone is merged + work — the integration branch is never the deletion target.""" + _init_publish_repo(tmp_path) + year = current_year() + _git(tmp_path, "switch", "-q", "-c", "feature-x") + _write(tmp_path, f".goga/history/{year}/feature-x/prd.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "topic feature-x") + _git(tmp_path, "switch", "-q", "main") + _git(tmp_path, "merge", "-q", "--ff-only", "feature-x") + _git(tmp_path, "branch", "-q", "-D", "feature-x") + heads_before = _git_out(tmp_path, "for-each-ref", "--format=%(refname)", "refs/heads") + monkeypatch.chdir(tmp_path) + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-x"], year=year) + + assert "feature-x" in raised.value.message + assert "main" in raised.value.message + assert _git_out(tmp_path, "for-each-ref", "--format=%(refname)", "refs/heads") == heads_before + + def test_delete_current_branch_hosting_target_is_an_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Sitting on the hosting branch blocks the deletion — switch away.""" + _init_publish_repo(tmp_path) + year = current_year() + _git(tmp_path, "switch", "-q", "-c", "Feature/Foo_Bar") + _write(tmp_path, f".goga/history/{year}/feature-foo-bar/todo.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "topic feature-foo-bar") + monkeypatch.chdir(tmp_path) + + with pytest.raises(click.ClickException, match="switch away"): + resolve_delete_targets(["feature-foo-bar"], year=year) + + assert _git_out(tmp_path, "rev-parse", "--verify", "refs/heads/Feature/Foo_Bar") + + def test_delete_cli_round_trip_with_yes( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The CLI surface round-trips: resolution, skipped confirmation, + removal, one result line — and without ``--yes`` a non-terminal + is a clean error before anything is deleted.""" + _init_delete_repo(tmp_path) + monkeypatch.chdir(tmp_path) + year = current_year() + + declined = CliRunner().invoke(topics, ["--year", year, "delete", "feature-foo-bar"]) + + assert declined.exit_code == 1 + assert "interactive terminal" in declined.output + assert _git_out(tmp_path, "rev-parse", "--verify", "refs/heads/Feature/Foo_Bar") + + result = CliRunner().invoke( + topics, ["--year", year, "delete", "feature-foo-bar", "--yes"] + ) + + assert result.exit_code == 0 + assert result.output == f"Deleted 1 topic(s) of {year}: feature-foo-bar\n" + assert "refs/heads/Feature/Foo_Bar" not in _git_out( + tmp_path, "for-each-ref", "--format=%(refname)", "refs/heads" + ) diff --git a/tests/topics/git/test_publish.py b/tests/topics/git/test_publish.py index b4bf5d3e..adfb8cb3 100644 --- a/tests/topics/git/test_publish.py +++ b/tests/topics/git/test_publish.py @@ -384,6 +384,23 @@ def test_delete_remote_branch_pushes_full_refspec(self) -> None: "refs/heads/feature-foo", ] + def test_delete_remote_branch_refspec_cannot_be_parsed_as_an_option(self) -> None: + """A dash-leading branch name stays a refspec — never a push option. + + Git accepts ``refs/heads/--mirror`` and the plant creates names + verbatim, so a bare-name argv would hand git ``push origin + --delete --mirror``: after ``--delete`` the bare ``--mirror`` no + longer names a branch, and ``--repo`` or ``--all`` would act at + all. The ``refs/heads/...`` refspec can never start with a dash. + """ + run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): + delete_remote_branch("--mirror") + + refspec = run.call_args.args[0][-1] + assert refspec == "refs/heads/--mirror" + assert not refspec.startswith("-") + def test_delete_remote_branch_git_failure_propagates_raw(self) -> None: """A rejected deletion push raises raw — the cell never wraps. diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index f46c50a7..6c74d3bf 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -534,6 +534,51 @@ def test_create_topic_publication_ask_empty_answer_is_no( assert result == "Created branch feature-foo and topic 2026/feature-foo" wired.create_branch.assert_called_once_with("feature-foo", "c0ffee") + def test_create_topic_failed_checkout_rolls_back_the_plant( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A failed checkout deletes the planted branch — no stranded name. + + The pre-rework ``git switch -c`` was atomic; the plant-then-checkout + split is not. A stranded plant would block the retry (the occupancy + oracle answers "already exists") and the deletion flow cannot + remove it (a bare branch hosts no topic) — the rollback mirrors + ``publish_topic``'s push-failure tail. + """ + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch) + monkeypatch.setattr(creation, "delete_local_branch", wired.delete_branch) + wired.checkout.side_effect = subprocess.CalledProcessError( + 1, + ["git", "switch", "feature-foo"], + stderr=b"error: Your local changes would be overwritten by checkout", + ) + + with pytest.raises(click.ClickException, match="overwritten by checkout"): + create_topic("feature-foo", "origin/main", todo="Fix.", year="2026") + + wired.delete_branch.assert_called_once_with("feature-foo") + + def test_create_topic_rollback_failure_still_surfaces_checkout_reason( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A broken rollback is suppressed — the checkout reason surfaces.""" + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch) + monkeypatch.setattr(creation, "delete_local_branch", wired.delete_branch) + wired.checkout.side_effect = subprocess.CalledProcessError( + 1, ["git", "switch", "feature-foo"], stderr=b"checkout refused" + ) + wired.delete_branch.side_effect = subprocess.CalledProcessError( + 1, ["git", "update-ref", "-d"], stderr=b"ref lock" + ) + + with pytest.raises(click.ClickException) as raised: + create_topic("feature-foo", "origin/main", todo="Fix.", year="2026") + + assert "checkout refused" in raised.value.message + assert "ref lock" not in raised.value.message + def test_create_topic_editor_todo_on_tty( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -641,6 +686,34 @@ def test_create_topic_publish_without_todo_error( wired.create_branch.assert_not_called() wired.checkout.assert_not_called() + def test_create_topic_publish_with_editor_todo_delegates_without_ask( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``publish=True`` delegates the editor-resolved todo — never the ask. + + The fast cycle must receive the resolved text (the editor's + read-back, trailing newline and all), not the absent value option, + and the confirm never fires: ``--publish`` is ask-free by contract. + """ + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch) + _editor_script(monkeypatch, tmp_path, "printf 'From editor.\\n' > \"$1\"") + _tty(monkeypatch) + confirm = mock.Mock() + monkeypatch.setattr(click, "confirm", confirm) + published = mock.Mock(return_value="published line") + monkeypatch.setattr(publishing, "publish_topic", published) + + result = create_topic("feature-foo", "origin/main", publish=True, year="2026") + + assert result == "published line" + published.assert_called_once_with( + "feature-foo", "From editor.\n", "origin/main", None, "2026" + ) + confirm.assert_not_called() + wired.create_branch.assert_not_called() + wired.checkout.assert_not_called() + def test_create_topic_current_branch_same_slug_is_conflict( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -861,6 +934,52 @@ def test_enter_topic_todo_missing_file_empty_entry( todo_file = tmp_path / ".goga" / "history" / "2026" / "feature-foo" / "todo.md" assert todo_file.read_text(encoding="utf-8") == "First.\n" + def test_enter_topic_todo_non_utf8_prefill_decodes_replacement( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A non-UTF-8 todo.md prefills with replacement bytes, not a traceback. + + The prefill is display data — a ``UnicodeDecodeError`` is a + ``ValueError`` that matches none of the module's handlers, so the + strict read would pierce the clean-error boundary of + ``switch --todo`` and ``pipeline --todo``. The editor script only + writes when the prefill carried the readable part, pinning that + the file was actually read. + """ + monkeypatch.chdir(tmp_path) + todo_file = _topic_dir(tmp_path, "2026", "feature-foo") / "todo.md" + todo_file.write_bytes("Old line.\n\xff\n".encode("latin-1")) + _editor_script( + monkeypatch, + tmp_path, + "grep -q 'Old line.' \"$1\" && printf 'New line.\\n' > \"$1\"", + ) + _tty(monkeypatch) + + assert enter_topic_todo("feature-foo", year="2026") is True + assert todo_file.read_text(encoding="utf-8") == "New line.\n" + + def test_enter_topic_todo_read_failure_is_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A failing prefill read is a clean error — before the editor. + + A directory named ``todo.md`` makes ``read_text`` raise + ``IsADirectoryError``; the boundary must fold it instead of + letting a raw traceback reach the CLI. + """ + monkeypatch.chdir(tmp_path) + (_topic_dir(tmp_path, "2026", "feature-foo") / "todo.md").mkdir() + marker = tmp_path / "editor-launched" + _editor_script(monkeypatch, tmp_path, f"touch '{marker}'") + _tty(monkeypatch) + + with pytest.raises(click.ClickException) as raised: + enter_topic_todo("feature-foo", year="2026") + + assert "cannot read or write the todo file" in raised.value.message + assert not marker.exists() + # --- Infrastructure boundary --- diff --git a/tests/topics/test_deletion.py b/tests/topics/test_deletion.py index 70fadcb1..d4f215b7 100644 --- a/tests/topics/test_deletion.py +++ b/tests/topics/test_deletion.py @@ -344,6 +344,117 @@ def test_resolve_delete_targets_no_match_names_identifier( assert "nope" in raised.value.message + def test_resolve_delete_targets_prefix_tier_matches_branch_and_slug( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A prefix identifier resolves through the third tier — one target. + + Both prefix arms agree here: the branch name starts with the raw + identifier and the hosted slug starts with its normalization. + """ + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="feature-foo", remote=False)] + trees = {"feature-foo": [".goga/history/2026/feature-foo/plan.md"]} + _wire_resolution(monkeypatch, inventory, trees, "main") + + targets = resolve_delete_targets(["feat"], year="2026") + + assert targets == [ + DeleteTarget(topic="feature-foo", branch="feature-foo", remote=None, has_dir=False) + ] + + def test_resolve_delete_targets_prefix_of_remote_short_name_matches( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The prefix tier also reads a remote-tracking ref by its short name. + + The display name starts with ``origin/``, so only the short-name + prefix arm can match a bare identifier prefix. + """ + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="origin/feature-foo", remote=True)] + trees = {"origin/feature-foo": [".goga/history/2026/feature-foo/plan.md"]} + _wire_resolution(monkeypatch, inventory, trees, "main") + + targets = resolve_delete_targets(["feature-fo"], year="2026") + + assert targets == [ + DeleteTarget(topic="feature-foo", branch=None, remote="feature-foo", has_dir=False) + ] + + def test_resolve_delete_targets_non_ascii_identifier_matches_nothing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A non-ASCII identifier normalizes to the empty slug — every slug + starts with it, so the slug-prefix arms must stay disabled.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="feature-foo", remote=False)] + trees = {"feature-foo": [".goga/history/2026/feature-foo/plan.md"]} + _wire_resolution(monkeypatch, inventory, trees, "main") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["Тема"], year="2026") + + assert "Тема" in raised.value.message + + def test_resolve_delete_targets_non_origin_remote_is_not_the_twin( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A tracking ref of another remote contributes no deletable twin. + + The deletion push of the git cell is origin-only, so a non-origin + remote's short name must never reach ``remote`` — it would be + pushed at origin (a wrong remote's branch deleted, or a phantom + "remote ref does not exist" after the local branch is gone). The + ref stays an eligible host — the topic is not merged work. + """ + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "feature-x") + inventory = [BranchRef(name="upstream/feature-x", remote=True)] + trees = {"upstream/feature-x": [".goga/history/2026/feature-x/plan.md"]} + _wire_resolution(monkeypatch, inventory, trees, "main") + + targets = resolve_delete_targets(["feature-x"], year="2026") + + assert targets == [DeleteTarget(topic="feature-x", branch=None, remote=None, has_dir=True)] + + def test_resolve_delete_targets_prefers_the_origin_twin_among_remotes( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """With twins on several remotes, the origin twin is the deletable one.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="origin/feature-x", remote=True), + BranchRef(name="upstream/feature-x", remote=True), + ] + trees = { + "origin/feature-x": [".goga/history/2026/feature-x/plan.md"], + "upstream/feature-x": [".goga/history/2026/feature-x/plan.md"], + } + _wire_resolution(monkeypatch, inventory, trees, "main") + + targets = resolve_delete_targets(["feature-x"], year="2026") + + assert targets == [ + DeleteTarget(topic="feature-x", branch=None, remote="feature-x", has_dir=False) + ] + + def test_resolve_delete_targets_current_branch_guard_slug_arm( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The guard also fires when the current branch's slug names the topic. + + A disk-only target carries ``branch=None``, so the branch-equality + arm alone would let ``goga topics delete feature-foo`` remove the + directory while the user sits on ``Feature_Foo``. + """ + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "feature-foo") + _wire_resolution(monkeypatch, [], {}, "Feature_Foo") + + with pytest.raises(click.ClickException, match="switch"): + resolve_delete_targets(["feature-foo"], year="2026") + # --- Infrastructure boundary --- @@ -467,3 +578,35 @@ def test_delete_topics_restore_failure_surfaces_original_reason( assert "remote error" in raised.value.message assert "ref lock" not in raised.value.message + + def test_delete_topics_targets_before_a_failure_stay_removed( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A multi-target deletion is not all-or-nothing across targets. + + The first target is fully removed before the second's remote + deletion fails; the failing target restores its local branch at + the captured commit and never reaches its directory. + """ + monkeypatch.chdir(tmp_path) + first = DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True) + second = DeleteTarget(topic="feature-bar", branch="feature-bar", remote="feature-bar", has_dir=True) + wired = _wire_removal(monkeypatch) + wired.remote.side_effect = [ + None, + subprocess.CalledProcessError(128, "git push", stderr=b"deny second"), + ] + + with pytest.raises(click.ClickException, match="deny second"): + delete_topics([first, second], year="2026") + + assert wired.order.mock_calls == [ + mock.call.capture("feature-foo"), + mock.call.local("feature-foo"), + mock.call.remote("feature-foo"), + mock.call.directory("feature-foo", "2026"), + mock.call.capture("feature-bar"), + mock.call.local("feature-bar"), + mock.call.remote("feature-bar"), + mock.call.restore("feature-bar", "c123"), + ] diff --git a/tests/topics/test_ensuring.py b/tests/topics/test_ensuring.py index 4e7ac5a8..60f0e685 100644 --- a/tests/topics/test_ensuring.py +++ b/tests/topics/test_ensuring.py @@ -319,6 +319,40 @@ def test_ensure_topic_empty_slug_identifier_error( create_and_switch.assert_not_called() ensure_dir.assert_not_called() + def test_ensure_topic_stray_file_at_topic_path_surfaces_as_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A stray file named like the slug folds into the same boundary + ``create_topic`` keeps — the pipeline-driven path pierces no + further than the CLI one. + + The history oracle counts directories only, so the name is free + and the branch mutation runs first; ``ensure_topic_dir`` then + fails on the file, and the wrapper turns the ``OSError`` into a + clean error instead of a traceback. + """ + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + year_dir.mkdir(parents=True) + (year_dir / "feat-x").write_text("not a topic", encoding="utf-8") + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + create_and_switch, _ensure_dir = _wire_fast_creation(monkeypatch, real_dir=True) + + with pytest.raises(click.ClickException) as raised: + ensure_topic("feat-x", year="2026") + + assert ( + "cannot create the topic directory or write the todo file" + in raised.value.message + ) + assert "feat-x" in raised.value.message + create_and_switch.assert_called_once_with("feat-x") + # --- Logic tests: the delegated switch at non-empty candidates --- From 1d271d49246b5fc9666375e86094f75672ea86ad Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 22:29:06 +0000 Subject: [PATCH 192/229] fix: address code review findings --- README.md | 4 ++-- docs/cli/topics.md | 10 +++++----- goga/commands/topics/topics.py | 11 ++++++----- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7b408864..6557d263 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ goga topics board --remote # same board over remote-tracking refs goga topics board --info # the board with the todo column (the todo summary of todo.md) goga topics create feat/x --from-current # fresh work off the current HEAD: the branch verbatim + its topic directory goga topics create feat/x -t "Payment retry" # same, and writes todo.md (status: todo) -goga topics create feat/x -t # same, then the todo entry in your $EDITOR +goga topics create feat/x # on a terminal, same and the todo entry opens in your $EDITOR goga topics create feat/x -p -t "Payment retry" # same, committed + pushed to origin, no switch goga topics switch feat-x # onto the branch hosting that work (branch, slug, or prefix) goga topics switch feat-x --todo # same, then edit the topic's todo.md in your $EDITOR @@ -154,7 +154,7 @@ goga topics delete feat-x # delete the branch, its origin twin, and the di goga topics --year 2025 board # the board of an explicit year ``` -Every `create` needs a base: `--base-ref`, or `topics.base_ref` in `.goga/config.yml`, or the current HEAD under `--from-current`. Without a `-t` value a terminal opens the external editor for the todo (empty or unchanged cancels), and on a terminal the command asks once whether to publish. `--publish`/`-p` is the fast mode: it builds the branch off the resolved base with a single `todo.md` commit and pushes it to `origin` without switching — your working copy, index, and HEAD stay untouched, and a failed push rolls the branch back. See [`goga topics`](https://qarium.github.io/goga/cli/topics/). +Every `create` needs a base: `--base-ref`, or `topics.base_ref` in `.goga/config.yml`, or the current HEAD under `--from-current`. With no `-t` given a terminal opens the external editor for the todo (empty or unchanged cancels), and on a terminal the command asks once whether to publish. `--publish`/`-p` is the fast mode: it builds the branch off the resolved base with a single `todo.md` commit and pushes it to `origin` without switching — your working copy, index, and HEAD stay untouched, and a failed push rolls the branch back. See [`goga topics`](https://qarium.github.io/goga/cli/topics/). The board is a three-column table — topic, branch, statuses, plus a todo column under `--info` — with `*` marking the current branch and a local branch absorbing its remote twin. Each topic carries its **maximal statuses** in scale order: `empty → todo → defined → discovered → backlog → designed → specified → planned → done`, deepening as `todo.md`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. A topic can carry several statuses at once (`goga history status` prints them; `-s` filters by any of them). diff --git a/docs/cli/topics.md b/docs/cli/topics.md index 09199596..ac95edc6 100644 --- a/docs/cli/topics.md +++ b/docs/cli/topics.md @@ -61,18 +61,18 @@ goga topics create Feature/Foo_Bar --base-ref origin/main --todo "Payment retry" ### Editor todo entry -`--todo` given without a value opens the external editor instead of taking the text from the command line: +Running the creation with no `--todo` given on an interactive terminal opens the external editor instead of taking the text from the command line. The option takes a value only — a value-less `--todo`/`-t` is click's own usage error (exit 2), not the entry form: ``` -$ goga topics create feat/x --todo +$ goga topics create feat/x --from-current Enter the text. An empty or unchanged file cancels the entry. # (the editor opens; saving writes todo.md, cancelling leaves nothing) # Created branch feat/x and topic 2026/feat-x ``` - The editor resolves through `$VISUAL` → `$EDITOR` → the system default (`vi`); the session edits a temporary file outside the project. -- Saving a blank file — or a file unchanged from its prefill — cancels the entry: the command continues as without the flag, and no `todo.md` is written. A failed editor run is a clean error with nothing mutated. -- Without an interactive terminal the value-less `--todo` is a clean error before any mutation: `the todo needs a value — pass --todo/-t or run the creation on an interactive terminal` (exit 1). +- Saving a blank file — or a file unchanged from its prefill — cancels the entry: the command continues with no `todo.md` written. A failed editor run is a clean error with nothing mutated. +- Without an interactive terminal a creation with no `--todo` value is a clean error before any mutation: `the todo needs a value — pass --todo/-t or run the creation on an interactive terminal` (exit 1). ### The publication ask @@ -89,7 +89,7 @@ goga topics create Feature/Foo_Bar --publish --todo "Payment retry" - The working copy, the index, and HEAD stay untouched — the commit is built through quarantined git plumbing, so a dirty tree and a detached HEAD do not interfere; the topic directory is never created on disk. - The branch carries exactly one commit — the todo file at `.goga/history/<YYYY>/<slug>/todo.md` — and is pushed to `origin` with upstream binding (`git push -u`, exactly that one branch). The topic appears on the remote board with the `todo` status. -- A todo is **required** under `--publish` (the board reads the topic through the todo file). A value-less `--todo` resolves through the editor entry first; a missing or cancelled todo exits 1 with `the publication needs a todo — the board reads the topic through todo.md`. +- A todo is **required** under `--publish` (the board reads the topic through the todo file). An omitted `--todo` resolves through the editor entry first; a missing or cancelled todo exits 1 with `the publication needs a todo — the board reads the topic through todo.md`. - Commit template: `--commit`/`-c` > `topics.publish_commit` > the built-in default `goga: create topic {slug}`. `{slug}` is replaced with the topic slug; a template without the placeholder is used verbatim. `--commit` without `--publish` is a clean error (exit 1) — it acts only together with `--publish`. - `origin` must be configured (exit 1 otherwise, before any mutation). The repository git identity must be set — `commit-tree` needs an author. - A failed push rolls back fully: the planted branch is deleted, nothing else was ever mutated, and git's push reason surfaces as one clean error (`git failed: <git stderr>`, exit 1). A re-run with the same name then succeeds. diff --git a/goga/commands/topics/topics.py b/goga/commands/topics/topics.py index 4fe4bfe7..67afd23a 100644 --- a/goga/commands/topics/topics.py +++ b/goga/commands/topics/topics.py @@ -114,7 +114,7 @@ def board(scope: _TopicsScope, remote: bool = False, info: bool = False) -> None "todo", default=None, metavar="[TEXT]", - help="Todo of the fresh work; an empty value counts as absent; without a value the editor opens on a terminal.", + help="Todo of the fresh work; an empty value counts as absent; with no todo given a terminal opens the editor.", ) @click.option( "--publish", @@ -157,10 +157,11 @@ def create( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared CLI surface year is created from its slug. The base resolves as --base-ref, then topics.base_ref of .goga/config.yml, then the current HEAD under --from-current; no base at all is a clean error naming the flag and - the configuration line. An explicit --todo/-t value is the todo — an - empty value counts as absent; without a value a terminal opens the - external editor and without a terminal the command is a clean error - naming the option. On a terminal without --publish the publication + the configuration line. An explicit --todo/-t value is the todo — the + value form only, a value-less --todo is click's own usage error; an + empty value counts as absent; with no todo given a terminal opens + the external editor and without a terminal the command is a clean + error naming the option. On a terminal without --publish the publication ask appears once a todo is resolved; declining takes the normal path — the branch off the base, the switch, the topic directory, then todo.md. --publish/-p publishes to origin without switching and From 1acb33d4fb5a128e6331753624e96464eee4e86a Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 22:50:04 +0000 Subject: [PATCH 193/229] fix: address code review findings --- docs/cli/topics.md | 6 +-- goga/topics/deletion.py | 67 ++++++++++++++++--------- tests/topics/test_deletion.py | 92 +++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 27 deletions(-) diff --git a/docs/cli/topics.md b/docs/cli/topics.md index ac95edc6..e0efd908 100644 --- a/docs/cli/topics.md +++ b/docs/cli/topics.md @@ -145,11 +145,11 @@ goga topics delete feature-foo feature-bar --yes Every IDENTIFIER resolves first — a branch name, a topic slug, or their prefix (the same tier order as `switch`), plus topic directories of the year no branch hosts: -- An identifier nothing hosts, an ambiguous identifier, merged work, or the current branch hosting a target is a clean error (exit 1) and nothing is deleted — the resolution is all-or-nothing. -- A local branch and its `origin` twin collapse into one target; repeated identifiers naming one topic collapse too. A tracking ref of another remote is not a twin — the deletion push targets `origin` only. +- An identifier nothing hosts, an ambiguous identifier, merged work, several branches hosting one topic, or the current branch hosting a target is a clean error (exit 1) and nothing is deleted — the resolution is all-or-nothing. +- A local branch and its `origin` twin collapse into one target; repeated identifiers naming one topic collapse too. A tracking ref of another remote is not a twin — the deletion push targets `origin` only. Two local branches normalizing into one slug (say `Feature/Foo` and `feature-foo`) never pick one of them by order — `several branches host topic '<topic>': <branches> — remove all but one of them before deleting`. - Merged work is out of scope: a topic hosted by a branch that is not its own topic branch (the post-merge state) is a clean error naming the hosting branch — `topic '<topic>' is hosted by <branches> as merged work — remove it from the hosting branch's tree instead of deleting`. A topic directory no branch hosts stays deletable (directory only). - The resolved list prints one line per target — `<topic> -> <branch>` (or the twin, or `(directory only)`) — and one confirmation covers the whole list; a declined answer exits 0 with nothing deleted. `--yes`/`-y` skips the confirmation; without it a non-interactive terminal is a clean error naming the flag. -- The removal deletes each topic's local branch, its `origin` twin (a network push), and its topic directory. The current branch hosting a target — by branch name or by slug — is a clean error asking to switch away first. +- The removal deletes each topic's local branch, its `origin` twin (a network push), and its topic directory — a directory a surviving branch still carries as merged work stays on disk with that branch's tree. The current branch hosting a target — by branch name or by slug — is a clean error asking to switch away first. - A rejected remote deletion restores the failing target's local branch at its captured commit and surfaces git's reason as one clean error; targets removed before the failure stay removed. ## Exit Codes diff --git a/goga/topics/deletion.py b/goga/topics/deletion.py index 6b308b1a..64ff950a 100644 --- a/goga/topics/deletion.py +++ b/goga/topics/deletion.py @@ -89,7 +89,8 @@ def resolve_delete_targets( 2. Each identifier resolves through the tiers — the exact branch name (a local ref by its name, a remote-tracking ref by its short name), the exact topic slug (a ref hosting it in its - tree, or a disk topic), the prefixes of both — the first + tree, or a disk topic — the slug names one topic, never the + hosting ref's other topics), the prefixes of both — the first non-empty tier wins 3. Within the tier the distinct hosted topics decide: none or a single tier without topics -> clean error naming the @@ -99,11 +100,15 @@ def resolve_delete_targets( when its normalized name equals the topic slug; a topic whose every hosting ref carries it as merged work is a clean error naming the topic and the hosting branch — a disk topic no - branch hosts stays targetable (no refs, directory only) + branch hosts stays targetable (no refs, directory only); a + topic an eligible ref and a merged-work host both carry keeps + its directory — the merged host's tree survives the deletion 5. Assemble every identified target from the full inventory — the local ref and the remote-tracking twin whose normalized names equal the slug, and the disk presence — never from the tier - that matched, so the result cannot depend on identifier order + that matched, so the result cannot depend on identifier order; + several local refs normalizing into the slug are a clean error + naming them 6. The current branch naming any target's branch, or its slug naming any target's topic -> clean error asking to switch away first @@ -120,7 +125,8 @@ def resolve_delete_targets( Raises: click.ClickException: an identifier nothing hosts, an ambiguous - identifier, merged work, the current branch hosting a target, + identifier, merged work, several branches hosting one topic, + the current branch hosting a target, a git infrastructure failure (its stderr when git reports one, or a missing git binary), or an OS failure of the history-tree read. @@ -238,7 +244,7 @@ def _identify( slug = normalize_topic_slug(identifier) tiers = ( _tier_exact_branch(identifier, refs, hosted), - _tier_exact_slug(slug, refs, hosted, disk), + _tier_exact_slug(slug, hosted, disk), _tier_prefix(identifier, slug, refs, hosted, disk), ) @@ -283,30 +289,25 @@ def _tier_exact_branch( return set().union(*(hosted[ref.name] for ref in matched)) -def _tier_exact_slug( - slug: str, refs: list[BranchRef], hosted: dict[str, set[str]], disk: set[str] -) -> set[str] | None: +def _tier_exact_slug(slug: str, hosted: dict[str, set[str]], disk: set[str]) -> set[str] | None: """Take the second tier — the exact topic slug. Args: slug: The normalized identifier. - refs: The full branch inventory. hosted: The hosted topic slugs per ref display name. disk: The on-disk topic slugs of the year. Returns: - The hosted topics of the refs carrying the slug, plus the slug - itself when it sits on disk — ``None`` when neither matches (the - tier is skipped). + The slug itself when a ref hosts it or it sits on disk — the + slug names exactly one topic, and a hosting ref's other topics + are not matches of it (the per-pair tiering of the switch + resolver) — ``None`` when neither matches (the tier is skipped). """ - matched = [ref for ref in refs if slug != "" and slug in hosted[ref.name]] - on_disk = slug != "" and slug in disk - if not matched and not on_disk: + if slug == "": return None - topics = set().union(*(hosted[ref.name] for ref in matched)) if matched else set() - if on_disk: - topics.add(slug) - return topics + if any(slug in slugs for slugs in hosted.values()) or slug in disk: + return {slug} + return None def _tier_prefix( @@ -367,11 +368,17 @@ def _assemble_target( Returns: The assembled target — the local branch and the origin twin short - name of the eligible refs, and the disk presence. + name of the eligible refs, and the disk presence. The directory + joins the target only when no merged-work host carries the topic: + a merged host's tree survives the deletion, so removing its + working-copy directory would dirty the hosting branch's checkout + while the topic lives on in its commits. Raises: click.ClickException: the topic is hosted only by refs that carry - it as merged work — the hosting branch is named in the error. + it as merged work — the hosting branch is named in the error; + or several local refs normalize into the slug — they are all + named in the error, and one of them must go first. """ hosts = [ref for ref in refs if topic in hosted[ref.name]] eligible = [ref for ref in hosts if _normalized_name(ref) == topic] @@ -381,8 +388,19 @@ def _assemble_target( f"topic {topic!r} is hosted by {names} as merged work — " "remove it from the hosting branch's tree instead of deleting" ) - - branch = next((ref.name for ref in eligible if not ref.remote), None) + merged = [ref for ref in hosts if _normalized_name(ref) != topic] + + # Two local refs normalizing into one slug must never pick one of + # them by inventory order — the named branch could be the one left + # behind. The error is order-independent by construction. + local_names = [ref.name for ref in eligible if not ref.remote] + if len(local_names) > 1: + names = ", ".join(local_names) + raise click.ClickException( + f"several branches host topic {topic!r}: {names} — " + "remove all but one of them before deleting" + ) + branch = local_names[0] if local_names else None # The twin is the *origin* twin — the one remote the deletion push of # the git cell addresses. A tracking ref of another remote stays an # eligible host (never merged work), but it contributes no deletable @@ -397,7 +415,8 @@ def _assemble_target( ), None, ) - return DeleteTarget(topic=topic, branch=branch, remote=remote, has_dir=topic in disk) + has_dir = topic in disk and not merged + return DeleteTarget(topic=topic, branch=branch, remote=remote, has_dir=has_dir) def _normalized_name(ref: BranchRef) -> str: diff --git a/tests/topics/test_deletion.py b/tests/topics/test_deletion.py index d4f215b7..cc9d5935 100644 --- a/tests/topics/test_deletion.py +++ b/tests/topics/test_deletion.py @@ -304,6 +304,39 @@ def test_resolve_delete_targets_merged_topic_is_error( assert "feature-x" in raised.value.message assert "main" in raised.value.message + def test_resolve_delete_targets_slug_tier_names_one_topic_of_multi_topic_host( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The exact-slug tier names one topic — never the host's other topics. + + An integration branch accumulates merged topics by design; deleting + one by slug must reach the merged-work guard naming the hosting + branch, not an ambiguity listing every topic the branch hosts. + """ + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="origin/main", remote=True), + ] + trees = { + "main": [ + ".goga/history/2026/feature-a/plan.md", + ".goga/history/2026/feature-b/plan.md", + ], + "origin/main": [ + ".goga/history/2026/feature-a/plan.md", + ".goga/history/2026/feature-b/plan.md", + ], + } + _wire_resolution(monkeypatch, inventory, trees, "other") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-b"], year="2026") + + assert "merged work" in raised.value.message + assert "feature-b" in raised.value.message + assert "main" in raised.value.message + def test_resolve_delete_targets_integration_branch_named_directly_is_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -455,6 +488,65 @@ def test_resolve_delete_targets_current_branch_guard_slug_arm( with pytest.raises(click.ClickException, match="switch"): resolve_delete_targets(["feature-foo"], year="2026") + def test_resolve_delete_targets_merged_host_keeps_the_directory( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A topic still carried by a merged-work host keeps its directory. + + The own branch goes, the twin goes, the working-copy directory + stays: the merged host's tree still carries the topic, and + removing the directory would dirty its checkout while the board + keeps showing the topic. + """ + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "feature-foo") + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="origin/main", remote=True), + BranchRef(name="feature-foo", remote=False), + BranchRef(name="origin/feature-foo", remote=True), + ] + trees = { + "main": [".goga/history/2026/feature-foo/plan.md"], + "origin/main": [".goga/history/2026/feature-foo/plan.md"], + "feature-foo": [".goga/history/2026/feature-foo/plan.md"], + "origin/feature-foo": [".goga/history/2026/feature-foo/plan.md"], + } + _wire_resolution(monkeypatch, inventory, trees, "main") + + targets = resolve_delete_targets(["feature-foo"], year="2026") + + assert targets == [ + DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=False) + ] + + def test_resolve_delete_targets_several_same_slug_branches_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Two local branches normalizing into one slug never pick one of them. + + ``Feature/Foo`` and ``feature-foo`` both normalize to + ``feature-foo``; naming one of them exactly must not assemble a + target that deletes the other by inventory order. + """ + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="Feature/Foo", remote=False), + BranchRef(name="feature-foo", remote=False), + ] + trees = { + "Feature/Foo": [".goga/history/2026/feature-foo/plan.md"], + "feature-foo": [".goga/history/2026/feature-foo/plan.md"], + } + _wire_resolution(monkeypatch, inventory, trees, "main") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-foo"], year="2026") + + assert "several branches" in raised.value.message + assert "Feature/Foo" in raised.value.message + assert "feature-foo" in raised.value.message + # --- Infrastructure boundary --- From 1fdf04501f79d58252fb492c89c12650581fac5c Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 23:24:10 +0000 Subject: [PATCH 194/229] fix: address code review findings --- docs/cli/topics.md | 2 +- goga/topics/.usages/deleting.md | 5 ++- goga/topics/deletion.py | 23 ++++++++---- tests/integration/test_topic_workflows.py | 29 +++++++++++++++ tests/topics/test_deletion.py | 45 +++++++++++++++++++++++ 5 files changed, 95 insertions(+), 9 deletions(-) diff --git a/docs/cli/topics.md b/docs/cli/topics.md index e0efd908..8cc04670 100644 --- a/docs/cli/topics.md +++ b/docs/cli/topics.md @@ -147,7 +147,7 @@ Every IDENTIFIER resolves first — a branch name, a topic slug, or their prefix - An identifier nothing hosts, an ambiguous identifier, merged work, several branches hosting one topic, or the current branch hosting a target is a clean error (exit 1) and nothing is deleted — the resolution is all-or-nothing. - A local branch and its `origin` twin collapse into one target; repeated identifiers naming one topic collapse too. A tracking ref of another remote is not a twin — the deletion push targets `origin` only. Two local branches normalizing into one slug (say `Feature/Foo` and `feature-foo`) never pick one of them by order — `several branches host topic '<topic>': <branches> — remove all but one of them before deleting`. -- Merged work is out of scope: a topic hosted by a branch that is not its own topic branch (the post-merge state) is a clean error naming the hosting branch — `topic '<topic>' is hosted by <branches> as merged work — remove it from the hosting branch's tree instead of deleting`. A topic directory no branch hosts stays deletable (directory only). +- Merged work is out of scope: a topic hosted by a branch that is not its own topic branch (the post-merge state) is a clean error naming the hosting branch — `topic '<topic>' is hosted by <branches> as merged work — remove it from the hosting branch's tree instead of deleting`. A topic directory no branch hosts stays deletable (directory only) — an unpublished topic (its todo not yet committed) reaches its disk directory by its exact name even though its branch carries no topic yet, and the bare branch itself stays. - The resolved list prints one line per target — `<topic> -> <branch>` (or the twin, or `(directory only)`) — and one confirmation covers the whole list; a declined answer exits 0 with nothing deleted. `--yes`/`-y` skips the confirmation; without it a non-interactive terminal is a clean error naming the flag. - The removal deletes each topic's local branch, its `origin` twin (a network push), and its topic directory — a directory a surviving branch still carries as merged work stays on disk with that branch's tree. The current branch hosting a target — by branch name or by slug — is a clean error asking to switch away first. - A rejected remote deletion restores the failing target's local branch at its captured commit and surfaces git's reason as one clean error; targets removed before the failure stay removed. diff --git a/goga/topics/.usages/deleting.md b/goga/topics/.usages/deleting.md index bd2869d4..fb44fbdc 100644 --- a/goga/topics/.usages/deleting.md +++ b/goga/topics/.usages/deleting.md @@ -16,7 +16,10 @@ confirmed deletion. print(target.topic, target.branch, target.remote, target.has_dir) - Identifier tiers: exact branch name, exact topic slug, prefixes — - plus topic directories of the year no branch hosts. + plus topic directories of the year no branch hosts. An exact branch + name hosting nothing falls through to the slug tier: an unpublished + topic (its todo uncommitted) resolves through its disk directory, + while the bare branch itself never resolves. - No match or several matches -> a clean error, no interactive selection; the whole call is cancelled — all-or-nothing. - A local branch and its origin twin form one target; repeated diff --git a/goga/topics/deletion.py b/goga/topics/deletion.py index 64ff950a..7f749231 100644 --- a/goga/topics/deletion.py +++ b/goga/topics/deletion.py @@ -92,10 +92,11 @@ def resolve_delete_targets( tree, or a disk topic — the slug names one topic, never the hosting ref's other topics), the prefixes of both — the first non-empty tier wins - 3. Within the tier the distinct hosted topics decide: none or a - single tier without topics -> clean error naming the - identifier; more than one -> clean error listing the - candidates — no interactive choice + 3. Within the tier the distinct hosted topics decide: more than + one -> clean error listing the candidates — no interactive + choice; an empty tier names no topic and the resolution falls + through to the next — no topic in any tier -> clean error + naming the identifier (a branch nothing hosts never resolves) 4. Merged-work guard: a hosting ref is part of the target only when its normalized name equals the topic slug; a topic whose every hosting ref carries it as merged work is a clean error @@ -257,7 +258,12 @@ def _identify( ) if topics: return next(iter(topics)) - break + # An empty tier names no topic — fall through to the next tier. The + # exact name of a bare branch must not shadow the slug tier: right + # after a creation the branch exists while its todo.md is still + # uncommitted, so the topic lives on disk only and the exact-name + # identifier reaches it there (a branch nothing hosts never + # resolves — deletion deletes topics, not bare branches). raise click.ClickException(f"no topic matches {identifier!r}") @@ -275,8 +281,11 @@ def _tier_exact_branch( Returns: The distinct hosted topics of the matched refs — ``None`` when no ref carries the name (the tier is skipped), an empty set when a - matched branch hosts nothing (deletion deletes topics, not bare - branches). + matched branch hosts nothing: the empty tier names no topic, so + the resolution falls through to the slug tier — the exact name of + a bare branch must not shadow the disk topic of the same slug + (deletion deletes topics, not bare branches: a branch nothing + hosts never resolves). """ matched = [ ref diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index b3dc5558..f7ac80ef 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -1327,3 +1327,32 @@ def test_delete_cli_round_trip_with_yes( assert "refs/heads/Feature/Foo_Bar" not in _git_out( tmp_path, "for-each-ref", "--format=%(refname)", "refs/heads" ) + + def test_delete_unpublished_topic_by_exact_name_over_real_git( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A freshly created, unpublished topic deletes by its exact name. + + ``create_topic`` leaves the todo.md uncommitted, so the branch is + bare of the topic and only the disk directory carries it — the + exact-name identifier, which the exact-branch tier matches as a + bare branch, must still reach the disk topic. The bare branch + itself stays: deletion deletes topics, not bare branches. + """ + _init_publish_repo(tmp_path) + monkeypatch.chdir(tmp_path) + year = current_year() + create_topic("feature-foo", "main", todo="the plan", year=year) + _git(tmp_path, "switch", "-q", "main") + + targets = resolve_delete_targets(["feature-foo"], year=year) + + assert targets == [ + DeleteTarget(topic="feature-foo", branch=None, remote=None, has_dir=True) + ] + line = delete_topics(targets, year=year) + + assert line == f"Deleted 1 topic(s) of {year}: feature-foo" + assert not (tmp_path / ".goga" / "history" / year / "feature-foo").exists() + # The bare branch stays — it hosts no topic. + assert _git_out(tmp_path, "rev-parse", "--verify", "refs/heads/feature-foo") diff --git a/tests/topics/test_deletion.py b/tests/topics/test_deletion.py index cc9d5935..aa8f7bae 100644 --- a/tests/topics/test_deletion.py +++ b/tests/topics/test_deletion.py @@ -365,6 +365,51 @@ def test_resolve_delete_targets_branch_without_topic_error( assert "gh-pages" in raised.value.message + def test_resolve_delete_targets_exact_name_falls_through_bare_branch_to_disk_topic( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The exact name of a bare branch does not shadow the slug tier. + + Right after a creation the branch exists while its todo.md is + still uncommitted — the topic lives on disk only, so the + exact-name identifier must reach it there, exactly as a prefix + identifier does. + """ + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "feature-foo") + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="feature-foo", remote=False), + ] + _wire_resolution(monkeypatch, inventory, {}, "main") + + targets = resolve_delete_targets(["feature-foo"], year="2026") + + assert targets == [DeleteTarget(topic="feature-foo", branch=None, remote=None, has_dir=True)] + + def test_resolve_delete_targets_bare_branch_shadow_reaches_merged_work_guard( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A bare same-named branch must not mask the merged-work truth. + + The exact name of the bare branch falls through to the slug tier, + which finds the topic hosted by another branch — the merged-work + guard names it instead of the misleading no-match error. + """ + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feature-x", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"main": [".goga/history/2026/feature-x/plan.md"]} + _wire_resolution(monkeypatch, inventory, trees, "other") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-x"], year="2026") + + assert "merged work" in raised.value.message + assert "main" in raised.value.message + def test_resolve_delete_targets_no_match_names_identifier( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 5213bb3e2982158a6fe446b53b9ea3befa8db8a0 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Wed, 2 Sep 2026 23:41:41 +0000 Subject: [PATCH 195/229] fix: address code review findings --- goga/topics/deletion.py | 12 ++++++++---- tests/topics/test_deletion.py | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/goga/topics/deletion.py b/goga/topics/deletion.py index 7f749231..263f3382 100644 --- a/goga/topics/deletion.py +++ b/goga/topics/deletion.py @@ -335,15 +335,19 @@ def _tier_prefix( The hosted topics of the refs whose name starts with the identifier, plus the hosted and disk slugs starting with the normalized slug — ``None`` when nothing matches (the tier is - skipped). A non-ASCII identifier normalizes to the empty slug, - which every slug starts with, so the slug-prefix arms stay - disabled for it. + skipped). The short-name arm carries the exact tier's remote + rule: a remote-tracking ref is read by its short name, a local + branch by its full name alone — a slashed local branch's tail + never widens the prefix. A non-ASCII identifier normalizes to + the empty slug, which every slug starts with, so the + slug-prefix arms stay disabled for it. """ topics: set[str] = set() matched = [ ref for ref in refs - if ref.name.startswith(identifier) or _short_name(ref.name).startswith(identifier) + if ref.name.startswith(identifier) + or (ref.remote and _short_name(ref.name).startswith(identifier)) ] for ref in matched: topics |= hosted[ref.name] diff --git a/tests/topics/test_deletion.py b/tests/topics/test_deletion.py index aa8f7bae..1e035d90 100644 --- a/tests/topics/test_deletion.py +++ b/tests/topics/test_deletion.py @@ -460,6 +460,26 @@ def test_resolve_delete_targets_prefix_of_remote_short_name_matches( DeleteTarget(topic="feature-foo", branch=None, remote="feature-foo", has_dir=False) ] + def test_resolve_delete_targets_local_short_name_prefix_does_not_match( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A local slashed branch is read by its full name, never its tail. + + The short-name arm is the exact tier's remote rule: ``Foo`` is a + prefix of neither ``Feature/Foo_Bar`` nor its topic slug, so the + identifier resolves to nothing — the branch stays reachable only + through its full name or the slug prefix. + """ + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="Feature/Foo_Bar", remote=False)] + trees = {"Feature/Foo_Bar": [".goga/history/2026/feature-foo-bar/plan.md"]} + _wire_resolution(monkeypatch, inventory, trees, "main") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["Foo"], year="2026") + + assert "no topic matches 'Foo'" in raised.value.message + def test_resolve_delete_targets_non_ascii_identifier_matches_nothing( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From e3b653336e668cc87c986b63d645e3115564e1f0 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 09:14:46 +0000 Subject: [PATCH 196/229] fix: align topics contracts with implementation after acceptance review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manifest: DeleteTarget.has_dir and resolve_delete_targets state the eligible+merged mixed case (directory stays out of the target); create_topic documents the checkout-failure rollback of the planted branch, the preflight check order, and the exact-one trailing-newline rule of todo.md. Usages: deleting.md and topics-command.md carry the same mixed-case clause; pipeline-command.md lists --todo among the flags the info forms silently ignore. Tests: eight boundary tests for the uncovered error mappings — history-tree OSError and detached-HEAD guard of the deletion resolution, missing-git/OSError of the removal, and the CalledProcessError/FileNotFoundError/ImportError mappings plus the candidate-match tail of ensure_topic. --- .../pipeline/.usages/pipeline-command.md | 2 +- .../commands/topics/.usages/topics-command.md | 15 ++-- goga/topics/.usages/deleting.md | 14 +-- goga/topics/CODEMANIFEST | 61 ++++++++----- tests/topics/test_deletion.py | 63 +++++++++++++ tests/topics/test_ensuring.py | 90 +++++++++++++++++++ 6 files changed, 211 insertions(+), 34 deletions(-) diff --git a/goga/commands/pipeline/.usages/pipeline-command.md b/goga/commands/pipeline/.usages/pipeline-command.md index a0bdac4f..88b0909c 100644 --- a/goga/commands/pipeline/.usages/pipeline-command.md +++ b/goga/commands/pipeline/.usages/pipeline-command.md @@ -66,7 +66,7 @@ silently. ## Flag behavior in the list/info forms - Ignored (no-op, no side effects): `-e/--env`, `--proxy`, `-c/--clean`, - `-s/--skip`, `-p/--parallel`, `--add-host`, `-t/--topic`. + `-s/--skip`, `-p/--parallel`, `--add-host`, `-t/--topic`, `--todo`. - `-u/--update`: works in `--list` without `--info`; no-op in both `--info` forms. - `-w/--workflow` and `--no-workflow`: validated as usual (exclusivity and, diff --git a/goga/commands/topics/.usages/topics-command.md b/goga/commands/topics/.usages/topics-command.md index d541f38c..c3cb8bb2 100644 --- a/goga/commands/topics/.usages/topics-command.md +++ b/goga/commands/topics/.usages/topics-command.md @@ -96,12 +96,15 @@ non-terminal without --yes is a clean error; the -y collision with the group --year is resolved by position). The deletion is symmetric to creation-and-publication: the local branch and its origin twin are both removed (the local first; a failed remote deletion restores the -local branch and stops with one clean error), a directory without -branches is removed from disk. The current branch hosting a target is -a clean error — switch away first. Merged work is out of scope: a -topic hosted by a branch that is not its own topic branch is a clean -error naming the hosting branch. Unmerged commits never block: the -deletion is unconditional after the confirmation. +local branch and stops with one clean error), and the topic directory +joins the deletion of every target — branches or none. The current +branch hosting a target is a clean error — switch away first. Merged +work is out of scope: a topic hosted only by branches that are not its +own topic branch is a clean error naming the hosting branch; a topic +carried by both its own branch and a merged-work host deletes its +eligible refs but keeps its directory — the merged host's tree +survives. Unmerged commits never block: the deletion is unconditional +after the confirmation. ## Exit codes diff --git a/goga/topics/.usages/deleting.md b/goga/topics/.usages/deleting.md index fb44fbdc..e4ed91ce 100644 --- a/goga/topics/.usages/deleting.md +++ b/goga/topics/.usages/deleting.md @@ -24,10 +24,12 @@ confirmed deletion. selection; the whole call is cancelled — all-or-nothing. - A local branch and its origin twin form one target; repeated identifiers collapse. -- Merged work is out of scope: a topic hosted by a branch that is not - its own topic branch (the post-merge state) is a clean error naming - the hosting branch — remove it from the hosting branch's tree - instead. +- Merged work is out of scope: a topic hosted only by branches that + are not its own topic branch (the post-merge state) is a clean error + naming the hosting branch — remove it from the hosting branch's tree + instead. A topic carried by both an eligible ref and a merged-work + host deletes its eligible refs but keeps its directory — the merged + host's tree survives the deletion. - The current branch hosting a target -> a clean error asking to switch away first. @@ -44,6 +46,8 @@ confirmed deletion. remote deletion restores the local branch at its former commit and raises one clean error — targets removed before the failure stay removed. -- A directory without branches is removed from disk. +- A directory without branches is removed from disk — the topic + directory joins the deletion of every target (branches or none) + unless a merged-work host carries the topic. - The deletion push is a network operation of the domain; no fetch ever happens. diff --git a/goga/topics/CODEMANIFEST b/goga/topics/CODEMANIFEST index ba1033a5..3a8e719a 100644 --- a/goga/topics/CODEMANIFEST +++ b/goga/topics/CODEMANIFEST @@ -399,13 +399,12 @@ Annotations: | Algorithm: 1. Preflight, read-only and before any input: normalize `branch_name` into a slug via `normalize_topic_slug`; an empty - slug is an input error; the occupancy oracles - `check_branch_occupancy` and `check_slug_occupancy` report a - conflict; the current branch — read via + slug is an input error; the current branch — read via `resolve_current_branch_name` — hosting the same slug is a - conflict; resolve `base_ref` into its commit via - `resolve_ref_commit` — every conflict is a clean error with a - hint to the board + conflict; the occupancy oracles `check_branch_occupancy` and + `check_slug_occupancy` report a conflict; resolve `base_ref` + into its commit via `resolve_ref_commit` — every conflict is a + clean error with a hint to the board 2. Resolve the todo: a value given is the todo; without a value, an interactive terminal opens the editor session via `edit_text`, its cancellation leaves no todo, a non-interactive terminal is a @@ -417,10 +416,12 @@ Annotations: | otherwise 5. The normal path: create the branch at the base commit via `create_branch_at_commit` and switch to it via - `checkout_local_branch`, create the topic directory of the year - via `ensure_topic_dir`, and write the todo file todo.md — the - path resolved via `resolve_topic_file` — when a todo resolved; - the write is the last action of the path + `checkout_local_branch` — a failed checkout rolls the planted + branch back via `delete_local_branch` (the occupancy oracle + would otherwise block the retry) —, create the topic directory + of the year via `ensure_topic_dir`, and write the todo file + todo.md — the path resolved via `resolve_topic_file` — when a + todo resolved; the write is the last action of the path 6. The publication path: delegate to `publish_topic` with the name, the todo, the base, the template, and the year 7. Return the single result line @@ -428,9 +429,12 @@ Annotations: | Requirements: - Every decision — preflight, todo, ask — precedes the first mutation - - The todo.md file carries the todo as entered plus a single - trailing newline, encoded UTF-8 — empty lines inside the text - stay as entered + - A failed checkout of the normal path rolls the planted branch + back — nothing of the path stays behind + - The todo.md file carries the todo as entered with exactly one + trailing newline — a todo already ending in one keeps it, a + bare todo gains it — encoded UTF-8; empty lines inside the + text stay as entered - The todo.md file is written only when a todo resolved - The topic directory exists before the todo.md file is written - The branch keeps the name as entered; the topic directory takes @@ -462,8 +466,9 @@ Annotations: | an existing file provides the initial text 2. Open the editor session via `edit_text` with the initial text 3. A cancelled entry -> False — the file stays untouched - 4. The saved text -> write todo.md as entered plus a single - trailing newline, encoded UTF-8, without a commit -> True + 4. The saved text -> write todo.md as entered with exactly one + trailing newline — a text already ending in one keeps it — + encoded UTF-8, without a commit -> True Requirements: - The write is the last action — nothing follows it @@ -517,7 +522,7 @@ Annotations: | `branch_name`: the branch name as entered by the user `todo`: the multi-line todo of the fresh work — written to todo.md - as entered plus a single trailing newline; required and + as entered with exactly one trailing newline; required and non-empty, an empty todo is a clean error asking for it `base_ref`: the base revision the branch starts from — any revision string, resolved as git resolves it @@ -568,8 +573,10 @@ Annotations: | - A failed publication rolls back fully — the planted branch is deleted and nothing else was ever mutated; a re-run after the cause is resolved succeeds - - The todo.md file carries `todo` as entered plus a single trailing - newline, encoded UTF-8 — the sole artifact of the topic directory + - The todo.md file carries `todo` as entered with exactly one + trailing newline — a todo already ending in one keeps it, a + bare todo gains it — encoded UTF-8; the sole artifact of the + topic directory - The result is exactly one line Constraints: @@ -630,7 +637,9 @@ Annotations: | `branch`: the hosting local branch name, or None `remote`: the hosting origin twin name, or None `has_dir`: True when the topic directory of the year exists on - disk + disk and no merged-work host carries the topic — a + merged host's tree survives the deletion, so its + directory stays out of the target Apply the `convention` practice for the data-model rules and intra-package imports. @@ -642,7 +651,9 @@ Annotations: | "remote -> str | None": | The hosting origin twin name, or None. "has_dir -> bool": | - True when the topic directory of the year exists on disk. + True when the topic directory of the year exists on disk and no + merged-work host carries the topic — a merged host's tree + survives the deletion, so its directory stays out of the target. "resolve_delete_targets(identifiers: list[str], year: str | None = None) -> targets: list[DeleteTarget]": location: deletion.py @@ -675,8 +686,11 @@ Annotations: | is not part of the target; when no eligible hosting ref remains, the identifier is a clean error naming the topic and the hosting branch — merged work is removed from the hosting - branch's tree, not deleted here. A topic directory no branch - hosts stays targetable (no refs, directory only) + branch's tree, not deleted here. A topic carried by both an + eligible ref and a merged-work host deletes its eligible refs + but keeps its directory — the merged host's tree survives the + deletion, so the target covers the refs only. A topic directory + no branch hosts stays targetable (no refs, directory only) 5. A local branch and its origin twin form one target; identifiers naming one topic collapse into it 6. The current branch — read via `resolve_current_branch_name` — @@ -690,6 +704,9 @@ Annotations: | - A topic hosted only by refs that are not its own topic branch is a clean error naming the hosting branch — merged work is out of scope + - A topic carried by both an eligible ref and a merged-work host + deletes its eligible refs but keeps its directory — the merged + host's tree survives the deletion Constraints: - Do not resolve remote state over the network — the local diff --git a/tests/topics/test_deletion.py b/tests/topics/test_deletion.py index 1e035d90..f782a804 100644 --- a/tests/topics/test_deletion.py +++ b/tests/topics/test_deletion.py @@ -644,6 +644,37 @@ def test_missing_git_binary_surfaces_as_clean_error( assert "git is not available" in raised.value.message + def test_history_tree_read_failure_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An OS failure of the history-tree read becomes a ``ClickException``.""" + monkeypatch.chdir(tmp_path) + _wire_resolution(monkeypatch, _twin_inventory(), _twin_trees(), None) + monkeypatch.setattr( + deletion, "collect_history_tree", mock.Mock(side_effect=OSError("history tree unreadable")) + ) + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-foo"], year="2026") + + assert "reading the history tree failed" in raised.value.message + assert "history tree unreadable" in raised.value.message + + def test_detached_head_skips_the_current_branch_guard( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """No current branch (a detached HEAD): the guard passes silently + and the resolution completes.""" + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "feature-foo") + _wire_resolution(monkeypatch, _twin_inventory(), _twin_trees(), None) + + targets = resolve_delete_targets(["feature-foo"], year="2026") + + assert targets == [ + DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True) + ] + # --- Logic tests: the confirmed removal --- @@ -767,3 +798,35 @@ def test_delete_topics_targets_before_a_failure_stay_removed( mock.call.remote("feature-bar"), mock.call.restore("feature-bar", "c123"), ] + + def test_delete_topics_missing_git_binary_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A missing git binary during the removal is a clean error.""" + monkeypatch.chdir(tmp_path) + target = DeleteTarget(topic="feature-foo", branch="feature-foo", remote=None, has_dir=False) + monkeypatch.setattr(deletion, "resolve_ref_commit", mock.Mock(return_value="c123")) + monkeypatch.setattr(deletion, "delete_local_branch", mock.Mock(side_effect=FileNotFoundError("git"))) + + with pytest.raises(click.ClickException) as raised: + delete_topics([target], year="2026") + + assert "git is not available" in raised.value.message + + def test_delete_topics_removal_oserror_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An OS failure of the directory removal is a clean error.""" + monkeypatch.chdir(tmp_path) + target = DeleteTarget(topic="feature-foo", branch=None, remote=None, has_dir=True) + + def _boom(*args: object, **kwargs: object) -> bool: + raise OSError("disk full") + + _wire_removal(monkeypatch, dir_side_effect=_boom) + + with pytest.raises(click.ClickException) as raised: + delete_topics([target], year="2026") + + assert "cannot complete the deletion" in raised.value.message + assert "disk full" in raised.value.message diff --git a/tests/topics/test_ensuring.py b/tests/topics/test_ensuring.py index 60f0e685..80c58863 100644 --- a/tests/topics/test_ensuring.py +++ b/tests/topics/test_ensuring.py @@ -19,6 +19,7 @@ from __future__ import annotations import inspect +import subprocess import sys import typing from collections.abc import Callable @@ -554,3 +555,92 @@ def test_ensure_topic_todo_non_tty_error_before_action( ensure_topic("anything", todo=True) resolver.assert_not_called() + + def test_ensure_topic_todo_current_branch_matching_no_candidate_creates_directory( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The current branch matching no resolution candidate takes the + directory-creation path: the topic directory of the current branch + is created first, then the fresh entry follows.""" + monkeypatch.chdir(tmp_path) + candidate = SwitchCandidate( + branch="other-branch", topic="other-topic", statuses=[], current=False, remote=False + ) + _wire_resolver(monkeypatch, [candidate]) + _wire_switch(monkeypatch, "Switched to branch fresh-work") + _wire_current(monkeypatch, "fresh-work") + ensure_dir = mock.Mock() + monkeypatch.setattr(ensuring, "ensure_topic_dir", ensure_dir) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + order = mock.Mock() + order.attach_mock(ensure_dir, "ensure_topic_dir") + order.attach_mock(entry, "entry") + + result = ensure_topic("fresh-work", todo=True, year="2026") + + assert result == "Switched to branch fresh-work" + ensure_dir.assert_called_once_with("fresh-work", "2026") + entry.assert_called_once_with("fresh-work", "2026") + assert order.mock_calls == [ + mock.call.ensure_topic_dir("fresh-work", "2026"), + mock.call.entry("fresh-work", "2026"), + ] + + +# --- Logic tests: the infrastructure boundary of the ensure --- + + +class TestEnsuringInfrastructureBoundary: + def test_git_failure_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A raw git infrastructure failure escaping a delegate becomes a + ``ClickException`` carrying the git reason.""" + monkeypatch.chdir(tmp_path) + candidate = SwitchCandidate( + branch="feature-foo", topic="feature-foo", statuses=["todo"], current=True, remote=False + ) + _wire_resolver(monkeypatch, [candidate]) + failure = subprocess.CalledProcessError(128, "git checkout", stderr="fatal: bad object") + monkeypatch.setattr(ensuring, "switch_topic", mock.Mock(side_effect=failure)) + + with pytest.raises(click.ClickException) as raised: + ensure_topic("feature-foo", year="2026") + + assert "fatal: bad object" in raised.value.message + + def test_missing_git_binary_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A raw missing-git-binary failure escaping a delegate is a clean + error.""" + monkeypatch.chdir(tmp_path) + candidate = SwitchCandidate( + branch="feature-foo", topic="feature-foo", statuses=["todo"], current=True, remote=False + ) + _wire_resolver(monkeypatch, [candidate]) + monkeypatch.setattr(ensuring, "switch_topic", mock.Mock(side_effect=FileNotFoundError("git"))) + + with pytest.raises(click.ClickException) as raised: + ensure_topic("feature-foo", year="2026") + + assert "git is not available" in raised.value.message + + def test_broken_tool_package_import_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A fatal ``ImportError`` escaping a delegate keeps its package + name in the clean error.""" + monkeypatch.chdir(tmp_path) + candidate = SwitchCandidate( + branch="feature-foo", topic="feature-foo", statuses=["todo"], current=True, remote=False + ) + _wire_resolver(monkeypatch, [candidate]) + broken = ImportError("package goga_tool_bad failed to import: boom") + monkeypatch.setattr(ensuring, "switch_topic", mock.Mock(side_effect=broken)) + + with pytest.raises(click.ClickException) as raised: + ensure_topic("feature-foo", year="2026") + + assert raised.value.message == "package goga_tool_bad failed to import: boom" From 237166e7b453c9bb90bfb2291371cbcb95b7e1c4 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 09:21:36 +0000 Subject: [PATCH 197/229] chore(memory): update project memory --- .goga/memory/architecture.md | 83 ++++++++++++++++++++---------------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/.goga/memory/architecture.md b/.goga/memory/architecture.md index cce4c445..3b17310a 100644 --- a/.goga/memory/architecture.md +++ b/.goga/memory/architecture.md @@ -1,10 +1,5 @@ # Project rules -## Core-anchored invariants - -Guarantees that must hold for every caller are specified and enforced in the core domain contracts, never at a single -entry point; a rule guarded inside one command counts as unenforced, because every other caller could bypass it. - ## Dependency edges target the owner's facade and respect fixed direction All interaction with a subsystem's capabilities — code dependencies and documentation alike — targets the owning unit's @@ -18,10 +13,18 @@ fixed direction puts a capability out of reach, the fallback is a consumer-side ## Single access zone per external system -All operations that reach one external system inside a domain belong to exactly one structural unit. New capabilities -extend that unit's zone instead of spawning a parallel sibling — even when the extension forces an exception to the -zone's established invariants. Extending a zone never rewrites already published contract fragments: their invariants -stay verbatim, and every new allowance is recorded only in the fragments of the new elements. +All operations that reach one external system inside a domain belong to exactly one dedicated leaf unit that owns the +access, mirrors the structure of the existing access leaves, exposes a minimal public surface, and is consumed only +through the domain facade; when the access happens and with what content remains the responsibility of consumer +orchestrations. New capabilities extend that unit's zone instead of spawning a parallel sibling — even when the +extension forces an exception to the zone's established invariants. Extending a zone never rewrites already published +contract fragments: their invariants stay verbatim, and every new allowance is recorded only in the fragments of the +new elements. + +## Core-anchored invariants + +Guarantees that must hold for every caller are specified and enforced in the core domain contracts, never at a single +entry point; a rule guarded inside one command counts as unenforced, because every other caller could bypass it. ## Additive regression-free extension @@ -32,26 +35,40 @@ site stays valid without edits. Migrating existing functionality onto a new plat near-rename: domain objects move unchanged, and only the source of registrations changes (the cell emits the platform's action instead of running its own enumeration mechanism). -## Specialization lives with the consumer +## Mechanism-agnostic contracts -When a domain needs its own variant of a shared capability, the variant is created inside the consumer's zone. A -provider's internal units are never extended to serve one specific consumer — misplacement distorts the ownership map, -and moving code after materialization is a full migration. +Contracts express only the abstract order of actions through references to practices and types. Concrete mechanisms, +tool choices, and lifecycle detail are fixed in separate project-level practice documents with executable guidance, +never inside contract annotations. ## Layered responsibility for external inputs -The dependency on external configuration lives at the boundary layer: it resolves source precedence — explicit argument -over configuration over built-in default — and passes primitive values inward, keeping inner layers free of -configuration coupling and independently testable. The value provider performs structural validation only (type and -shape), stores values verbatim, embeds no defaults, and checks no semantics — semantic interpretation and defaulting -belong to the consumer. +Environment coupling lives at the boundary layer, never in the domain core. The boundary layer resolves external +inputs — source precedence of explicit argument over configuration over built-in default — and passes primitive values +inward; interactive prompting and terminal-capability handling belong to the outer command layer, keeping the domain +core usable from non-interactive callers and inner layers independently testable. The domain core exposes +all-or-nothing read-only resolution with clean errors and mutation routines that run unconditionally once the caller +has confirmed. The value provider performs structural validation only (type and shape), stores values verbatim, +embeds no defaults, and checks no semantics — semantic interpretation and defaulting belong to the consumer. ## Decisions before mutations, with compensating rollback -Orchestrating algorithms order every read-only check and validation before the first state change. When a started -mutation sequence fails, every performed mutation is rolled back, exactly one clean error with the root cause is -reported, and a repeated invocation stays safe. Rollback mechanisms belong to the access layer; the decision to roll -back belongs to the caller. +Orchestrating algorithms order every read-only check and validation before the first state change. Before any +irreversible step of a multi-step mutation, the state needed to undo it is captured; when a later step fails, prior +effects are restored by composing existing primitives, exactly one clean error with the root cause is reported, and a +repeated invocation stays safe. The rollback is scoped to the failed sequence — work completed outside it deliberately +remains. Rollback mechanisms belong to the access layer; the decision to roll back belongs to the caller. + +## Fix-in-place verification gates + +Defects surfaced by verification are repaired in the artifact itself, and the complete check suite is re-run to green +before approval. Approving with known breakage and deferring the repair to a later stage is rejected. + +## Specialization lives with the consumer + +When a domain needs its own variant of a shared capability, the variant is created inside the consumer's zone. A +provider's internal units are never extended to serve one specific consumer — misplacement distorts the ownership map, +and moving code after materialization is a full migration. ## Mode-based safety of destructive operations @@ -66,20 +83,6 @@ A process stage produces only its designated artifact type; transformations belo A planning stage does not modify implementation artifacts — materialization belongs to the next stage. Mixing planning with materialization destroys the workflow's guarantees: unreviewed code changes without an approved plan. -## ADR revision instead of silent deviation - -When implementation reveals that a settled ADR is redundant, the ADR's guarantee is restated through another means -rather than obeyed blindly or violated silently: the revision is explicitly recorded in the plan, the original intent is -preserved by a different mechanism (e.g. a checkpoint contract holding the guarantee instead of an explicit build step), -and routines made redundant by the revision are abolished. Neither letter-following against discovered redundancy nor -unrecorded deviation is acceptable. - -## Deferred assembly on first use - -A run-scoped registry is created empty and cheap and is assembled at most once, at the first event emission or -inspection. The guarantee that assembly happens before any output or state change is held by the checkpoint contract ( -events are emitted before any output or state mutation), not by requiring an explicit assembly step in every command. - ## Closed binding of names in a contract Every name declared as an imported dependency must be referenced within the contract's own text, and every mention must @@ -94,6 +97,14 @@ An operation's name states its exact coverage — never broader than what it doe local-only operation), never narrower. Scope inaccuracy in a name is a contract defect; a rename is applied across all already produced artifacts so that stages never disagree on names. +## ADR revision instead of silent deviation + +When implementation reveals that a settled ADR is redundant, the ADR's guarantee is restated through another means +rather than obeyed blindly or violated silently: the revision is explicitly recorded in the plan, the original intent is +preserved by a different mechanism (e.g. a checkpoint contract holding the guarantee instead of an explicit build step), +and routines made redundant by the revision are abolished. Neither letter-following against discovered redundancy nor +unrecorded deviation is acceptable. + ## One document — one behavior domain Consumer documentation is structured by behavior domain: a new domain gets its own self-contained document, documents of From 79c283fc4d50c779535b728e28221d8411cca570 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 14:09:45 +0300 Subject: [PATCH 198/229] feat: support todo in bugfix and patch pipeline --- goga/assets/pipelines/bugfix.yml | 2 ++ goga/assets/pipelines/patch.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/goga/assets/pipelines/bugfix.yml b/goga/assets/pipelines/bugfix.yml index c96aee7c..89780613 100644 --- a/goga/assets/pipelines/bugfix.yml +++ b/goga/assets/pipelines/bugfix.yml @@ -6,6 +6,8 @@ description: "Bug fixing process" title: "Root-cause analysis and resolution" communication: true prompt: | + Use the TODO file at the path printed by `goga history path -f todo.md`, if it exists. + Cell convention: - When changing CODEMANIFEST and usage files, you **MUST** ensure the current state, **NOT** the changelogs. - Keep algorithms, requirements, constraints and usages succinct and to the point. Use clear bullet points and focus strictly on the required outcomes. diff --git a/goga/assets/pipelines/patch.yml b/goga/assets/pipelines/patch.yml index 934717e0..8ae5e188 100644 --- a/goga/assets/pipelines/patch.yml +++ b/goga/assets/pipelines/patch.yml @@ -6,6 +6,8 @@ description: "Refactoring or miniman changes process" title: "Formalize the task, make the plan and implement it" communication: true prompt: | + Use the TODO file at the path printed by `goga history path -f todo.md`, if it exists. + Cell convention: - When changing CODEMANIFEST and usage files, you **MUST** ensure the current state, **NOT** the changelogs. - Keep algorithms, requirements, constraints and usages succinct and to the point. Use clear bullet points and focus strictly on the required outcomes. From 0066702eba1cb7fb7d4995b650ecdc4ae537427d Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 18:28:13 +0000 Subject: [PATCH 199/229] feat: unify history year addressing with group -y option in contracts --- goga/commands/CODEMANIFEST | 2 +- .../history/.usages/history-command.md | 68 ++++++++---- goga/commands/history/CODEMANIFEST | 101 ++++++++++-------- goga/history/.usages/history-tree.md | 19 +++- goga/history/CODEMANIFEST | 43 +++++--- 5 files changed, 145 insertions(+), 88 deletions(-) diff --git a/goga/commands/CODEMANIFEST b/goga/commands/CODEMANIFEST index c4bf2884..9192f45a 100644 --- a/goga/commands/CODEMANIFEST +++ b/goga/commands/CODEMANIFEST @@ -79,7 +79,7 @@ Annotations: | command: the four modes, the post-install hooks, and the exit codes. Use the `history-command` practice for consumer scenarios of the history - command group: the four subcommands, their options and filters, the path + command group: the five subcommands, their options and filters, the path and ensure behaviors, and the exit codes. Use the `topics-command` practice for consumer scenarios of the topics diff --git a/goga/commands/history/.usages/history-command.md b/goga/commands/history/.usages/history-command.md index 94ec6280..2d7a3caa 100644 --- a/goga/commands/history/.usages/history-command.md +++ b/goga/commands/history/.usages/history-command.md @@ -6,70 +6,90 @@ operators. A topic value can always be given as a branch name (`release/1.3.0`) or as a slug (`release-1-3-0`) — both address `.goga/history/<year>/release-1-3-0/`. +## Year addressing + +The year is addressed by the group option `-y/--year` — given once, before +the subcommand, and shared by every subcommand: + + goga history -y 2025 status + goga history --year 2025 ensure Feature/Foo_Bar + +- Without the option (and an empty value counts as absent) every subcommand + works with the current calendar year — except `list`, which prints the + full tree. +- The value is a plain string; four digits is the documented grammar of a + history year. `status`, `prune`, and `list` with a year missing from the + tree print nothing and exit 0; `path` composes the path regardless — + it never checks existence. +- A year option after the subcommand (`goga history status -y 2025`) is a + usage error; so are a positional YEAR and any other year form. + ## goga history list -Prints the inventory tree — every year with its topics. No statuses, no -artifacts. +Prints the inventory tree — every year with its topics, or the scoped year +alone. No statuses, no artifacts. 2026/ └── add-ref-for-review └── history-commands - Read-only. An empty history prints nothing, exit 0. +- `goga history -y 2025 list` prints the 2025 section alone, same shape. -## goga history status [YEAR] [-t TOPIC] [-s STATUS] +## goga history status [-t TOPIC] [-s STATUS] goga history status - goga history status 2025 + goga history -y 2025 status goga history status --topic release goga history status -s done -s mkdocs.published -Prints one line per topic: the slug and every maximal status in brackets, -in scale order — for example "release-1-3-0 [done] [mkdocs.published]". -Status filters take qualified status names: built-in names bare, tool -statuses as <tool>.<name>; a record matches when any of its maximal -statuses is one of the requested names. An unknown name is a clean error. -`-t/--topic` keeps the topics whose slug contains the normalized filter -as a substring; it combines with `-s/--status` by AND, and a filter that -normalizes to an empty slug is a clean error. -The year is never printed; an empty result prints nothing and exits 0. +Prints one line per topic of the scoped year: the slug and every maximal +status in brackets, in scale order — for example "release-1-3-0 [done] +[mkdocs.published]". Status filters take qualified status names: built-in +names bare, tool statuses as <tool>.<name>; a record matches when any of +its maximal statuses is one of the requested names. An unknown name is a +clean error. `-t/--topic` keeps the topics whose slug contains the +normalized filter as a substring; it combines with `-s/--status` by AND, +and a filter that normalizes to an empty slug is a clean error. The year +is never printed; an empty result prints nothing and exits 0. -## goga history path [TOPIC] [-f FILENAME] [-y YEAR] +## goga history path [TOPIC] [-f FILENAME] -Prints one path — and nothing else — to stdout. Nothing is created. +Prints one path of the scoped year — and nothing else — to stdout. Nothing +is created. goga history path # topic dir of the current branch goga history path -f plan.md # …/plan.md of the current branch - goga history path release/1.3.0 -f plan.md # explicit topic (branch name ok) - goga history path -y 2025 # another year + goga history path release/1.3.0 -f plan.md # explicit topic (branch name ok) + goga history -y 2025 path # another year - Without `TOPIC` the current git branch names the topic. No branch (not a repository, detached HEAD, git missing) → clean error, non-zero exit. - `-f/--file` — an artifact filename with an extension; without the flag the topic directory is printed. A filename without an extension is an error. -- `-y/--year` — four digits; defaults to the current year. - Scripting pattern: `plan=$(goga history path -f plan.md)`. ## goga history ensure [NAME] -Creates the topic directory of the current year — idempotently. +Creates the topic directory of the scoped year — idempotently. goga history ensure # topic of the current branch goga history ensure Feature/Foo_Bar # → .goga/history/<year>/feature-foo-bar + goga history -y 2025 ensure fix-x # → .goga/history/2025/fix-x - An existing topic directory is a success, not a conflict. Occupancy checks belong to the caller. - Prints nothing on stdout; the exit code carries the result. -## goga history prune [YEAR] [--dry-run] +## goga history prune [--dry-run] -Deletes the orphan topics of one year — the topics no branch of the +Deletes the orphan topics of the scoped year — the topics no branch of the repository inventory hosts — and prints one slug per line. Nothing else is printed; an empty result prints nothing and exits 0. goga history prune --dry-run # list the candidates, delete nothing goga history prune # current year, delete the orphans - goga history prune 2025 # an explicit year + goga history -y 2025 prune # an explicit year - Protection: a local branch or a remote-tracking ref whose short name normalizes to the topic slug protects the topic — in every year. @@ -84,4 +104,6 @@ Every failure is a clean message on stderr with a non-zero exit and no fallback values: git unavailable / not a repository / detached HEAD, a topic that normalizes to an empty slug, a filename without an extension, an unknown status name, a tool package of the status scale that fails to -import (status), a topic directory that cannot be deleted (prune). +import (status), a topic directory that cannot be deleted (prune). Year +forms other than the group option — a positional YEAR, a year option after +the subcommand — are usage errors (non-zero exit, no traceback). diff --git a/goga/commands/history/CODEMANIFEST b/goga/commands/history/CODEMANIFEST index a0e8cfd3..529df2d5 100644 --- a/goga/commands/history/CODEMANIFEST +++ b/goga/commands/history/CODEMANIFEST @@ -31,73 +31,81 @@ Annotations: | - Understanding the general principles and rules of development and testing in the project Use the `click` practice to build the history command group: the group - decorator, the subcommand registration, the options and arguments of each - subcommand (the optional positional, the long/short option pairs, the - repeatable -s/--status option via multiple=True), echo, and exit-code + decorator with its year option, the subcommand registration, the + arguments and options of each subcommand (the long/short option pairs, + the repeatable -s/--status option via multiple=True), the state + handover from the group to the subcommands, echo, and exit-code propagation. This cell is the CLI surface of the history domain: a thin wrapper that resolves inputs, delegates every computation to the domain routines, and renders the results. No path building, no slug grammar, no status - resolution, and no tree walking live here. Domain errors surface as clean - CLI errors (stderr, non-zero exit, no traceback) — no fallback topic names - and no silent skips. Use relative imports. + resolution, and no tree walking live here. The year is addressed by the + group option alone — the subcommands carry no year surfaces of their + own. Domain errors surface as clean CLI errors (stderr, non-zero exit, + no traceback) — no fallback topic names and no silent skips. Use + relative imports. --- -"history()": +"history(year: str | None = None)": location: history.py annotations: | The goga history command group — a click.Group container for the history subcommands. Exported via __all__ and registered in the root application - group. The group carries no options of its own — every subcommand owns - its arguments and options. + group. The group carries the year scope every subcommand shares. - Use the `click` practice for the group decorator and the subcommand - registration. + `year`: the -y/--year group option — exactly one year, four digits the + recognized form; None and the empty string mean no year given; + the option is passed before the subcommand and applies to every + subcommand of the group + + Use the `click` practice for the group decorator, the group option, + and the subcommand registration. Subcommand surfaces: - list — no arguments, no options - - status — an optional YEAR positional, --topic/-t (substring filter), - -s/--status (repeatable status filter) - - path — an optional TOPIC positional, -f/--file FILENAME, --year/-y YYYY + - status — --topic/-t (substring filter), -s/--status (repeatable + status filter) + - path — an optional TOPIC positional, -f/--file FILENAME - ensure — an optional NAME positional - - prune — an optional YEAR positional, --dry-run + - prune — a --dry-run flag - The surfaces carry the CLI names; the Python attribute of a callback - may carry a suffix to avoid shadowing a builtin (list -> list_topics) — - the CLI name is the contract. + The scoped year reaches every subcommand; the CLI stays free of year + validation — the value is a plain string, the domain owns the year + semantics. The subcommand surfaces carry the CLI names; the Python + attribute of a callback may carry a suffix to avoid shadowing a + builtin (list -> list_topics) — the CLI name is the contract. Apply the `convention` CLI command docstring rule for the --help text (rendered verbatim by Click; omit Args/Returns/Raises). methods: "list() -> exit_code: int": | - Subcommand goga history list: print the tree of every history year with - its topics — the inventory view, no statuses and no artifacts. + Subcommand goga history list: print the inventory tree — every year + with its topics when no year is given, the scoped year alone when it + is — no statuses and no artifacts. `exit_code`: 0 on success, 1 on error Apply the `history-tree` practice for the tree contract of the domain. Algorithm: - 1. Collect the full tree via `collect_history_tree` + 1. Collect the tree via `collect_history_tree` with the scoped year 2. Render it via `render_history_tree` 3. An empty tree renders nothing — exit 0 Requirements: - Read-only — nothing is created or written + - A scoped year missing from the tree renders nothing — exit 0 Constraints: - Do not print statuses or artifact names — the list view carries years and topics only - "status(year: str | None = None, topic: str | None = None, statuses: tuple[str, ...]) -> exit_code: int": | - Subcommand goga history status: print the flat list of topics of one - year, each line "topic [status] [status] ...". + "status(topic: str | None = None, statuses: tuple[str, ...]) -> exit_code: int": | + Subcommand goga history status: print the flat list of topics of the + scoped year, each line "topic [status] [status] ...". - `year`: optional YEAR positional — four digits, the recognized form of - a history year; None means the current year; the year is - never printed `topic`: --topic/-t value — a substring filter; the value is normalized via `normalize_topic_slug` before matching `statuses`: -s/--status values (repeatable, multiple=True) — qualified @@ -117,8 +125,8 @@ Annotations: | 2. A `topic` value that normalizes to an empty slug is a clean error (stderr, non-zero exit) — an empty filter would silently match every topic and is rejected instead - 3. Collect the records via `collect_topic_statuses` with `year` and - the assembled scale — the single scale of this command run + 3. Collect the records via `collect_topic_statuses` with the scoped + year and the assembled scale — the single scale of this command run 4. Filter: when `topic` is given, keep the records whose topic contains the normalized filter as a substring; when `statuses` is non-empty, keep the records carrying at least one of the resolved @@ -134,22 +142,22 @@ Annotations: | disables it always - A record matches a status filter when any of its maximal statuses is one of the requested names + - A scoped year with no matching directory yields an empty result — + the domain enumerates four-digit year directories only Constraints: - Do not print the year, a header, or a summary line — one record per line only - Do not treat an empty result as an error - "path(topic: str | None = None, filename: str | None = None, year: str | None = None) -> exit_code: int": | - Subcommand goga history path: print one path of the history tree — and - nothing else. + "path(topic: str | None = None, filename: str | None = None) -> exit_code: int": | + Subcommand goga history path: print one path of the scoped year of the + history tree — and nothing else. `topic`: optional TOPIC positional — a branch name or a slug; None means the current git branch `filename`: -f/--file value — an artifact filename with an extension; without the flag the topic directory is printed - `year`: --year/-y value — four digits, the recognized form of a - history year; None means the current year `exit_code`: 0 on success, 1 on error Apply the `topic-paths` practice for the path contracts of the domain. @@ -158,8 +166,9 @@ Annotations: | 1. Resolve `topic`: the positional when given, otherwise the current branch via `resolve_current_branch_name`; an undetermined branch is a clean error (stderr, non-zero exit) - 2. `filename` given -> resolve the file path via `resolve_topic_file`; - otherwise resolve the topic directory via `resolve_topic_dir` + 2. `filename` given -> resolve the file path via `resolve_topic_file` + with the scoped year; otherwise resolve the topic directory via + `resolve_topic_dir` with the scoped year 3. Echo the resolved path to stdout — exactly one line, nothing else Requirements: @@ -173,7 +182,7 @@ Annotations: | "ensure(name: str | None = None) -> exit_code: int": | Subcommand goga history ensure: create the topic directory of the - current year. + scoped year. `name`: optional NAME positional — a branch name or a slug; None means the current git branch @@ -186,7 +195,8 @@ Annotations: | 1. Resolve `name`: the positional when given, otherwise the current branch via `resolve_current_branch_name`; an undetermined branch is a clean error (stderr, non-zero exit) - 2. Create the directory via `ensure_topic_dir` — idempotently + 2. Create the directory via `ensure_topic_dir` with `name` and the + scoped year — idempotently 3. Exit 0 Requirements: @@ -198,14 +208,10 @@ Annotations: | belongs to the caller - Do not create artifact files inside the directory - "prune(year: str | None = None, dry_run: bool = False) -> exit_code: int": | - Subcommand goga history prune: delete the orphan topics of one year — - the topics no branch of the repository inventory hosts. + "prune(dry_run: bool = False) -> exit_code: int": | + Subcommand goga history prune: delete the orphan topics of the scoped + year — the topics no branch of the repository inventory hosts. - `year`: optional YEAR positional — four digits, the recognized form of - a history year; None means the current year; a year with no - matching directory of the tree yields an empty result — the - domain enumerates four-digit year directories only `dry_run`: the --dry-run flag — list the deletion candidates without deleting anything `exit_code`: 0 on success (an empty result included), 1 on error @@ -215,7 +221,8 @@ Annotations: | Apply the `click` practice for the flag and echo. Algorithm: - 1. Run the orphan cleanup via `prune_topics` with `year` and `dry_run` + 1. Run the orphan cleanup via `prune_topics` with the scoped year and + `dry_run` 2. A domain ValueError surfaces as a clean CLI error; a git CalledProcessError surfaces as git failed, a missing git binary as git is not available, and an OSError of the deletion as @@ -226,6 +233,8 @@ Annotations: | Requirements: - The deletion is irreversible — the dry pass is the safe preview - Only stdout carries the slug list — nothing else is printed + - A scoped year with no matching directory yields an empty result — + the domain enumerates four-digit year directories only Constraints: - Do not ask for confirmation — the dry pass is the safety tool diff --git a/goga/history/.usages/history-tree.md b/goga/history/.usages/history-tree.md index 48c6a5f9..fe727278 100644 --- a/goga/history/.usages/history-tree.md +++ b/goga/history/.usages/history-tree.md @@ -1,6 +1,6 @@ # history — year and topic inventory -How to walk the whole `.goga/history/` tree with the `goga.history` facade. +How to walk the `.goga/history/` tree with the `goga.history` facade. For consumers that inventory history: CLI list output, audits, cleanups. ## Collecting the full tree @@ -15,9 +15,20 @@ for year_record in tree: print(topic) ``` -- One `HistoryYear` per year, sorted by year ascending; topics within a year - sorted alphabetically. +## Collecting one year + +```python +from goga.history import collect_history_tree + +tree = collect_history_tree(year="2025") # one section only +for year_record in tree: + ... # exactly one HistoryYear when the year exists +``` + +- One `HistoryYear` per year, sorted by year ascending; topics within a + year sorted alphabetically. - A year directory is a directory named with exactly four digits; anything else in the history root is ignored. Only directories count as topics. -- An absent history root yields an empty list — not an error. +- An absent history root — or a year missing from the tree — yields an empty + list, not an error. - The tree carries names only: no statuses, no artifact lists. diff --git a/goga/history/CODEMANIFEST b/goga/history/CODEMANIFEST index 53fb621a..cdea7b20 100644 --- a/goga/history/CODEMANIFEST +++ b/goga/history/CODEMANIFEST @@ -126,7 +126,8 @@ Annotations: | Compute the directory path of a history topic. `topic`: topic input — a branch name or an already-normalized slug - `year`: optional year as four digits; None means the current year + `year`: optional year as four digits; None and the empty string mean + the current year `topic_dir`: the topic directory path .goga/history/<year>/<slug>/ Apply the `convention` practice for docstring style and intra-package imports. @@ -154,7 +155,8 @@ Annotations: | `topic`: topic input — a branch name or an already-normalized slug `filename`: artifact filename — arbitrary, must carry an extension - `year`: optional year as four digits; None means the current year + `year`: optional year as four digits; None and the empty string mean + the current year `file_path`: the artifact file path .goga/history/<year>/<slug>/<filename> Apply the `convention` practice for docstring style and intra-package imports. @@ -181,7 +183,8 @@ Annotations: | Decide whether a history topic already exists for the year. `topic`: topic input — a branch name or an already-normalized slug - `year`: optional year as four digits; None means the current year + `year`: optional year as four digits; None and the empty string mean + the current year `exists`: True when the topic directory exists Apply the `convention` practice for docstring style and intra-package imports. @@ -204,7 +207,8 @@ Annotations: | Create the directory of a history topic of a year. `name`: topic input — a branch name or an already-normalized slug - `year`: optional year as four digits; None means the current year + `year`: optional year as four digits; None and the empty string mean + the current year `topic_dir`: the topic directory path that now exists Apply the `convention` practice for docstring style and intra-package imports. @@ -230,7 +234,8 @@ Annotations: | Delete the directory of a history topic of a year. `name`: topic input — a branch name or an already-normalized slug - `year`: optional year as four digits; None means the current year + `year`: optional year as four digits; None and the empty string mean + the current year `removed`: True when the topic directory existed and was deleted, False when it was absent @@ -305,7 +310,8 @@ Annotations: | annotations: | Collect every topic of one year with its maximal present statuses. - `year`: optional year as four digits; None means the current year + `year`: optional year as four digits; None and the empty string mean + the current year `scale`: optional assembled status scale; None assembles it once here `records`: one `TopicRecord` per topic, sorted alphabetically by topic @@ -346,27 +352,36 @@ Annotations: | "topics -> list[str]": | The topic slugs found under the year, sorted alphabetically. -"collect_history_tree() -> tree: list[HistoryYear]": +"collect_history_tree(year: str | None = None) -> tree: list[HistoryYear]": location: tree.py annotations: | - Collect the full history tree — every year with its topics. + Collect the history tree — every year with its topics, or the one + named year alone. - `tree`: one `HistoryYear` per year, sorted by year ascending + `year`: optional year as four digits; None and the empty string mean + no year selection — the full tree; a year missing from the + tree yields an empty list — not an error + `tree`: one `HistoryYear` per selected year, sorted by year ascending Apply the `convention` practice for docstring style and intra-package imports. Algorithm: - 1. List the year directories of the history root - 2. For each year, list its topic directories - 3. Assemble one `HistoryYear` per year — topics sorted alphabetically, - years sorted ascending - 4. Return the assembled tree + 1. Resolve the year selection — `year` when it names a year, + otherwise no selection + 2. List the year directories of the history root + 3. Keep only the selected year directory when a selection exists + 4. For each kept year, list its topic directories + 5. Assemble one `HistoryYear` per kept year — topics sorted + alphabetically, years sorted ascending + 6. Return the assembled tree Requirements: - A year directory is a directory named with exactly four digits — anything else is ignored - Only directories count as topics — stray files are ignored - An absent history root yields an empty list — not an error + - A selected year absent from the tree yields an empty list — not + an error Constraints: - Do not compute statuses — the tree carries topic names only From 43ce4228f0a23261a0f3f745824999a90bcfefd1 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 18:28:18 +0000 Subject: [PATCH 200/229] chore: bump AFM_VERSION to 0.5.64 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 9fc8d860..30516aef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG AFM_VERSION=0.5.63 +ARG AFM_VERSION=0.5.64 ARG RALPHEX_VERSION=1.6 ARG PYTHON_VERSION=3.12 ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 From 79a60f37cd5b26678504e830b1ad1d554bc798df Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 18:31:40 +0000 Subject: [PATCH 201/229] feat: add optional year parameter with selected-year filter to collect_history_tree --- goga/history/tree.py | 37 ++++++++++++++--------- tests/history/test_tree.py | 60 ++++++++++++++++++++++++++++++++++---- 2 files changed, 79 insertions(+), 18 deletions(-) diff --git a/goga/history/tree.py b/goga/history/tree.py index 4ae0e0f1..94ec4e6a 100644 --- a/goga/history/tree.py +++ b/goga/history/tree.py @@ -1,9 +1,10 @@ """History tree inventory for the history domain. The entities declared in the cell CODEMANIFEST with ``location: tree.py``: -the per-year record of the tree listing and the full-tree collector. The -collector is read-only and carries names only — statuses belong to the -status module, filtering and rendering to the consumer. +the per-year record of the tree listing and the tree collector — the full +tree or one selected year. The collector is read-only and carries names +only — statuses belong to the status module, filtering and rendering to the +consumer. """ from __future__ import annotations @@ -28,32 +29,42 @@ class HistoryYear: topics: list[str] -def collect_history_tree() -> list[HistoryYear]: - """Collect the full history tree — every year with its topics. +def collect_history_tree(year: str | None = None) -> list[HistoryYear]: + """Collect the history tree — every year with its topics, or the one named year alone. A year directory is a directory named with exactly four ASCII digits — anything else in the history root is ignored (the ASCII filter matters: some non-ASCII digit strings still satisfy ``str.isdigit()``). Only directories count as topics; stray files are ignored on both levels. + Args: + year: Optional year as four digits; ``None`` and the empty string + mean no year selection — the full tree; a year missing from the + tree yields an empty list — not an error. + Returns: - One ``HistoryYear`` per year — years sorted ascending, topics within - a year sorted alphabetically. An absent history root yields an empty - list, not an error. Read-only — nothing is created, and no status is - computed: the tree carries topic names only. + One ``HistoryYear`` per selected year — years sorted ascending, + topics within a year sorted alphabetically. An absent history root + yields an empty list, not an error. Read-only — nothing is created, + and no status is computed: the tree carries topic names only. """ root = _history_root() if not root.is_dir(): return [] + selected = year or None years = sorted( path.name for path in root.iterdir() - if path.is_dir() and len(path.name) == _YEAR_NAME_LENGTH and path.name.isascii() and path.name.isdigit() + if path.is_dir() + and len(path.name) == _YEAR_NAME_LENGTH + and path.name.isascii() + and path.name.isdigit() + and (selected is None or path.name == selected) ) return [ HistoryYear( - year=year, - topics=sorted(entry.name for entry in (root / year).iterdir() if entry.is_dir()), + year=year_name, + topics=sorted(entry.name for entry in (root / year_name).iterdir() if entry.is_dir()), ) - for year in years + for year_name in years ] diff --git a/tests/history/test_tree.py b/tests/history/test_tree.py index e23b771b..5f0a0d0b 100644 --- a/tests/history/test_tree.py +++ b/tests/history/test_tree.py @@ -2,10 +2,12 @@ ``goga/history/CODEMANIFEST`` with ``location: tree.py``: - ``HistoryYear(year: str, topics: list[str])`` -- ``collect_history_tree() -> tree: list[HistoryYear]`` +- ``collect_history_tree(year: str | None = None) -> tree: list[HistoryYear]`` The collector is read-only with respect to the filesystem and carries names -only — no statuses are resolved and the clock is never read. Filesystem +only — no statuses are resolved and the clock is never read. The year +selection is optional: ``None`` and the empty string mean no selection — +the full tree; a year missing from the tree yields an empty list. Filesystem fixtures use ``tmp_path`` + ``monkeypatch.chdir``; no mocks are needed. """ @@ -54,11 +56,17 @@ def test_history_year_is_frozen_kw_only_dataclass(self) -> None: HistoryYear("2026", []) # type: ignore[misc] def test_collect_history_tree_signature(self) -> None: - """``collect_history_tree() -> list[HistoryYear]`` — no parameters.""" + """``collect_history_tree(year: str | None = None) -> list[HistoryYear]``.""" signature = inspect.signature(collect_history_tree) - assert list(signature.parameters) == [] + assert list(signature.parameters) == ["year"] + assert signature.parameters["year"].default is None hints = typing.get_type_hints(collect_history_tree) - assert hints == {"return": list[HistoryYear]} + assert hints == {"year": str | None, "return": list[HistoryYear]} + + def test_collect_history_tree_call_without_arguments_is_valid(self) -> None: + """The no-argument call stays valid — ``goga/topics/_disk_slugs`` relies on it.""" + signature = inspect.signature(collect_history_tree) + signature.bind() # no TypeError — every parameter carries a default # --- Logic tests --- @@ -87,3 +95,45 @@ def test_collect_history_tree_absent_root_empty(self, tmp_path: Path, monkeypatc monkeypatch.chdir(tmp_path) assert collect_history_tree() == [] assert not (tmp_path / ".goga").exists() + + def test_collect_history_tree_scoped_year_returns_single_section( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A selected year yields exactly that year's section — the other years stay out.""" + monkeypatch.chdir(tmp_path) + root = tmp_path / ".goga" / "history" + (root / "2025" / "b-topic").mkdir(parents=True) + (root / "2025" / "a-topic").mkdir() + (root / "2026" / "history-commands").mkdir(parents=True) + (root / "backups").mkdir() + (root / "notes.md").write_text("not a year", encoding="utf-8") + assert collect_history_tree(year="2025") == [HistoryYear(year="2025", topics=["a-topic", "b-topic"])] + + def test_collect_history_tree_scoped_missing_year_empty( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A selected year missing from the tree yields an empty list — not an error.""" + monkeypatch.chdir(tmp_path) + root = tmp_path / ".goga" / "history" + (root / "2026" / "feat-x").mkdir(parents=True) + assert collect_history_tree(year="2099") == [] + + def test_collect_history_tree_empty_string_is_no_selection( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``year=""`` means no selection — the full tree, identical to the no-argument call.""" + monkeypatch.chdir(tmp_path) + root = tmp_path / ".goga" / "history" + (root / "2025" / "feat-a").mkdir(parents=True) + (root / "2026" / "feat-b").mkdir(parents=True) + tree = collect_history_tree(year="") + assert [year_record.year for year_record in tree] == ["2025", "2026"] + + def test_collect_history_tree_scoped_non_four_digit_value_empty( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A selection off the year grammar matches no year directory — an empty list, not an error.""" + monkeypatch.chdir(tmp_path) + root = tmp_path / ".goga" / "history" + (root / "2026" / "feat-x").mkdir(parents=True) + assert collect_history_tree(year="20a6") == [] From b93ab3db555b030c3a31b8fd53687a2f996d80ff Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 18:32:42 +0000 Subject: [PATCH 202/229] feat: scope prune_topics year selection to collect_history_tree(resolved_year) --- goga/history/prune.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/goga/history/prune.py b/goga/history/prune.py index f5e995ab..9578e721 100644 --- a/goga/history/prune.py +++ b/goga/history/prune.py @@ -85,11 +85,8 @@ def prune_topics(year: str | None = None, dry_run: bool = False) -> list[str]: — the caller wraps it). """ resolved_year = year or current_year() - year_topics: list[str] = [] - for history_year in collect_history_tree(): - if history_year.year == resolved_year: - year_topics = history_year.topics - break + tree = collect_history_tree(resolved_year) + year_topics: list[str] = tree[0].topics if tree else [] hosted = { normalize_topic_slug(ref.name.partition("/")[2] if ref.remote else ref.name) for ref in list_branch_refs() } From c5073a4fda6cb90fd3d7796872a5b5d2097d8a26 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 18:34:03 +0000 Subject: [PATCH 203/229] feat: align year docstrings of six history routines with contract --- goga/history/paths.py | 10 +++++----- goga/history/status.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/goga/history/paths.py b/goga/history/paths.py index f2db7a25..bf925a31 100644 --- a/goga/history/paths.py +++ b/goga/history/paths.py @@ -46,7 +46,7 @@ def resolve_topic_dir(topic: str, year: str | None = None) -> Path: Args: topic: Topic input — a branch name or an already-normalized slug. - year: Optional year as four digits; ``None`` means the current year. + year: Optional year as four digits; ``None`` and the empty string mean the current year. Returns: The topic directory path ``.goga/history/<year>/<slug>/`` — relative @@ -75,7 +75,7 @@ def resolve_topic_file(topic: str, filename: str, year: str | None = None) -> Pa Args: topic: Topic input — a branch name or an already-normalized slug. filename: Artifact filename — arbitrary, must carry an extension. - year: Optional year as four digits; ``None`` means the current year. + year: Optional year as four digits; ``None`` and the empty string mean the current year. Returns: The artifact file path ``.goga/history/<year>/<slug>/<filename>`` — @@ -99,7 +99,7 @@ def topic_exists(topic: str, year: str | None = None) -> bool: Args: topic: Topic input — a branch name or an already-normalized slug. - year: Optional year as four digits; ``None`` means the current year. + year: Optional year as four digits; ``None`` and the empty string mean the current year. Returns: True when the topic directory exists, otherwise False. @@ -116,7 +116,7 @@ def ensure_topic_dir(name: str, year: str | None = None) -> Path: Args: name: Topic input — a branch name or an already-normalized slug. - year: Optional year as four digits; ``None`` means the current year. + year: Optional year as four digits; ``None`` and the empty string mean the current year. Returns: The topic directory path that now exists. @@ -145,7 +145,7 @@ def remove_topic_dir(name: str, year: str | None = None) -> bool: Args: name: Topic input — a branch name or an already-normalized slug. - year: Optional year as four digits; ``None`` means the current year. + year: Optional year as four digits; ``None`` and the empty string mean the current year. Returns: True when the topic directory existed and was deleted, False when diff --git a/goga/history/status.py b/goga/history/status.py index 3bf6fc80..26cb3422 100644 --- a/goga/history/status.py +++ b/goga/history/status.py @@ -72,8 +72,8 @@ def collect_topic_statuses( """Collect every topic of one year with its maximal present statuses. Args: - year: Optional year as four digits; ``None`` (or the empty string an - empty CLI value produces) means the current year. + year: Optional year as four digits; ``None`` and the empty string + mean the current year. scale: Optional assembled status scale; ``None`` assembles it once here. From 394c9e9d10c33f972ec509ad692f2de8aab931b4 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 18:41:31 +0000 Subject: [PATCH 204/229] feat: scope history group year to a single -y option via _HistoryScope --- goga/commands/history/history.py | 138 ++++++++++-------- tests/commands/history/test_history.py | 137 ++++++++++++----- .../commands/history/test_history_command.py | 47 +++--- tests/integration/test_topic_workflows.py | 10 +- 4 files changed, 202 insertions(+), 130 deletions(-) diff --git a/goga/commands/history/history.py b/goga/commands/history/history.py index 9535f12b..9f99b73d 100644 --- a/goga/commands/history/history.py +++ b/goga/commands/history/history.py @@ -2,18 +2,22 @@ The click group declared in the cell CODEMANIFEST with ``location: history.py``: the ``list``/``status``/``path``/``ensure``/``prune`` -subcommands over the ``.goga/history/`` tree. The group is a thin wrapper — -it resolves the inputs, delegates every computation to the domain routines -of ``goga.history``, and renders the results through the ``render`` module. -No path building, no slug grammar, and no status resolution live here. -Domain errors surface as clean CLI errors: a ``ValueError`` from the domain -and an undetermined git branch become ``click.ClickException`` (stderr, -exit 1, no traceback) — no fallback topic names, no silent skips. +subcommands over the ``.goga/history/`` tree. The group carries the year +scope every subcommand shares — the ``-y/--year`` option addressed before +the subcommand; the subcommands themselves carry no year surfaces of their +own. The group is a thin wrapper — it resolves the inputs, delegates every +computation to the domain routines of ``goga.history``, and renders the +results through the ``render`` module. No path building, no slug grammar, +no year validation, and no status resolution live here. Domain errors +surface as clean CLI errors: a ``ValueError`` from the domain and an +undetermined git branch become ``click.ClickException`` (stderr, exit 1, +no traceback) — no fallback topic names, no silent skips. """ from __future__ import annotations import subprocess +from dataclasses import dataclass import click @@ -31,6 +35,13 @@ from .render import render_history_tree, render_topic_statuses +@dataclass(kw_only=True) +class _HistoryScope: + """The year scope shared by every subcommand of the group.""" + + year: str | None = None + + def _resolve_topic_input(topic: str | None) -> str: """Resolve a topic input: the positional when given, the branch otherwise. @@ -54,44 +65,48 @@ def _resolve_topic_input(topic: str | None) -> str: @click.group() -def history() -> None: +@click.option( + "--year", + "-y", + default=None, + help="Four-digit year shared by every subcommand (without it: " + "list prints every year, the others take the current year)", +) +@click.pass_context +def history(ctx: click.Context, year: str | None = None) -> None: """Work with the .goga/history/ tree.""" + ctx.ensure_object(_HistoryScope) + ctx.obj.year = year @history.command("list") -@click.pass_context -def list_topics(ctx: click.Context) -> None: - """Print the tree of every history year with its topics. +@click.pass_obj +def list_topics(scope: _HistoryScope) -> None: + """Print the inventory tree — every year with its topics, or the year given via -y/--year alone. The inventory view: one YYYY/ line per year, each topic indented under its year. An empty tree prints nothing. Read-only — nothing is created or written; statuses and artifact names never appear. """ - render_history_tree(collect_history_tree()) - ctx.exit(0) + render_history_tree(collect_history_tree(scope.year)) + click.get_current_context().exit(0) @history.command("status") -@click.argument("year", required=False) @click.option("-t", "--topic", default=None, help="Substring filter on the normalized topic slug.") @click.option("-s", "--status", "statuses", multiple=True, help="Status filter, repeatable (e.g. -s planned).") -@click.pass_context -def status( - ctx: click.Context, - year: str | None = None, - topic: str | None = None, - statuses: tuple[str, ...] = (), -) -> None: +@click.pass_obj +def status(scope: _HistoryScope, topic: str | None = None, statuses: tuple[str, ...] = ()) -> None: """Print the topics of one year, one 'topic [status] [status] …' line each. A topic carries its maximal statuses in scale order — one bracketed - segment per status, tool statuses included. YEAR defaults to the current - year and is never printed. -t/--topic keeps the topics whose slug - contains the normalized filter as a substring; -s/--status keeps the - topics carrying at least one of the requested statuses; both filters - combine by AND. An empty result prints nothing and exits 0 — it is not - an error. The topics come out alphabetically; the domain sorts, this - command does not re-sort. + segment per status, tool statuses included. The year comes from the + group's -y/--year (default: the current year) and is never printed. + -t/--topic keeps the topics whose slug contains the normalized filter + as a substring; -s/--status keeps the topics carrying at least one of + the requested statuses; both filters combine by AND. An empty result + prints nothing and exits 0 — it is not an error. The topics come out + alphabetically; the domain sorts, this command does not re-sort. """ try: scale = assemble_status_scale() @@ -109,14 +124,14 @@ def status( if filter_slug == "": raise click.ClickException(f"topic filter {topic!r} normalizes to an empty topic slug") - records = collect_topic_statuses(year, scale) + records = collect_topic_statuses(scope.year, scale) if topic is not None: records = [record for record in records if filter_slug in record.topic] if statuses: requested = set(statuses) records = [record for record in records if set(record.statuses) & requested] render_topic_statuses(records) - ctx.exit(0) + click.get_current_context().exit(0) @history.command("path") @@ -128,80 +143,75 @@ def status( default=None, help="Print the artifact file path instead of the topic directory.", ) -@click.option("-y", "--year", default=None, help="Four-digit year (default: the current year).") -@click.pass_context -def path( - ctx: click.Context, - topic: str | None = None, - filename: str | None = None, - year: str | None = None, -) -> None: +@click.pass_obj +def path(scope: _HistoryScope, topic: str | None = None, filename: str | None = None) -> None: """Print one path of the history tree — and nothing else. TOPIC defaults to the current git branch (taken raw, as a branch name or a slug). With -f/--file the artifact file path is printed, otherwise the - topic directory; the year defaults to the current one. The path and only - the path — exactly one stdout line, for scripting: - plan=$(goga history path -f plan.md). Nothing is created on disk. + topic directory; the year comes from the group's -y/--year (default: + the current one). The path and only the path — exactly one stdout line, + for scripting: plan=$(goga history path -f plan.md). Nothing is created + on disk. """ resolved_topic = _resolve_topic_input(topic) try: if filename is not None: - resolved_path = resolve_topic_file(resolved_topic, filename, year) + resolved_path = resolve_topic_file(resolved_topic, filename, scope.year) else: - resolved_path = resolve_topic_dir(resolved_topic, year) + resolved_path = resolve_topic_dir(resolved_topic, scope.year) except ValueError as exc: raise click.ClickException(str(exc)) from exc click.echo(resolved_path) - ctx.exit(0) + click.get_current_context().exit(0) @history.command("ensure") @click.argument("name", required=False) -@click.pass_context -def ensure(ctx: click.Context, name: str | None = None) -> None: - """Create the topic directory of the current year, idempotently. +@click.pass_obj +def ensure(scope: _HistoryScope, name: str | None = None) -> None: + """Create the topic directory of the scoped year, idempotently. NAME defaults to the current git branch (taken raw, as a branch name or a slug); parent directories are created as needed and an existing topic - directory is a success, not a conflict. Prints nothing on stdout — the + directory is a success, not a conflict. The year comes from the group's + -y/--year (default: the current one). Prints nothing on stdout — the exit code carries the result. Only directories: no artifact file is created, and occupancy is not reported (deciding whether a topic may be created belongs to the caller). """ resolved_name = _resolve_topic_input(name) try: - ensure_topic_dir(resolved_name) + ensure_topic_dir(resolved_name, scope.year) except ValueError as exc: raise click.ClickException(str(exc)) from exc - ctx.exit(0) + click.get_current_context().exit(0) @history.command("prune") -@click.argument("year", required=False) @click.option( "--dry-run", is_flag=True, default=False, help="List the deletion candidates without deleting anything.", ) -@click.pass_context -def prune(ctx: click.Context, year: str | None = None, dry_run: bool = False) -> None: +@click.pass_obj +def prune(scope: _HistoryScope, dry_run: bool = False) -> None: """Delete the orphan topics of one year — the topics no branch hosts. A local branch or a remote-tracking ref whose short name normalizes to - the topic slug protects it, in every year; every other topic of YEAR is - an orphan and goes. YEAR defaults to the current year — only that year - is touched. Every removed topic is printed as one slug per line, and - nothing else; an empty result prints nothing and exits 0. The deletion - is filesystem-only (no branch, ref, or index of git is touched) and - unconditional — no status protects a topic. It is also irreversible: - the history tree is not in git, so a deleted topic directory cannot be - recovered. Run the command with --dry-run first to preview the - candidates. + the topic slug protects it, in every year; every other topic of the + year is an orphan and goes. The year comes from the group's -y/--year + (default: the current year) — only that year is touched. Every removed + topic is printed as one slug per line, and nothing else; an empty + result prints nothing and exits 0. The deletion is filesystem-only (no + branch, ref, or index of git is touched) and unconditional — no status + protects a topic. It is also irreversible: the history tree is not in + git, so a deleted topic directory cannot be recovered. Run the command + with --dry-run first to preview the candidates. """ try: - removed = prune_topics(year, dry_run) + removed = prune_topics(scope.year, dry_run) except ValueError as exc: raise click.ClickException(str(exc)) from exc except subprocess.CalledProcessError as exc: @@ -215,4 +225,4 @@ def prune(ctx: click.Context, year: str | None = None, dry_run: bool = False) -> raise click.ClickException(f"cannot delete topic directory: {exc}") from exc for slug in removed: click.echo(slug) - ctx.exit(0) + click.get_current_context().exit(0) diff --git a/tests/commands/history/test_history.py b/tests/commands/history/test_history.py index dc11d969..d882dd3f 100644 --- a/tests/commands/history/test_history.py +++ b/tests/commands/history/test_history.py @@ -3,12 +3,15 @@ the ``history`` click group with the ``list``/``status``/``path``/``ensure``/ ``prune`` subcommands. -The group is a thin wrapper: inputs are resolved here, every computation is -delegated to the ``goga.history`` domain, and output goes through the -``render`` module. The logic tests cover the negative paths — domain errors -(``ValueError``), an undetermined git branch, and validation failures must -surface as ``click.ClickException`` (stderr, exit 1, no traceback). The -positive cross-entity scenarios live in ``test_history_command.py``. +The group is a thin wrapper: it carries the shared ``-y/--year`` option, +inputs are resolved here, every computation is delegated to the +``goga.history`` domain, and output goes through the ``render`` module. +The logic tests cover the negative paths — domain errors (``ValueError``), +an undetermined git branch, and validation failures must surface as +``click.ClickException`` (stderr, exit 1, no traceback), while the removed +year forms (a positional YEAR, a year option after the subcommand) are +click's own usage errors (exit 2). The positive cross-entity scenarios +live in ``test_history_command.py``. """ from __future__ import annotations @@ -58,9 +61,30 @@ def test_history_module_binds_domain_prune_topics(self) -> None: """The command module imports the domain cleanup routine at its site.""" assert _history_module.prune_topics is prune_topics - def test_history_group_carries_no_options(self) -> None: - """Every subcommand owns its arguments — the group has none.""" - assert history.params == [] + def test_history_group_carries_only_the_year_option(self) -> None: + """The group owns the shared -y/--year option — and nothing else.""" + assert len(history.params) == 1 + year_option = history.params[0] + assert isinstance(year_option, click.Option) + assert year_option.name == "year" + assert {"-y", "--year"} <= set(year_option.opts) + assert year_option.default is None + + def test_history_group_callback_signature(self) -> None: + """``history(ctx, year)`` — the context and the scoped year.""" + callback = history.callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["ctx", "year"] + assert signature.parameters["year"].default is None + + def test_history_scope_is_a_kw_only_dataclass_with_year(self) -> None: + """``_HistoryScope`` is a kw_only dataclass carrying the year field.""" + scope = _history_module._HistoryScope(year="2025") + assert scope.year == "2025" + assert _history_module._HistoryScope().year is None + # Not frozen — the group assigns the year after ensure_object. + scope.year = None + assert scope.year is None def test_list_topics_does_not_shadow_builtin_list(self) -> None: """The list subcommand callback is named list_topics, not list.""" @@ -68,64 +92,72 @@ def test_list_topics_does_not_shadow_builtin_list(self) -> None: assert not hasattr(_history_module, "list") def test_list_callback_signature(self) -> None: - """``list_topics(ctx)`` — no arguments beyond the click context.""" + """``list_topics(scope)`` — the scope object alone.""" callback = history.commands["list"].callback - assert list(inspect.signature(callback).parameters) == ["ctx"] + signature = inspect.signature(callback) + assert list(signature.parameters) == ["scope"] + hints = typing.get_type_hints(callback) + assert hints == { + "scope": _history_module._HistoryScope, + "return": type(None), + } def test_status_callback_signature(self) -> None: - """``status(ctx, year, topic, statuses)`` with the tuple default ``()``.""" + """``status(scope, topic, statuses)`` with the tuple default ``()``.""" callback = history.commands["status"].callback signature = inspect.signature(callback) - assert list(signature.parameters) == ["ctx", "year", "topic", "statuses"] + assert list(signature.parameters) == ["scope", "topic", "statuses"] assert signature.parameters["statuses"].default == () hints = typing.get_type_hints(callback) assert hints == { - "ctx": click.Context, - "year": str | None, + "scope": _history_module._HistoryScope, "topic": str | None, "statuses": tuple[str, ...], "return": type(None), } def test_path_callback_signature(self) -> None: - """``path(ctx, topic, filename, year)``.""" + """``path(scope, topic, filename)``.""" callback = history.commands["path"].callback signature = inspect.signature(callback) - assert list(signature.parameters) == ["ctx", "topic", "filename", "year"] + assert list(signature.parameters) == ["scope", "topic", "filename"] hints = typing.get_type_hints(callback) assert hints == { - "ctx": click.Context, + "scope": _history_module._HistoryScope, "topic": str | None, "filename": str | None, - "year": str | None, "return": type(None), } def test_ensure_callback_signature(self) -> None: - """``ensure(ctx, name)``.""" + """``ensure(scope, name)``.""" callback = history.commands["ensure"].callback - assert list(inspect.signature(callback).parameters) == ["ctx", "name"] + signature = inspect.signature(callback) + assert list(signature.parameters) == ["scope", "name"] + hints = typing.get_type_hints(callback) + assert hints == { + "scope": _history_module._HistoryScope, + "name": str | None, + "return": type(None), + } def test_prune_callback_signature(self) -> None: - """``prune(ctx, year, dry_run)`` with the declared defaults.""" + """``prune(scope, dry_run)`` with the declared default ``False``.""" callback = history.commands["prune"].callback signature = inspect.signature(callback) - assert list(signature.parameters) == ["ctx", "year", "dry_run"] - assert signature.parameters["year"].default is None + assert list(signature.parameters) == ["scope", "dry_run"] assert signature.parameters["dry_run"].default is False hints = typing.get_type_hints(callback) assert hints == { - "ctx": click.Context, - "year": str | None, + "scope": _history_module._HistoryScope, "dry_run": bool, "return": type(None), } def test_status_options(self) -> None: - """status: optional YEAR positional, -t/--topic, repeatable -s/--status.""" + """status: -t/--topic and repeatable -s/--status — no year surface.""" command = history.commands["status"] - year_argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "year") - assert year_argument.required is False + assert all(param.name != "year" for param in command.params) topic_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "topic") assert "-t" in topic_option.opts assert "--topic" in topic_option.opts @@ -135,16 +167,14 @@ def test_status_options(self) -> None: assert status_option.multiple is True def test_path_options(self) -> None: - """path: optional TOPIC positional, -f/--file, -y/--year.""" + """path: optional TOPIC positional and -f/--file — no year option.""" command = history.commands["path"] + assert all(param.name != "year" for param in command.params) topic_argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "topic") assert topic_argument.required is False file_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "filename") assert "-f" in file_option.opts assert "--file" in file_option.opts - year_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "year") - assert "-y" in year_option.opts - assert "--year" in year_option.opts def test_ensure_argument(self) -> None: """ensure: optional NAME positional.""" @@ -152,11 +182,10 @@ def test_ensure_argument(self) -> None: name_argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "name") assert name_argument.required is False - def test_prune_argument_and_option(self) -> None: - """prune: optional YEAR positional, --dry-run flag.""" + def test_prune_options(self) -> None: + """prune: the --dry-run flag alone — no arguments, no year surface.""" command = history.commands["prune"] - year_argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "year") - assert year_argument.required is False + assert len(command.params) == 1 dry_run_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "dry_run") assert "--dry-run" in dry_run_option.opts assert dry_run_option.is_flag is True @@ -220,10 +249,42 @@ def test_history_ensure_no_branch_fails_cleanly(self) -> None: assert result.stdout == "" +class TestHistoryYearUsageErrors: + def test_history_status_positional_year_is_usage_error(self) -> None: + """status 2025 — the removed positional YEAR is click's usage error.""" + result = CliRunner().invoke(history, ["status", "2025"]) + assert result.exit_code == 2 + assert "Usage" in result.stderr + assert "extra argument" in result.stderr + assert "Traceback" not in result.stderr + + def test_history_prune_positional_year_is_usage_error(self) -> None: + """prune 2025 — the removed positional YEAR is click's usage error.""" + result = CliRunner().invoke(history, ["prune", "2025"]) + assert result.exit_code == 2 + assert "Usage" in result.stderr + assert "extra argument" in result.stderr + assert "Traceback" not in result.stderr + + def test_history_path_year_option_is_usage_error(self) -> None: + """path -y 2025 — the removed local year option is click's usage error.""" + result = CliRunner().invoke(history, ["path", "feat-x", "-y", "2025"]) + assert result.exit_code == 2 + assert "No such option" in result.stderr + assert "Traceback" not in result.stderr + + def test_history_status_year_option_after_subcommand_is_usage_error(self) -> None: + """status -y 2025 — the year option belongs to the group, before the subcommand.""" + result = CliRunner().invoke(history, ["-y", "2026", "status", "-y", "2025"]) + assert result.exit_code == 2 + assert "No such option" in result.stderr + assert "Traceback" not in result.stderr + + @pytest.mark.parametrize( ("argv", "stderr_fragment"), [ - (["status", "2026", "-t", "Релиз"], "empty topic slug"), + (["-y", "2026", "status", "-t", "Релиз"], "empty topic slug"), (["path", "Релиз/Один", "-f", "plan.md"], "empty topic slug"), (["ensure", "Релиз/Один"], "empty topic slug"), ], diff --git a/tests/commands/history/test_history_command.py b/tests/commands/history/test_history_command.py index d97ea3b4..8b575c9b 100644 --- a/tests/commands/history/test_history_command.py +++ b/tests/commands/history/test_history_command.py @@ -101,18 +101,19 @@ def test_history_list_renders_tree(self, tmp_path: Path, monkeypatch: pytest.Mon class TestHistoryStatus: def test_status_signature_defaults(self) -> None: - """``status(year=None, topic=None, statuses=())`` — the declared shape.""" + """``status(scope, topic=None, statuses=())`` — the declared shape.""" callback = history.commands["status"].callback signature = inspect.signature(callback) - assert list(signature.parameters) == ["ctx", "year", "topic", "statuses"] - assert signature.parameters["year"].default is None + assert list(signature.parameters) == ["scope", "topic", "statuses"] + # scope is the pass_obj injection — click supplies it, no default. + assert signature.parameters["scope"].default is inspect.Parameter.empty assert signature.parameters["topic"].default is None assert signature.parameters["statuses"].default == () - def test_history_status_multi_status_line_and_filter( + def test_history_status_scoped_year_collects_that_year( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A registered tool status is maximal and filterable — one segment per status.""" + """The scoped year is the year collected — a registered tool status is maximal and filterable.""" year_dir = tmp_path / ".goga" / "history" / "2026" (year_dir / "release-1-3-0" / "mkdocs").mkdir(parents=True) (year_dir / "release-1-3-0" / "plan.md").write_text("plan\n", encoding="utf-8") @@ -122,7 +123,7 @@ def test_history_status_multi_status_line_and_filter( monkeypatch.chdir(tmp_path) _fake_tool_packages(monkeypatch) - result = CliRunner().invoke(history, ["status", "2026", "-s", "mkdocs.published"]) + result = CliRunner().invoke(history, ["-y", "2026", "status", "-s", "mkdocs.published"]) assert result.exit_code == 0 assert result.output.splitlines() == ["release-1-3-0 [mkdocs.published]"] @@ -157,7 +158,7 @@ def test_history_status_filters_and(self, tmp_path: Path, monkeypatch: pytest.Mo (year_dir / "other" / "prd.md").write_text("prd\n", encoding="utf-8") monkeypatch.chdir(tmp_path) - result = CliRunner().invoke(history, ["status", "2026", "-t", "Release/1.3.0", "-s", "done"]) + result = CliRunner().invoke(history, ["-y", "2026", "status", "-t", "Release/1.3.0", "-s", "done"]) assert result.exit_code == 0 assert result.output.strip() == "release-1-3-0 [done]" @@ -198,7 +199,7 @@ def test_history_status_repeatable_status_filter( (year_dir / "defined-topic" / "prd.md").write_text("prd\n", encoding="utf-8") monkeypatch.chdir(tmp_path) - result = CliRunner().invoke(history, ["status", "2026", "-s", "planned", "-s", "done"]) + result = CliRunner().invoke(history, ["-y", "2026", "status", "-s", "planned", "-s", "done"]) assert result.exit_code == 0 assert result.output.splitlines() == ["done-topic [done]", "planned-topic [planned]"] @@ -216,7 +217,7 @@ def test_history_status_filter_todo_selects_todo_topics( monkeypatch.chdir(tmp_path) monkeypatch.setattr("goga.hooks.tools.packages.packages_distributions", lambda: {}) - result = CliRunner().invoke(history, ["status", "2026", "-s", "todo"]) + result = CliRunner().invoke(history, ["-y", "2026", "status", "-s", "todo"]) assert result.exit_code == 0 assert result.output.splitlines() == ["feat-a [todo]"] @@ -233,7 +234,7 @@ def test_history_status_filter_todo_skips_defined_topics( monkeypatch.chdir(tmp_path) monkeypatch.setattr("goga.hooks.tools.packages.packages_distributions", lambda: {}) - result = CliRunner().invoke(history, ["status", "2026", "-s", "todo"]) + result = CliRunner().invoke(history, ["-y", "2026", "status", "-s", "todo"]) assert result.exit_code == 0 assert result.output == "" @@ -242,7 +243,7 @@ def test_history_status_filter_todo_skips_defined_topics( def test_history_status_filter_new_unknown(self) -> None: """The retired new name is rejected — unknown status, clean error, no collection.""" with mock.patch.object(_history_module, "collect_topic_statuses") as collect_mock: - result = CliRunner().invoke(history, ["status", "2026", "-s", "new"]) + result = CliRunner().invoke(history, ["-y", "2026", "status", "-s", "new"]) assert result.exit_code == 1 assert "unknown status name: 'new'" in result.stderr @@ -287,13 +288,13 @@ def test_history_path_without_file_prints_topic_dir( assert result.output.endswith("\n") assert not (tmp_path / ".goga").exists() - def test_history_path_explicit_topic_and_year( + def test_history_path_scoped_year_composes_that_year( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """path takes an explicit branch-name topic; -y overrides the year.""" + """path takes an explicit branch-name topic; the scoped year composes the path.""" monkeypatch.chdir(tmp_path) - result = CliRunner().invoke(history, ["path", "release/1.3.0", "-f", "plan.md", "-y", "2025"]) + result = CliRunner().invoke(history, ["-y", "2025", "path", "release/1.3.0", "-f", "plan.md"]) expected = str(Path(".goga/history") / "2025" / "release-1-3-0" / "plan.md") assert result.exit_code == 0 @@ -348,15 +349,15 @@ def test_history_prune_command_prints_slugs(self) -> None: assert result.output == "done-c\norphan-b\n" prune_mock.assert_called_once_with(None, True) - def test_history_prune_command_passes_year(self) -> None: - """prune forwards the YEAR positional; an empty result prints nothing.""" + def test_history_prune_scoped_year_passes_year(self) -> None: + """prune forwards the scoped year and --dry-run; the slug list prints.""" runner = CliRunner() - with mock.patch.object(_history_module, "prune_topics", return_value=[]) as prune_mock: - result = runner.invoke(history, ["prune", "2025"]) + with mock.patch.object(_history_module, "prune_topics", return_value=["orphan-topic"]) as prune_mock: + result = runner.invoke(history, ["-y", "2025", "prune", "--dry-run"]) assert result.exit_code == 0 - assert result.output == "" - prune_mock.assert_called_once_with("2025", False) + assert result.output.splitlines() == ["orphan-topic"] + prune_mock.assert_called_once_with("2025", True) @pytest.mark.parametrize( ("failure", "message"), @@ -392,7 +393,7 @@ def test_history_prune_empty_slug_dir_is_clean_error( # domain ValueError before the list is returned — the echo loop never # runs and nothing is deleted. with mock.patch("goga.history.prune.list_branch_refs", return_value=[]): - result = CliRunner().invoke(history, ["prune", "2026"]) + result = CliRunner().invoke(history, ["-y", "2026", "prune"]) assert result.exit_code == 1 assert result.stdout == "" @@ -411,7 +412,7 @@ def test_history_status_empty_result_prints_nothing_exit_zero( """A year without topics prints nothing and exits 0 — not an error.""" monkeypatch.chdir(tmp_path) - result = CliRunner().invoke(history, ["status", "1999"]) + result = CliRunner().invoke(history, ["-y", "1999", "status"]) assert result.exit_code == 0 assert result.output == "" @@ -425,7 +426,7 @@ def test_history_status_filter_matching_nothing_exit_zero( (year_dir / "history-commands" / "plan.md").write_text("plan\n", encoding="utf-8") monkeypatch.chdir(tmp_path) - result = CliRunner().invoke(history, ["status", "2026", "-t", "nomatch"]) + result = CliRunner().invoke(history, ["-y", "2026", "status", "-t", "nomatch"]) assert result.exit_code == 0 assert result.output == "" diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index f7ac80ef..d42f11e4 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -352,8 +352,8 @@ def register_hooks(hooks: Any) -> None: _write(tmp_path, ".goga/history/2026/other-topic/prd.md") monkeypatch.chdir(tmp_path) - filtered = CliRunner().invoke(history, ["status", "2026", "-s", qualified]) - unfiltered = CliRunner().invoke(history, ["status", "2026"]) + filtered = CliRunner().invoke(history, ["-y", "2026", "status", "-s", qualified]) + unfiltered = CliRunner().invoke(history, ["-y", "2026", "status"]) assert filtered.exit_code == 0 assert unfiltered.exit_code == 0 @@ -742,13 +742,13 @@ def test_prune_over_real_git_deletes_orphans_keeps_hosted( _write(tmp_path, ".goga/history/2025/done-d/completed/plan.md") monkeypatch.chdir(tmp_path) - dry = CliRunner().invoke(history, ["prune", "2025", "--dry-run"]) + dry = CliRunner().invoke(history, ["-y", "2025", "prune", "--dry-run"]) assert dry.exit_code == 0 assert dry.output.splitlines() == ["done-d", "orphan-c"] assert (tmp_path / ".goga/history/2025/orphan-c/prd.md").exists() - wet = CliRunner().invoke(history, ["prune", "2025"]) + wet = CliRunner().invoke(history, ["-y", "2025", "prune"]) assert wet.exit_code == 0 assert wet.output.splitlines() == ["done-d", "orphan-c"] @@ -775,7 +775,7 @@ def test_prune_remote_only_host_protects_over_real_git( _write(tmp_path, ".goga/history/2025/remote-only/prd.md") monkeypatch.chdir(tmp_path) - result = CliRunner().invoke(history, ["prune", "2025", "--dry-run"]) + result = CliRunner().invoke(history, ["-y", "2025", "prune", "--dry-run"]) assert result.exit_code == 0 assert result.output == "" From 8fc178b81e5bfaab4695c7b2e635c98f0b5e17cd Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 18:43:32 +0000 Subject: [PATCH 205/229] feat: add scoped-year integration tests for the history group --- .../commands/history/test_history_command.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/commands/history/test_history_command.py b/tests/commands/history/test_history_command.py index 8b575c9b..adc70d7e 100644 --- a/tests/commands/history/test_history_command.py +++ b/tests/commands/history/test_history_command.py @@ -98,6 +98,22 @@ def test_history_list_renders_tree(self, tmp_path: Path, monkeypatch: pytest.Mon ] assert "[planned]" not in result.output + def test_history_list_scoped_year_renders_one_section( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """-y scopes the inventory to one year — the other years never print.""" + root = tmp_path / ".goga" / "history" + (root / "2025" / "release-1-3-0").mkdir(parents=True) + (root / "2026" / "feat-x").mkdir(parents=True) + (root / "2026" / "history-commands").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["-y", "2025", "list"]) + + assert result.exit_code == 0 + assert result.output.splitlines() == ["2025/", " └── release-1-3-0"] + assert "2026" not in result.output + class TestHistoryStatus: def test_status_signature_defaults(self) -> None: @@ -335,6 +351,22 @@ def test_history_ensure_explicit_name_creates_dir( assert result.output == "" assert (tmp_path / ".goga" / "history" / "2031" / "feature-foo-bar").is_dir() + def test_history_ensure_scoped_year_creates_and_is_idempotent( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """ensure addresses the scoped year: created there, twice a success, stdout empty.""" + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + first = runner.invoke(history, ["-y", "2025", "ensure", "Feature/Foo_Bar"]) + second = runner.invoke(history, ["-y", "2025", "ensure", "Feature/Foo_Bar"]) + + assert first.exit_code == 0 + assert second.exit_code == 0 + assert first.output == "" + assert second.output == "" + assert (tmp_path / ".goga" / "history" / "2025" / "feature-foo-bar").is_dir() + class TestHistoryPrune: def test_history_prune_command_prints_slugs(self) -> None: @@ -441,3 +473,30 @@ def test_history_list_absent_history_empty_output( assert result.exit_code == 0 assert result.output == "" + + def test_history_empty_year_value_counts_as_absent( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """-y '' is an empty CLI value — the domain reads it as no selection: full tree.""" + root = tmp_path / ".goga" / "history" + (root / "2025" / "feat-a").mkdir(parents=True) + (root / "2026" / "feat-b").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["-y", "", "list"]) + + assert result.exit_code == 0 + assert "2025/" in result.output + assert "2026/" in result.output + + def test_history_list_scoped_missing_year_prints_nothing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A scoped year absent from the tree is empty — list prints nothing, exit 0.""" + (tmp_path / ".goga" / "history" / "2026" / "feat-x").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["-y", "2099", "list"]) + + assert result.exit_code == 0 + assert result.output == "" From d8465f59208e50753584b35db4c093b595ec5767 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 18:46:24 +0000 Subject: [PATCH 206/229] feat: sync history docs and provider usages with the scoped group year --- README.md | 2 +- docs/cli/history.md | 45 ++++++++++++++++---------- goga/history/.usages/topic-paths.md | 6 ++-- goga/history/.usages/topic-statuses.md | 4 +-- 4 files changed, 35 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 6557d263..f1ddfab5 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ Every `create` needs a base: `--base-ref`, or `topics.base_ref` in `.goga/config The board is a three-column table — topic, branch, statuses, plus a todo column under `--info` — with `*` marking the current branch and a local branch absorbing its remote twin. Each topic carries its **maximal statuses** in scale order: `empty → todo → defined → discovered → backlog → designed → specified → planned → done`, deepening as `todo.md`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. A topic can carry several statuses at once (`goga history status` prints them; `-s` filters by any of them). -Topics no branch hosts anymore are orphans — `goga history prune --dry-run` lists the orphans of a year, and `goga history prune [YEAR]` deletes them (irreversibly: the history tree is not in git). +Topics no branch hosts anymore are orphans — `goga history prune --dry-run` lists the orphans of a year, and `goga history -y <year> prune` deletes them (the year is the group's `-y`/`--year` option, given once before the subcommand; irreversibly: the history tree is not in git). To resume work inside a pipeline, pass the identifier to the run — `goga pipeline development -t feat/x` switches to the hosting branch first (creating a local branch from its remote-tracking ref when needed) and is an idempotent no-op when you are already on it; adding `--todo` opens the topic's `todo.md` in your editor after the switch. Fresh work is started with `goga topics create`, not `-t`. diff --git a/docs/cli/history.md b/docs/cli/history.md index df912c0a..1bda57d6 100644 --- a/docs/cli/history.md +++ b/docs/cli/history.md @@ -7,16 +7,26 @@ Work with the `.goga/history/` tree — its per-year topics, their statuses, and ## Synopsis ```bash -goga history list -goga history status [YEAR] [-t TOPIC] [-s STATUS]... -goga history path [TOPIC] [-f FILENAME] [-y YEAR] -goga history ensure [NAME] -goga history prune [YEAR] [--dry-run] +goga history [-y YEAR] list|status [-t TOPIC] [-s STATUS]|path [TOPIC] [-f FILENAME]|ensure [NAME]|prune [--dry-run] +``` + +## Year addressing + +The year is addressed by the group option `-y`/`--year` alone — given once, *before* the subcommand — and every subcommand reads the same value. The subcommands carry no year surfaces of their own: the removed forms are usage errors (exit 2) — a positional `YEAR` (`goga history status 2025`, `goga history prune 2025`) or a year option placed after the subcommand (`goga history path feat-x -y 2025`). + +- Without `-y`, `list` prints every year; `status`, `path`, `ensure`, and `prune` take the current year. +- An empty value (`-y ""`) counts as not set. +- The CLI does not validate the value — the domain owns the year semantics; a year missing from the tree is an empty result, not an error. + +```bash +goga history -y 2025 status +goga history -y 2025 path release/1.3.0 -f plan.md +goga history -y 2025 prune ``` ## `goga history list` -The inventory view: one `YYYY/` line per year, each topic indented under its year. +The inventory view: one `YYYY/` line per year, each topic indented under its year. With `-y`/`--year` the tree narrows to that year's section alone. ``` 2025/ @@ -51,7 +61,7 @@ A topic carries its **maximal present statuses** in scale order — one brackete | `planned` | `plan.md` | | | `done` | `completed/plan.md` | | -A topic can carry several statuses at once: every artifact present that is outranked by no other present artifact stays visible (tool statuses included, shown qualified such as `mkdocs.published` — see [Tools](../tools.md) for how a tool package registers its own statuses). YEAR defaults to the current year and is never printed; topics come out alphabetically. +A topic can carry several statuses at once: every artifact present that is outranked by no other present artifact stays visible (tool statuses included, shown qualified such as `mkdocs.published` — see [Tools](../tools.md) for how a tool package registers its own statuses). The year comes from the group's `-y`/`--year` (default: the current year) and is never printed; topics come out alphabetically. The status segments print colored (`cyan`) unless `NO_COLOR` is set in the environment. @@ -63,7 +73,7 @@ The status segments print colored (`cyan`) unless `NO_COLOR` is set in the envir ```bash goga history status # the current year, every topic -goga history status 2025 # one explicit year +goga history -y 2025 status # one explicit year goga history status -s planned # every topic carrying [planned] goga history status -t release # every topic whose slug contains "release" ``` @@ -73,26 +83,27 @@ goga history status -t release # every topic whose slug contains "release" Prints exactly one path of the history tree — and nothing else — for scripting: ```bash -plan=$(goga history path -f plan.md) +plan=$(goga history path -f plan.md) # the current year +plan=$(goga history -y 2025 path release/1.3.0 -f plan.md) # one explicit year ``` -TOPIC defaults to the current git branch (taken raw, as a branch name or a slug — the two compose identically through the slug grammar). With `-f`/`--file` the artifact file path prints (the filename is taken verbatim and must carry an extension); otherwise the topic directory. `-y`/`--year` selects the year (default: the current one). Nothing is created on disk. +TOPIC defaults to the current git branch (taken raw, as a branch name or a slug — the two compose identically through the slug grammar). With `-f`/`--file` the artifact file path prints (the filename is taken verbatim and must carry an extension); otherwise the topic directory. The year comes from the group's `-y`/`--year` (default: the current one). Nothing is created on disk. ## `goga history ensure` -Creates the topic directory of the current year, idempotently: parents are created as needed and an existing directory is a success, not a conflict. NAME defaults to the current git branch. Prints nothing on stdout — the exit code carries the result. Only directories: no artifact file is created, and occupancy is not reported. +Creates the topic directory of the scoped year, idempotently: parents are created as needed and an existing directory is a success, not a conflict. The year comes from the group's `-y`/`--year` (default: the current one). NAME defaults to the current git branch. Prints nothing on stdout — the exit code carries the result. Only directories: no artifact file is created, and occupancy is not reported. ## `goga history prune` -Deletes the orphan topics of one year — the topics no branch of the repository inventory hosts — and prints one slug per line; an empty result prints nothing and exits 0. +Deletes the orphan topics of one year — the topics no branch of the repository inventory hosts — and prints one slug per line; an empty result prints nothing and exits 0. The year comes from the group's `-y`/`--year` (default: the current year) — only that year is touched. ```bash -goga history prune --dry-run # list the deletion candidates, delete nothing -goga history prune # the current year -goga history prune 2025 # one explicit year +goga history prune --dry-run # the current year: list the candidates, delete nothing +goga history -y 2025 prune --dry-run +goga history -y 2025 prune # one explicit year ``` -- A topic is protected when a local branch, or a remote-tracking ref whose short name (the part after the first `/`) normalizes to the topic slug, hosts it — in every year, not just YEAR. +- A topic is protected when a local branch, or a remote-tracking ref whose short name (the part after the first `/`) normalizes to the topic slug, hosts it — in every year, not just the scoped one. - Deletion is unconditional — no status protects a topic, a `done` orphan goes too — and irreversible: the history tree is not in git, so a deleted topic directory cannot be recovered. Run with `--dry-run` first. - Filesystem-only: no branch, ref, or index of git is touched — the only git call is the read-only ref listing. @@ -102,7 +113,7 @@ goga history prune 2025 # one explicit year |------|---------| | `0` | Success — the tree, statuses, or path printed, the directory ensured, or the orphans pruned (possibly none) | | `1` | A clean domain error: an unknown `-s` status name, an empty topic filter or slug, an undeterminable current branch where a topic default is needed, a broken `goga_tool_*` package failing to import during status-scale assembly, or a prune failure (a git failure of the ref listing, a missing git binary, a topic directory that cannot be deleted, or a directory name that normalizes to an empty slug) | -| `2` | A usage error (unknown option, too many arguments) | +| `2` | A usage error (unknown option, too many arguments — including the removed year forms (a positional YEAR, a year option after the subcommand)) | ## Notes diff --git a/goga/history/.usages/topic-paths.md b/goga/history/.usages/topic-paths.md index b5580cfb..1e6b3c42 100644 --- a/goga/history/.usages/topic-paths.md +++ b/goga/history/.usages/topic-paths.md @@ -22,7 +22,8 @@ topic_dir = resolve_topic_dir("release-1-3-0", year="2025") ``` - Pure: nothing is created on disk. -- The year defaults to the current year (four digits, local time). +- The year defaults to the current year (four digits, local time); + `None` and the empty string mean the current year. ## Computing an artifact file path @@ -65,7 +66,8 @@ topic_dir = ensure_topic_dir("Feature/Foo_Bar", year="2025") # -> .goga/history/2025/feature-foo-bar (now existing) ``` -- The year defaults to the current year (four digits, local time). +- The year defaults to the current year (four digits, local time); + `None` and the empty string mean the current year. - Idempotent: an existing topic directory is a success, not a conflict. Decide occupancy *before* creating (via `topic_exists`) when the distinction matters. diff --git a/goga/history/.usages/topic-statuses.md b/goga/history/.usages/topic-statuses.md index d6222785..2dc70908 100644 --- a/goga/history/.usages/topic-statuses.md +++ b/goga/history/.usages/topic-statuses.md @@ -31,8 +31,8 @@ for record in records: carries every maximal status name in scale order. - Pass an assembled `scale` to reuse one assembly across calls; None assembles it once inside. -- An absent year or a year without topics yields an empty list — not an - error. +- `year`: `None` and the empty string mean the current year; an absent + year or a year without topics yields an empty list — not an error. ## Resolving one topic's statuses From 58f86697b482f10cf8cc8df6b97234dabc3c1f49 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 19:00:45 +0000 Subject: [PATCH 207/229] fix: address code review findings - pin the empty-string year of the destructive prune_topics: a domain test (current year supplies the candidates, other years untouched) and a CLI test (-y '' prune --dry-run takes the current year, not the full tree) - pin the documented no-validation pass-through of the group -y value at CLI level: an off-grammar year yields an empty result, exit 0 - test_cli.py: the history group help-surface assertion now covers all five subcommands, matching the corrected CODEMANIFEST wording --- .../commands/history/test_history_command.py | 31 +++++++++++++++++++ tests/history/test_prune.py | 12 +++++++ tests/test_cli.py | 4 +-- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/tests/commands/history/test_history_command.py b/tests/commands/history/test_history_command.py index adc70d7e..55501f9a 100644 --- a/tests/commands/history/test_history_command.py +++ b/tests/commands/history/test_history_command.py @@ -391,6 +391,25 @@ def test_history_prune_scoped_year_passes_year(self) -> None: assert result.output.splitlines() == ["orphan-topic"] prune_mock.assert_called_once_with("2025", True) + def test_history_prune_empty_year_value_takes_current_year( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """-y '' reaches the domain as an empty string — read as the current year, not the full tree.""" + history_root = tmp_path / ".goga" / "history" + (history_root / "2025" / "orphan-old").mkdir(parents=True) + (history_root / "2031" / "orphan-new").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + + with ( + mock.patch.object(naming, "datetime", _FixedClock), + mock.patch("goga.history.prune.list_branch_refs", return_value=[]), + ): + result = CliRunner().invoke(history, ["-y", "", "prune", "--dry-run"]) + + assert result.exit_code == 0 + assert result.output == "orphan-new\n" + assert (history_root / "2025" / "orphan-old").is_dir() + @pytest.mark.parametrize( ("failure", "message"), [ @@ -489,6 +508,18 @@ def test_history_empty_year_value_counts_as_absent( assert "2025/" in result.output assert "2026/" in result.output + def test_history_off_grammar_year_value_is_not_validated( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An off-grammar -y value passes through unvalidated — the domain answers an empty year.""" + (tmp_path / ".goga" / "history" / "2026" / "feat-x").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["-y", "20a6", "status"]) + + assert result.exit_code == 0 + assert result.output == "" + def test_history_list_scoped_missing_year_prints_nothing( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/history/test_prune.py b/tests/history/test_prune.py index 144a493c..21c257b1 100644 --- a/tests/history/test_prune.py +++ b/tests/history/test_prune.py @@ -261,6 +261,18 @@ def test_prune_topics_only_resolved_year_touched(self, tmp_path: Path, monkeypat assert (tmp_path / ".goga" / "history" / "2025").is_dir() # the emptied year directory stays assert new.is_dir() + def test_prune_topics_empty_string_year_means_current_year( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An empty-string year is "not set" — the current year alone supplies the candidates.""" + monkeypatch.chdir(tmp_path) + old = _topic(tmp_path, "2025", "orphan-old", "prd.md") + new = _topic(tmp_path, "2026", "orphan-new", "prd.md") + with mock.patch.object(naming, "datetime", _FixedClock), _inventory([]): + assert prune_topics("", dry_run=True) == ["orphan-new"] + assert old.is_dir() + assert new.is_dir() + def test_prune_topics_normalizes_tree_names_for_protection( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index ee2a19a0..6b94b217 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -324,7 +324,7 @@ def test_cli_registers_history_group() -> None: """The history group is registered on app and re-exported by the facade. Regression guard for the full registration chain: the group must be added - to the root ``app`` (help surface), expose all four subcommands, and be + to the root ``app`` (help surface), expose all five subcommands, and be re-exported through ``goga.commands.__all__`` — otherwise ``from goga.commands import history`` breaks on some consumer paths even though ``cli.py`` registered it. @@ -337,7 +337,7 @@ def test_cli_registers_history_group() -> None: history_help = runner.invoke(app, ["history", "--help"]) assert history_help.exit_code == 0 - for subcommand in ("list", "status", "path", "ensure"): + for subcommand in ("list", "status", "path", "ensure", "prune"): assert subcommand in history_help.output assert "history" in commands.__all__ From 77f2fa34c097e80770a3379eb1f9a85f238e20a4 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 19:22:25 +0000 Subject: [PATCH 208/229] chore(memory): update project memory --- .goga/memory/architecture.md | 100 +++++++++++++++++++---------------- 1 file changed, 54 insertions(+), 46 deletions(-) diff --git a/.goga/memory/architecture.md b/.goga/memory/architecture.md index 3b17310a..6f455e6f 100644 --- a/.goga/memory/architecture.md +++ b/.goga/memory/architecture.md @@ -21,35 +21,45 @@ extension forces an exception to the zone's established invariants. Extending a contract fragments: their invariants stay verbatim, and every new allowance is recorded only in the fragments of the new elements. -## Core-anchored invariants +## Core-anchored invariants and shared parameters Guarantees that must hold for every caller are specified and enforced in the core domain contracts, never at a single -entry point; a rule guarded inside one command counts as unenforced, because every other caller could bypass it. - -## Additive regression-free extension - -New functionality enters as a new unit beside the existing ones, never as a mode inside an existing unit. Existing -observable behavior, its contracts, and its tests are not edited and do not acquire new dependencies — including reads -of new data sources. Data-model extensions arrive as optional fields with a safe default so every existing construction -site stays valid without edits. Migrating existing functionality onto a new platform follows the same spirit as a -near-rename: domain objects move unchanged, and only the source of registrations changes (the cell emits the platform's -action instead of running its own enumeration mechanism). - -## Mechanism-agnostic contracts - -Contracts express only the abstract order of actions through references to practices and types. Concrete mechanisms, -tool choices, and lifecycle detail are fixed in separate project-level practice documents with executable guidance, -never inside contract annotations. +entry point; a rule guarded inside one command counts as unenforced, because every other caller could bypass it. The +same law governs the command surface: a parameter shared by every subcommand of a command group is declared once on +the group itself and applied implicitly by all subcommands — subcommand surfaces and declared signatures carry no copy +of it. The mechanism that transports the value to the subcommands is an implementation detail kept out of the +contract. ## Layered responsibility for external inputs Environment coupling lives at the boundary layer, never in the domain core. The boundary layer resolves external inputs — source precedence of explicit argument over configuration over built-in default — and passes primitive values inward; interactive prompting and terminal-capability handling belong to the outer command layer, keeping the domain -core usable from non-interactive callers and inner layers independently testable. The domain core exposes -all-or-nothing read-only resolution with clean errors and mutation routines that run unconditionally once the caller -has confirmed. The value provider performs structural validation only (type and shape), stores values verbatim, -embeds no defaults, and checks no semantics — semantic interpretation and defaulting belong to the consumer. +core usable from non-interactive callers and inner layers independently testable. Command callbacks stay thin in the +same spirit: they only resolve inputs, delegate to domain routines, and render results, passing values through as +opaque data without validating or re-interpreting them — grammar, normalization, and filtering rules for a value +belong exclusively to the domain module. The domain core exposes all-or-nothing read-only resolution with clean errors +and mutation routines that run unconditionally once the caller has confirmed. The value provider performs structural +validation only (type and shape), stores values verbatim, embeds no defaults, and checks no semantics — semantic +interpretation and defaulting belong to the consumer. + +## Graded outcome-to-exit mapping + +Absence of data or an empty result is a successful run with empty output, never a failure. Usage mistakes and domain +failures are kept distinct and map to separate standardized non-zero exit codes, each reported to the user as one +clean message — internal tracebacks never reach the output. + +## Additive regression-free extension + +New functionality enters as a new unit beside the existing ones, never as a mode inside an existing unit. When +behavior is added to an existing routine instead, it arrives as an optional parameter so every current caller stays +valid and unchanged, and invocation forms that remain supported stay observationally identical in output shape and +exit behavior; no parallel routines duplicating existing logic are ever introduced. Existing observable behavior, its +contracts, and its tests are not edited and do not acquire new dependencies — including reads of new data sources. +Data-model extensions arrive as optional fields with a safe default so every existing construction site stays valid +without edits. Migrating existing functionality onto a new platform follows the same spirit as a near-rename: domain +objects move unchanged, and only the source of registrations changes (the cell emits the platform's action instead of +running its own enumeration mechanism). ## Decisions before mutations, with compensating rollback @@ -59,10 +69,25 @@ effects are restored by composing existing primitives, exactly one clean error w repeated invocation stays safe. The rollback is scoped to the failed sequence — work completed outside it deliberately remains. Rollback mechanisms belong to the access layer; the decision to roll back belongs to the caller. -## Fix-in-place verification gates +## Mechanism-agnostic contracts -Defects surfaced by verification are repaired in the artifact itself, and the complete check suite is re-run to green -before approval. Approving with known breakage and deferring the repair to a later stage is rejected. +Contracts express only the abstract order of actions through references to practices and types. Concrete mechanisms, +tool choices, and lifecycle detail are fixed in separate project-level practice documents with executable guidance, +never inside contract annotations. + +## Closed binding of names in a contract + +Every name declared as an imported dependency must be referenced within the contract's own text, and every mention must +resolve within that same contract: either through a declared dependency or through a locally declared practice (when a +direct dependency is impossible — cycles, unreachability). No dangling declarations, no free-floating mentions — +otherwise the contract cannot stand alone and the implementation cannot be rebuilt from it. References use the +contract's own notation, without procedural phrases about where names come from. + +## Names state their scope + +An operation's name states its exact coverage — never broader than what it does (no implying remote-side effects of a +local-only operation), never narrower. Scope inaccuracy in a name is a contract defect; a rename is applied across all +already produced artifacts so that stages never disagree on names. ## Specialization lives with the consumer @@ -83,30 +108,13 @@ A process stage produces only its designated artifact type; transformations belo A planning stage does not modify implementation artifacts — materialization belongs to the next stage. Mixing planning with materialization destroys the workflow's guarantees: unreviewed code changes without an approved plan. -## Closed binding of names in a contract - -Every name declared as an imported dependency must be referenced within the contract's own text, and every mention must -resolve within that same contract: either through a declared dependency or through a locally declared practice (when a -direct dependency is impossible — cycles, unreachability). No dangling declarations, no free-floating mentions — -otherwise the contract cannot stand alone and the implementation cannot be rebuilt from it. References use the -contract's own notation, without procedural phrases about where names come from. - -## Names state their scope - -An operation's name states its exact coverage — never broader than what it does (no implying remote-side effects of a -local-only operation), never narrower. Scope inaccuracy in a name is a contract defect; a rename is applied across all -already produced artifacts so that stages never disagree on names. - -## ADR revision instead of silent deviation - -When implementation reveals that a settled ADR is redundant, the ADR's guarantee is restated through another means -rather than obeyed blindly or violated silently: the revision is explicitly recorded in the plan, the original intent is -preserved by a different mechanism (e.g. a checkpoint contract holding the guarantee instead of an explicit build step), -and routines made redundant by the revision are abolished. Neither letter-following against discovered redundancy nor -unrecorded deviation is acceptable. - ## One document — one behavior domain Consumer documentation is structured by behavior domain: a new domain gets its own self-contained document, documents of unchanged behavior are not edited, and cross-references between sibling documents are not introduced. The set of documents to touch is decided by this rule, not by the task's original list. + +## Fix-in-place verification gates + +Defects surfaced by verification are repaired in the artifact itself, and the complete check suite is re-run to green +before approval. Approving with known breakage and deferring the repair to a later stage is rejected. From f7680e7c05e50de02ed6678d84b355e85de5950d Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 20:04:04 +0000 Subject: [PATCH 209/229] fix: stabilize CI across Python 3.10-3.14 and apply ruff format Three independent CI failure root causes, fixed: - Reject a lone trailing dot ('plan.') as an empty extension in resolve_topic_file: PurePath.suffix returns '.' for it on Python 3.14, bypassing the non-empty-suffix contract (goga/history/paths.py, goga/history/CODEMANIFEST requirement updated to match) - Add portable is_kw_only_dataclass helper to tests/conftest.py and use it across test modules: _DataclassParams.kw_only exists only from Python 3.12, causing AttributeError in 15 tests on 3.10/3.11 - Apply ruff format to goga/ and tests/ (formatting-only changes across the remaining files, plus .usages/*.md python blocks per ruff 0.16) - Add reflect stage (incidents.md) to the bugfix workflow --- .goga/workflows/bugfix.yml | 2 + goga/commands/pipeline/pipeline.py | 3 +- goga/commands/topics/render.py | 4 +- goga/config/.usages/project-configuration.md | 2 +- goga/history/.usages/history-tree.md | 2 +- goga/history/.usages/prune.md | 4 +- goga/history/.usages/topic-paths.md | 4 +- goga/history/.usages/topic-statuses.md | 2 +- goga/history/CODEMANIFEST | 3 +- goga/history/paths.py | 8 +- goga/history/status.py | 13 +- goga/hooks/registry/state.py | 4 +- goga/hooks/tools/registration.py | 12 +- goga/topics/.usages/creating.md | 2 +- goga/topics/.usages/ensuring.md | 2 +- goga/topics/.usages/switching.md | 4 +- goga/topics/.usages/topic-board.md | 2 +- goga/topics/board.py | 14 +- goga/topics/creation.py | 39 ++--- goga/topics/deletion.py | 43 ++--- goga/topics/ensuring.py | 4 +- goga/topics/git/.usages/publishing.md | 16 +- goga/topics/git/.usages/refs-and-switching.md | 3 +- goga/topics/git/publish.py | 18 +- goga/topics/publishing.py | 16 +- goga/topics/switching.py | 30 +--- tests/build/test_build.py | 16 +- .../commands/history/test_history_command.py | 40 ++--- tests/commands/history/test_render.py | 4 +- .../pipeline/test_pipeline_command.py | 16 +- .../pipeline/test_pipeline_dispatch.py | 4 +- tests/commands/topics/test_render.py | 12 +- tests/commands/topics/test_topics_command.py | 21 +-- tests/config/test_loader.py | 5 +- tests/config/test_project_cell_contract.py | 4 +- tests/conftest.py | 12 ++ tests/history/git/test_refs.py | 4 +- tests/history/statuses/test_assembly.py | 20 +-- tests/history/statuses/test_registry.py | 4 +- tests/history/test_facade.py | 5 +- tests/history/test_naming.py | 4 +- tests/history/test_paths.py | 59 ++----- tests/history/test_status.py | 26 +-- tests/history/test_tree.py | 4 +- tests/hooks/catalog/test_catalog.py | 4 +- tests/hooks/registry/test_state.py | 44 ++--- tests/hooks/tools/test_packages.py | 4 +- tests/hooks/tools/test_registration.py | 12 +- tests/integration/test_topic_workflows.py | 127 ++++---------- .../compiler/test_compile_flow_memory.py | 52 +----- .../test_compile_flow_memory_integration.py | 16 +- .../compiler/test_compile_flow_notes.py | 13 +- .../compiler/test_flow_memory_contract.py | 9 +- .../compiler/test_flow_memory_logic.py | 4 +- .../test_serialize_flow_buttons_slot.py | 4 +- .../test_serialize_flow_memory_slot.py | 7 +- .../workflow/test_parse_workflow_logic.py | 9 +- tests/topics/git/test_publish.py | 3 +- tests/topics/git/test_trees.py | 3 +- tests/topics/test_board.py | 16 +- tests/topics/test_creation.py | 162 +++++------------- tests/topics/test_deletion.py | 62 ++----- tests/topics/test_ensuring.py | 13 +- tests/topics/test_publishing.py | 101 +++-------- tests/topics/test_switching.py | 4 +- 65 files changed, 345 insertions(+), 840 deletions(-) diff --git a/.goga/workflows/bugfix.yml b/.goga/workflows/bugfix.yml index 8af3d51b..cb38c33a 100644 --- a/.goga/workflows/bugfix.yml +++ b/.goga/workflows/bugfix.yml @@ -12,3 +12,5 @@ stages: Constraints: - Don't implementation without approve from user - Don't research before you receive the task from user + reflect: + file: incidents.md diff --git a/goga/commands/pipeline/pipeline.py b/goga/commands/pipeline/pipeline.py index 7ea98e81..449d9605 100644 --- a/goga/commands/pipeline/pipeline.py +++ b/goga/commands/pipeline/pipeline.py @@ -44,8 +44,7 @@ "todo", is_flag=True, default=False, - help="Open the editor with the topic's todo.md after the switch or fast creation " - "(run form only; requires --topic)", + help="Open the editor with the topic's todo.md after the switch or fast creation (run form only; requires --topic)", ) @click.option( "-e", diff --git a/goga/commands/topics/render.py b/goga/commands/topics/render.py index 0b3deff9..c5bc21d4 100644 --- a/goga/commands/topics/render.py +++ b/goga/commands/topics/render.py @@ -83,9 +83,7 @@ def render_topic_board(records: list[BoardRecord], width: int, info: bool = Fals for record in records: topic_text = f"{_CURRENT_MARKER}{record.topic}" if record.current else record.topic - leading = ( - (topic_text, record.branch, record.todo or "") if info else (topic_text, record.branch) - ) + leading = (topic_text, record.branch, record.todo or "") if info else (topic_text, record.branch) segments = [f"[{status}]" for status in record.statuses] for index, statuses_line in enumerate(_wrap_segments(segments, caps[-1])): diff --git a/goga/config/.usages/project-configuration.md b/goga/config/.usages/project-configuration.md index 814b7a48..e24a3253 100644 --- a/goga/config/.usages/project-configuration.md +++ b/goga/config/.usages/project-configuration.md @@ -298,7 +298,7 @@ config.build.review_executor # ReviewExecutorConfig | None config.build.review_executor.skip # bool | None — tri-state skip source config.build.review_executor.agent # str | None — review executor name config.build.review_executor.roles # list[str] | None — verbatim; [] means the full default set to the consumer -config.build.review_executor.env # dict — {str: str}, empty when absent +config.build.review_executor.env # dict — {str: str}, empty when absent config.build.review_executor.base_ref # str | None — review diff base, verbatim config.build.review_executor.patience # int | None — external-review stop threshold diff --git a/goga/history/.usages/history-tree.md b/goga/history/.usages/history-tree.md index fe727278..2709e32d 100644 --- a/goga/history/.usages/history-tree.md +++ b/goga/history/.usages/history-tree.md @@ -10,7 +10,7 @@ from goga.history import collect_history_tree tree = collect_history_tree() for year_record in tree: - print(year_record.year) # "2026" + print(year_record.year) # "2026" for topic in year_record.topics: # sorted alphabetically print(topic) ``` diff --git a/goga/history/.usages/prune.md b/goga/history/.usages/prune.md index b87b74c3..e8b4697e 100644 --- a/goga/history/.usages/prune.md +++ b/goga/history/.usages/prune.md @@ -20,8 +20,8 @@ first. from goga.history import prune_topics candidates = prune_topics(dry_run=True) # lists candidates, deletes nothing -removed = prune_topics() # current year, deletes orphans -removed = prune_topics(year="2025") # an explicit year +removed = prune_topics() # current year, deletes orphans +removed = prune_topics(year="2025") # an explicit year print("\n".join(removed)) ``` diff --git a/goga/history/.usages/topic-paths.md b/goga/history/.usages/topic-paths.md index 1e6b3c42..d80f3ff7 100644 --- a/goga/history/.usages/topic-paths.md +++ b/goga/history/.usages/topic-paths.md @@ -81,8 +81,8 @@ outside `[a-z0-9]` becomes `-` → collapse repeats → trim edges. from goga.history import normalize_topic_slug normalize_topic_slug("Feature/Foo_Bar") # "feature-foo-bar" -normalize_topic_slug("release/1.3.0") # "release-1-3-0" -normalize_topic_slug("aБb") # "ab" +normalize_topic_slug("release/1.3.0") # "release-1-3-0" +normalize_topic_slug("aБb") # "ab" ``` - Pure and deterministic; no transliteration; a fully non-ASCII name yields diff --git a/goga/history/.usages/topic-statuses.md b/goga/history/.usages/topic-statuses.md index 2dc70908..43730c32 100644 --- a/goga/history/.usages/topic-statuses.md +++ b/goga/history/.usages/topic-statuses.md @@ -20,7 +20,7 @@ statuses at once — all of them are shown. ```python from goga.history import assemble_status_scale, collect_topic_statuses -records = collect_topic_statuses() # current year, scale assembled here +records = collect_topic_statuses() # current year, scale assembled here scale = assemble_status_scale() records = collect_topic_statuses(year="2025", scale=scale) # reuse one scale for record in records: diff --git a/goga/history/CODEMANIFEST b/goga/history/CODEMANIFEST index cdea7b20..d1523cf5 100644 --- a/goga/history/CODEMANIFEST +++ b/goga/history/CODEMANIFEST @@ -169,7 +169,8 @@ Annotations: | Requirements: - A filename carries an extension when a dot separates a non-empty stem from a non-empty suffix — a leading dot alone (a dotfile name such as - ".md") is a hidden-file marker, not an extension separator + ".md") is a hidden-file marker, not an extension separator, and a + trailing dot alone ("plan.") is an empty extension, not a suffix - Pure with respect to the filesystem — the file is neither created nor checked for existence - The filename is taken verbatim — no normalization, no case change diff --git a/goga/history/paths.py b/goga/history/paths.py index bf925a31..ef0f3ecb 100644 --- a/goga/history/paths.py +++ b/goga/history/paths.py @@ -69,8 +69,10 @@ def resolve_topic_file(topic: str, filename: str, year: str | None = None) -> Pa The filename is taken verbatim — no normalization, no case change — and must carry an extension: a dot separating a non-empty stem from a non-empty suffix. A leading dot alone (a dotfile name such as ``.md``) is - a hidden-file marker, not an extension separator, which is exactly the - standard-library ``PurePath.suffix`` semantics this check relies on. + a hidden-file marker, not an extension separator, and a trailing dot + alone (``plan.``) is an empty extension — both are rejected explicitly, + because ``PurePath.suffix`` reports a lone trailing dot as the suffix + ``"."`` starting from Python 3.14. Args: topic: Topic input — a branch name or an already-normalized slug. @@ -85,7 +87,7 @@ def resolve_topic_file(topic: str, filename: str, year: str | None = None) -> Pa ValueError: The filename carries no extension, or the topic input normalizes to an empty slug (the directory composer's error). """ - if PurePath(filename).suffix == "": + if PurePath(filename).suffix in ("", "."): raise ValueError(f"filename {filename!r} must carry an extension") return resolve_topic_dir(topic, year) / filename diff --git a/goga/history/status.py b/goga/history/status.py index 26cb3422..322bb218 100644 --- a/goga/history/status.py +++ b/goga/history/status.py @@ -58,17 +58,11 @@ def resolve_topic_status(topic_dir: Path, scale: StatusScale) -> list[str]: per command run. Do not consider files outside the scale. """ - paths = [ - path.relative_to(topic_dir).as_posix() - for path in topic_dir.rglob("*") - if path.is_file() - ] + paths = [path.relative_to(topic_dir).as_posix() for path in topic_dir.rglob("*") if path.is_file()] return scale.maximal_present(paths) -def collect_topic_statuses( - year: str | None = None, scale: StatusScale | None = None -) -> list[TopicRecord]: +def collect_topic_statuses(year: str | None = None, scale: StatusScale | None = None) -> list[TopicRecord]: """Collect every topic of one year with its maximal present statuses. Args: @@ -103,6 +97,5 @@ def collect_topic_statuses( return [] topics = sorted(path.name for path in year_dir.iterdir() if path.is_dir()) return [ - TopicRecord(topic=topic, statuses=resolve_topic_status(year_dir / topic, resolved_scale)) - for topic in topics + TopicRecord(topic=topic, statuses=resolve_topic_status(year_dir / topic, resolved_scale)) for topic in topics ] diff --git a/goga/hooks/registry/state.py b/goga/hooks/registry/state.py index d850bfb7..bfa253ac 100644 --- a/goga/hooks/registry/state.py +++ b/goga/hooks/registry/state.py @@ -153,9 +153,7 @@ def by_tool(self) -> list[ToolHooks]: return [ ToolHooks( tool=tool, - subscriptions=[ - subscription for subscription in self._subscriptions if subscription.tool == tool - ], + subscriptions=[subscription for subscription in self._subscriptions if subscription.tool == tool], rejections=[rejection for rejection in self._rejections if rejection.tool == tool], ) for tool in tools diff --git a/goga/hooks/tools/registration.py b/goga/hooks/tools/registration.py index b4d376b7..1e8f6094 100644 --- a/goga/hooks/tools/registration.py +++ b/goga/hooks/tools/registration.py @@ -94,9 +94,7 @@ def reject(reason: str) -> None: print(f"Warning: rejected hook of tool {self.tool} on {domain}.{action}: {reason}", file=sys.stderr) - known = any( - record.domain == domain and record.name == action for record in declared_actions() - ) + known = any(record.domain == domain and record.name == action for record in declared_actions()) if not known: reject(f"unknown action {domain}.{action}") @@ -114,9 +112,7 @@ def reject(reason: str) -> None: return repeated = any( - subscription.domain == domain - and subscription.action == action - and subscription.name == name + subscription.domain == domain and subscription.action == action and subscription.name == name for subscription in self._subscriptions ) @@ -125,9 +121,7 @@ def reject(reason: str) -> None: return - self._subscriptions.append( - Subscription(tool=self.tool, domain=domain, action=action, name=name, hook=hook) - ) + self._subscriptions.append(Subscription(tool=self.tool, domain=domain, action=action, name=name, hook=hook)) @dataclass(frozen=True, kw_only=True) diff --git a/goga/topics/.usages/creating.md b/goga/topics/.usages/creating.md index bdb8354c..dcc575db 100644 --- a/goga/topics/.usages/creating.md +++ b/goga/topics/.usages/creating.md @@ -14,7 +14,7 @@ normalized slug of the year — the two may deliberately differ ```python from goga.topics import create_topic -result = create_topic("Feature/Foo_Bar", "origin/main") # current year +result = create_topic("Feature/Foo_Bar", "origin/main") # current year result = create_topic("Feature/Foo_Bar", "origin/main", year="2025") print(result) # one line describing what was created ``` diff --git a/goga/topics/.usages/ensuring.md b/goga/topics/.usages/ensuring.md index edb4c934..1c8f080d 100644 --- a/goga/topics/.usages/ensuring.md +++ b/goga/topics/.usages/ensuring.md @@ -17,7 +17,7 @@ anything. With `todo=True` the todo entry runs after the switch or the creation. ```python from goga.topics import ensure_topic -result = ensure_topic("prune-history-and-new-status") # current year +result = ensure_topic("prune-history-and-new-status") # current year result = ensure_topic("Feature/Foo_Bar", year="2025") print(result) # one line — the outcome ``` diff --git a/goga/topics/.usages/switching.md b/goga/topics/.usages/switching.md index 67719308..5444ae77 100644 --- a/goga/topics/.usages/switching.md +++ b/goga/topics/.usages/switching.md @@ -16,8 +16,8 @@ a valid target. ```python from goga.topics import switch_topic -result = switch_topic("history-com") # prefix match, one candidate -print(result) # one line — the outcome +result = switch_topic("history-com") # prefix match, one candidate +print(result) # one line — the outcome ``` - Zero candidates -> a clean error with a hint to the board. diff --git a/goga/topics/.usages/topic-board.md b/goga/topics/.usages/topic-board.md index 19c1fbaa..4240594c 100644 --- a/goga/topics/.usages/topic-board.md +++ b/goga/topics/.usages/topic-board.md @@ -16,7 +16,7 @@ through git plumbing, so the working copy and .git stay untouched. ```python from goga.topics import collect_topic_board -records = collect_topic_board() # current year, local +records = collect_topic_board() # current year, local records = collect_topic_board(year="2025", remote=True) # remote-tracking refs for record in records: print(record.topic, record.branch, record.statuses, record.current, record.todo) diff --git a/goga/topics/board.py b/goga/topics/board.py index 5dc18ee0..d62ec13e 100644 --- a/goga/topics/board.py +++ b/goga/topics/board.py @@ -68,9 +68,7 @@ class BoardRecord: todo: str | None = None -def collect_topic_board( - year: str | None = None, remote: bool = False -) -> list[BoardRecord]: +def collect_topic_board(year: str | None = None, remote: bool = False) -> list[BoardRecord]: """Collect the cross-branch topic inventory of one year with todo summaries. Args: @@ -256,9 +254,7 @@ def _year_topics(paths: list[str], year: str) -> dict[str, list[str]]: return topics -def _current_branch_topic( - current: str, year: str, scale: StatusScale -) -> tuple[str, list[str], str | None] | None: +def _current_branch_topic(current: str, year: str, scale: StatusScale) -> tuple[str, list[str], str | None] | None: """Read the current branch's own topic from the working copy. The slug guard runs first: ``resolve_topic_dir`` and ``topic_exists`` @@ -336,11 +332,7 @@ def _collapse_remote_twins(rows: dict[tuple[str, str], _Row]) -> dict[tuple[str, """ local_keys = {key for key, row in rows.items() if not row[0]} - return { - key: row - for key, row in rows.items() - if row[0] is False or (key[0], _short_name(key[1])) not in local_keys - } + return {key: row for key, row in rows.items() if row[0] is False or (key[0], _short_name(key[1])) not in local_keys} def _marks_current(branch: str, current: str | None, remote: bool) -> bool: diff --git a/goga/topics/creation.py b/goga/topics/creation.py index e76c0f77..940bc145 100644 --- a/goga/topics/creation.py +++ b/goga/topics/creation.py @@ -54,9 +54,7 @@ _BOARD_HINT = "run 'goga topics board' to see the board" -def check_branch_occupancy( - branch_name: str, slug: str, year: str | None = None -) -> str | None: +def check_branch_occupancy(branch_name: str, slug: str, year: str | None = None) -> str | None: """Decide whether the entered branch name and the topic slug are free. Probes three oracles in order and returns the human-readable reason of @@ -242,9 +240,7 @@ def create_topic( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared signat # named like the slug occupies no topic for the oracle, so the # failure can only surface here, after the branch was created. The # todo write shares the boundary: one clean error for both. - raise click.ClickException( - f"cannot create the topic directory or write the todo file: {exc}" - ) from exc + raise click.ClickException(f"cannot create the topic directory or write the todo file: {exc}") from exc def enter_topic_todo(topic: str, year: str | None = None) -> bool: @@ -285,14 +281,10 @@ def enter_topic_todo(topic: str, year: str | None = None) -> bool: return _enter_topic_todo(topic, year) except OSError as exc: # The boundary covers the prefill read and the saved write alike. - raise click.ClickException( - f"cannot read or write the todo file: {exc}" - ) from exc + raise click.ClickException(f"cannot read or write the todo file: {exc}") from exc -def _occupancy_conflict( - branch_name: str, slug: str, year: str | None -) -> str | None: +def _occupancy_conflict(branch_name: str, slug: str, year: str | None) -> str | None: """Probe the three occupancy oracles — the traced algorithm, unwrapped. Args: @@ -308,9 +300,7 @@ def _occupancy_conflict( if any(not ref.remote and ref.name == branch_name for ref in refs): return f"branch '{branch_name}' already exists" - if any( - ref.remote and ref.name.partition("/")[2] == branch_name for ref in refs - ): + if any(ref.remote and ref.name.partition("/")[2] == branch_name for ref in refs): return f"remote-tracking branch '{branch_name}' already exists" if topic_exists(slug, resolved_year): @@ -336,9 +326,7 @@ def _slug_conflict(slug: str, year: str | None) -> str | None: for ref in list_branch_refs(): if read_ref_tree_paths(ref.name, prefix): - return ( - f"topic '{slug}' of {resolved_year} is already hosted by branch '{ref.name}'" - ) + return f"topic '{slug}' of {resolved_year} is already hosted by branch '{ref.name}'" return None @@ -371,15 +359,12 @@ def _create_topic( # noqa: PLR0913, PLR0917 — the unwrapped mirror of the dec # conflicted name must never waste an entered todo. slug = normalize_topic_slug(branch_name) if slug == "": - raise click.ClickException( - f"branch name '{branch_name}' normalizes to an empty topic slug" - ) + raise click.ClickException(f"branch name '{branch_name}' normalizes to an empty topic slug") current = resolve_current_branch_name() if current is not None and normalize_topic_slug(current) == slug: raise click.ClickException( - f"branch {current} already hosts topic {resolved_year}/{slug}" - " — switch to it instead of re-creating it" + f"branch {current} already hosts topic {resolved_year}/{slug} — switch to it instead of re-creating it" ) conflict = check_branch_occupancy(branch_name, slug, year) @@ -393,9 +378,7 @@ def _create_topic( # noqa: PLR0913, PLR0917 — the unwrapped mirror of the dec resolved_todo = _resolve_todo(todo) if publish and resolved_todo is None: - raise click.ClickException( - "the publication needs a todo — the board reads the topic through todo.md" - ) + raise click.ClickException("the publication needs a todo — the board reads the topic through todo.md") if not _publication_asked(publish, resolved_todo): create_branch_at_commit(branch_name, base_commit) @@ -492,9 +475,7 @@ def _enter_topic_todo(topic: str, year: str | None) -> bool: # replacement character: a ``UnicodeDecodeError`` is a ``ValueError``, # it matches none of the module's handlers and would pierce the # clean-error boundary — mirroring ``_run_git`` of the git cell. - initial = ( - path.read_text(encoding="utf-8", errors="replace") if path.exists() else None - ) + initial = path.read_text(encoding="utf-8", errors="replace") if path.exists() else None saved = edit_text(initial) diff --git a/goga/topics/deletion.py b/goga/topics/deletion.py index 263f3382..d21e094b 100644 --- a/goga/topics/deletion.py +++ b/goga/topics/deletion.py @@ -64,9 +64,7 @@ class DeleteTarget: has_dir: bool -def resolve_delete_targets( - identifiers: list[str], year: str | None = None -) -> list[DeleteTarget]: +def resolve_delete_targets(identifiers: list[str], year: str | None = None) -> list[DeleteTarget]: """Resolve deletion identifiers into targets — every check before any removal. @@ -224,9 +222,7 @@ def _disk_slugs(year: str) -> set[str]: return set() -def _identify( - identifier: str, refs: list[BranchRef], hosted: dict[str, set[str]], disk: set[str] -) -> str: +def _identify(identifier: str, refs: list[BranchRef], hosted: dict[str, set[str]], disk: set[str]) -> str: """Resolve one identifier into its single topic through the tiers. Args: @@ -253,9 +249,7 @@ def _identify( if topics is None: continue if len(topics) > 1: - raise click.ClickException( - f"several topics match {identifier!r}: {', '.join(sorted(topics))}" - ) + raise click.ClickException(f"several topics match {identifier!r}: {', '.join(sorted(topics))}") if topics: return next(iter(topics)) # An empty tier names no topic — fall through to the next tier. The @@ -268,9 +262,7 @@ def _identify( raise click.ClickException(f"no topic matches {identifier!r}") -def _tier_exact_branch( - identifier: str, refs: list[BranchRef], hosted: dict[str, set[str]] -) -> set[str] | None: +def _tier_exact_branch(identifier: str, refs: list[BranchRef], hosted: dict[str, set[str]]) -> set[str] | None: """Take the first tier — the exact branch name. Args: @@ -290,8 +282,7 @@ def _tier_exact_branch( matched = [ ref for ref in refs - if (not ref.remote and ref.name == identifier) - or (ref.remote and _short_name(ref.name) == identifier) + if (not ref.remote and ref.name == identifier) or (ref.remote and _short_name(ref.name) == identifier) ] if not matched: return None @@ -346,24 +337,19 @@ def _tier_prefix( matched = [ ref for ref in refs - if ref.name.startswith(identifier) - or (ref.remote and _short_name(ref.name).startswith(identifier)) + if ref.name.startswith(identifier) or (ref.remote and _short_name(ref.name).startswith(identifier)) ] for ref in matched: topics |= hosted[ref.name] if slug != "": - topics |= { - hosted_slug for slugs in hosted.values() for hosted_slug in slugs if hosted_slug.startswith(slug) - } + topics |= {hosted_slug for slugs in hosted.values() for hosted_slug in slugs if hosted_slug.startswith(slug)} topics |= {disk_slug for disk_slug in disk if disk_slug.startswith(slug)} if not matched and not topics: return None return topics -def _assemble_target( - topic: str, refs: list[BranchRef], hosted: dict[str, set[str]], disk: set[str] -) -> DeleteTarget: +def _assemble_target(topic: str, refs: list[BranchRef], hosted: dict[str, set[str]], disk: set[str]) -> DeleteTarget: """Assemble one topic's target from the full inventory. The hosting refs decide eligibility — a ref is part of the target @@ -410,8 +396,7 @@ def _assemble_target( if len(local_names) > 1: names = ", ".join(local_names) raise click.ClickException( - f"several branches host topic {topic!r}: {names} — " - "remove all but one of them before deleting" + f"several branches host topic {topic!r}: {names} — remove all but one of them before deleting" ) branch = local_names[0] if local_names else None # The twin is the *origin* twin — the one remote the deletion push of @@ -421,11 +406,7 @@ def _assemble_target( # remote's branch deleted or a phantom "remote ref does not exist" # after the local branch is already gone. remote = next( - ( - _short_name(ref.name) - for ref in eligible - if ref.remote and ref.name.partition("/")[0] == "origin" - ), + (_short_name(ref.name) for ref in eligible if ref.remote and ref.name.partition("/")[0] == "origin"), None, ) has_dir = topic in disk and not merged @@ -462,9 +443,7 @@ def _guard_current_branch(targets: list[DeleteTarget]) -> None: slug = normalize_topic_slug(current) for target in targets: if target.branch == current or slug == target.topic: - raise click.ClickException( - f"the current branch hosts topic {target.topic!r} — switch away before deleting" - ) + raise click.ClickException(f"the current branch hosts topic {target.topic!r} — switch away before deleting") def delete_topics(targets: list[DeleteTarget], year: str | None = None) -> str: diff --git a/goga/topics/ensuring.py b/goga/topics/ensuring.py index 2d363fe8..fe545c6b 100644 --- a/goga/topics/ensuring.py +++ b/goga/topics/ensuring.py @@ -114,9 +114,7 @@ def ensure_topic(identifier: str, todo: bool = False, year: str | None = None) - # todo write, so the pipeline-driven path pierces no further than # the CLI one (``FileNotFoundError``, an ``OSError`` subclass, is # handled above as the missing git binary). - raise click.ClickException( - f"cannot create the topic directory or write the todo file: {exc}" - ) from exc + raise click.ClickException(f"cannot create the topic directory or write the todo file: {exc}") from exc def _ensure_topic(identifier: str, todo: bool, year: str | None) -> str: diff --git a/goga/topics/git/.usages/publishing.md b/goga/topics/git/.usages/publishing.md index 5a35138f..3d7d9e22 100644 --- a/goga/topics/git/.usages/publishing.md +++ b/goga/topics/git/.usages/publishing.md @@ -17,7 +17,7 @@ the caller. ```python from goga.topics.git import resolve_ref_commit -commit = resolve_ref_commit("origin/main") # any rev string +commit = resolve_ref_commit("origin/main") # any rev string ``` - The revision resolves as git resolves it — a branch, a remote-tracking @@ -33,10 +33,10 @@ commit = resolve_ref_commit("origin/main") # any rev string from goga.topics.git import commit_file_on_base commit = commit_file_on_base( - base, # from resolve_ref_commit - ".goga/history/2026/feature-foo/todo.md", # repo-root-relative - "Fix payment retries.\n\nRetries ignore the cap.\n", # content as text - "goga: create topic feature-foo", # final message + base, # from resolve_ref_commit + ".goga/history/2026/feature-foo/todo.md", # repo-root-relative + "Fix payment retries.\n\nRetries ignore the cap.\n", # content as text + "goga: create topic feature-foo", # final message ) ``` @@ -62,8 +62,8 @@ from goga.topics.git import ( if not origin_configured(): ... # clean error: origin is not configured — before any mutation -create_branch_at_commit("Feature/Foo_Bar", commit) # name verbatim, no switch -push_branch("Feature/Foo_Bar") # push -u origin +create_branch_at_commit("Feature/Foo_Bar", commit) # name verbatim, no switch +push_branch("Feature/Foo_Bar") # push -u origin ``` - `create_branch_at_commit` takes the branch name exactly as entered and @@ -79,7 +79,7 @@ push_branch("Feature/Foo_Bar") # push -u origin ```python from goga.topics.git import delete_local_branch -delete_local_branch("Feature/Foo_Bar") # full rollback of a failed publication +delete_local_branch("Feature/Foo_Bar") # full rollback of a failed publication ``` - `delete_local_branch` removes the local branch ref; the working copy, the diff --git a/goga/topics/git/.usages/refs-and-switching.md b/goga/topics/git/.usages/refs-and-switching.md index 1a4d8362..295e7321 100644 --- a/goga/topics/git/.usages/refs-and-switching.md +++ b/goga/topics/git/.usages/refs-and-switching.md @@ -47,8 +47,7 @@ path = f"{resolve_history_root().as_posix()}/2026/feature-foo/todo.md" content = read_ref_file("feature-foo", path) if content is not None: first = next( - (line.lstrip("#").strip() for line in content.splitlines() - if line.lstrip("#").strip()), + (line.lstrip("#").strip() for line in content.splitlines() if line.lstrip("#").strip()), "", ) print(first) diff --git a/goga/topics/git/publish.py b/goga/topics/git/publish.py index 297c2af8..0ab92234 100644 --- a/goga/topics/git/publish.py +++ b/goga/topics/git/publish.py @@ -265,14 +265,16 @@ def push_branch(branch_name: str) -> None: # ``--no-follow-tags`` holds that no-tags line even under the user's # ``push.followTags`` config — a local-only annotated tag on the base # commit would otherwise ride along with the branch. - _run_git([ - "git", - "push", - "--no-follow-tags", - "-u", - "origin", - f"refs/heads/{branch_name}:refs/heads/{branch_name}", - ]) + _run_git( + [ + "git", + "push", + "--no-follow-tags", + "-u", + "origin", + f"refs/heads/{branch_name}:refs/heads/{branch_name}", + ] + ) def origin_configured() -> bool: diff --git a/goga/topics/publishing.py b/goga/topics/publishing.py index a910d273..1d6a4778 100644 --- a/goga/topics/publishing.py +++ b/goga/topics/publishing.py @@ -120,21 +120,15 @@ def _publish_topic( slug = normalize_topic_slug(branch_name) if slug == "": - raise click.ClickException( - f"branch name '{branch_name}' normalizes to an empty topic slug" - ) + raise click.ClickException(f"branch name '{branch_name}' normalizes to an empty topic slug") if not todo: - raise click.ClickException( - "the fast path needs a non-empty todo" - " — pass the text or enter it interactively" - ) + raise click.ClickException("the fast path needs a non-empty todo — pass the text or enter it interactively") current = resolve_current_branch_name() if current is not None and normalize_topic_slug(current) == slug: raise click.ClickException( - f"branch {current} already hosts topic {resolved_year}/{slug}" - " — the fast path is only for fresh work" + f"branch {current} already hosts topic {resolved_year}/{slug} — the fast path is only for fresh work" ) conflict = check_branch_occupancy(branch_name, slug, resolved_year) @@ -144,9 +138,7 @@ def _publish_topic( raise click.ClickException(f"{conflict} — {_BOARD_HINT}") if not origin_configured(): - raise click.ClickException( - "origin is not configured — the fast mode publishes to origin" - ) + raise click.ClickException("origin is not configured — the fast mode publishes to origin") base_commit = resolve_ref_commit(base_ref) diff --git a/goga/topics/switching.py b/goga/topics/switching.py index d7cbf0f3..72892de2 100644 --- a/goga/topics/switching.py +++ b/goga/topics/switching.py @@ -61,9 +61,7 @@ class SwitchCandidate: remote: bool -def resolve_switch_candidates( - identifier: str, year: str | None = None -) -> list[SwitchCandidate]: +def resolve_switch_candidates(identifier: str, year: str | None = None) -> list[SwitchCandidate]: """Resolve a switch identifier into its candidate branches. Args: @@ -122,9 +120,7 @@ def resolve_switch_candidates( raise click.ClickException(str(exc)) from exc -def switch_topic( - identifier: str, todo: bool = False, year: str | None = None -) -> str: +def switch_topic(identifier: str, todo: bool = False, year: str | None = None) -> str: """Bring the repository onto the branch hosting the requested work; with the todo flag, enter the todo of the switched topic after the switch. @@ -194,9 +190,7 @@ def switch_topic( raise click.ClickException(str(exc)) from exc -def _resolve_switch_candidates( - identifier: str, year: str | None -) -> list[SwitchCandidate]: +def _resolve_switch_candidates(identifier: str, year: str | None) -> list[SwitchCandidate]: """Build the candidate inventory and take the first non-empty tier. Args: @@ -303,11 +297,7 @@ def _unique_candidates(candidates: list[SwitchCandidate]) -> list[SwitchCandidat Returns: The candidates without remote twins and branch repetitions. """ - local_topics = { - (candidate.topic, candidate.branch) - for candidate in candidates - if not candidate.remote - } + local_topics = {(candidate.topic, candidate.branch) for candidate in candidates if not candidate.remote} unique: list[SwitchCandidate] = [] branches: set[str] = set() @@ -340,16 +330,12 @@ def _switch_topic(identifier: str, todo: bool, year: str | None) -> str: candidates = resolve_switch_candidates(identifier, year) if not candidates: - raise click.ClickException( - f"no branch hosts {identifier!r} — run 'goga topics board' to see the board" - ) + raise click.ClickException(f"no branch hosts {identifier!r} — run 'goga topics board' to see the board") chosen = _take_candidate(candidates) if todo and chosen.topic is None: - raise click.ClickException( - f"branch '{chosen.branch}' hosts no topic — switching creates nothing" - ) + raise click.ClickException(f"branch '{chosen.branch}' hosts no topic — switching creates nothing") line = _apply_candidate(chosen) @@ -422,9 +408,7 @@ def _choose_candidate(candidates: list[SwitchCandidate]) -> SwitchCandidate: for line in lines: click.echo(line) - number = click.prompt( - "Select a branch by number", type=click.IntRange(1, len(candidates)) - ) + number = click.prompt("Select a branch by number", type=click.IntRange(1, len(candidates))) return candidates[number - 1] diff --git a/tests/build/test_build.py b/tests/build/test_build.py index debd9bc7..1a346da6 100644 --- a/tests/build/test_build.py +++ b/tests/build/test_build.py @@ -1186,9 +1186,7 @@ class TestReviewScopedPassComposition: def test_full_pass_carries_review_scoped_options(self, tmp_path, monkeypatch) -> None: # Same agent as the task executor and an empty review env -> a single # full pass, which IS review-carrying: the scoped options ride along. - config = _make_config( - review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/1.2.x", patience=3) - ) + config = _make_config(review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/1.2.x", patience=3)) with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: result = _run_build_in_tmp( tmp_path, @@ -1206,9 +1204,7 @@ def test_two_pass_review_scoped_options_only_on_review_pass(self, tmp_path, monk # A differing review agent induces the two-pass mode: pass 1 is # tasks-only (universal options only), pass 2 is the review pass and # carries the scoped options. - config = _make_config( - review_executor=ReviewExecutorConfig(agent="codex", base_ref="origin/1.2.x", patience=3) - ) + config = _make_config(review_executor=ReviewExecutorConfig(agent="codex", base_ref="origin/1.2.x", patience=3)) review_wrapper = tmp_path / "codex-as-claude.sh" review_wrapper.write_text("#!/bin/sh\n") @@ -1237,9 +1233,7 @@ def test_cli_scoped_options_override_config_on_review_pass(self, tmp_path, monke # The CLI source flows through the same composition: cli_options carry # base_ref/review_patience, the config declares different values, and # the CLI wins on the review-carrying (here: single full) pass. - config = _make_config( - review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/main", patience=3) - ) + config = _make_config(review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/main", patience=3)) with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: result = _run_build_in_tmp( tmp_path, @@ -1273,9 +1267,7 @@ def test_cli_scoped_options_without_review_executor_section(self, tmp_path, monk def test_skip_run_omits_review_scoped_options(self, tmp_path, monkeypatch) -> None: # A skip run has no review phase of any kind: even with review bounds # declared, the single tasks-only pass carries universal options only. - config = _make_config( - review_executor=ReviewExecutorConfig(skip=True, base_ref="origin/1.2.x", patience=3) - ) + config = _make_config(review_executor=ReviewExecutorConfig(skip=True, base_ref="origin/1.2.x", patience=3)) with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: result = _run_build_in_tmp( tmp_path, diff --git a/tests/commands/history/test_history_command.py b/tests/commands/history/test_history_command.py index 55501f9a..bd847e94 100644 --- a/tests/commands/history/test_history_command.py +++ b/tests/commands/history/test_history_command.py @@ -202,9 +202,7 @@ def test_history_status_defaults_to_current_year_unfiltered( assert result.output.splitlines() == ["alpha [planned]", "mid [defined]", "zeta [empty]"] assert "old-topic" not in result.output - def test_history_status_repeatable_status_filter( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_history_status_repeatable_status_filter(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """-s repeats: one -s per name keeps several statuses, drops the rest.""" year_dir = tmp_path / ".goga" / "history" / "2026" for topic in ("done-topic", "planned-topic", "defined-topic"): @@ -268,9 +266,7 @@ def test_history_status_filter_new_unknown(self) -> None: class TestHistoryPath: - def test_history_path_prints_file_path_only( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_history_path_prints_file_path_only(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """path answers the branch-defaulted artifact path — one line, nothing created.""" monkeypatch.chdir(tmp_path) runner = CliRunner() @@ -286,9 +282,7 @@ def test_history_path_prints_file_path_only( assert result.output.endswith("\n") assert not (tmp_path / ".goga").exists() - def test_history_path_without_file_prints_topic_dir( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_history_path_without_file_prints_topic_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """path without -f answers the branch-defaulted topic directory.""" monkeypatch.chdir(tmp_path) runner = CliRunner() @@ -304,9 +298,7 @@ def test_history_path_without_file_prints_topic_dir( assert result.output.endswith("\n") assert not (tmp_path / ".goga").exists() - def test_history_path_scoped_year_composes_that_year( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_history_path_scoped_year_composes_that_year(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """path takes an explicit branch-name topic; the scoped year composes the path.""" monkeypatch.chdir(tmp_path) @@ -319,9 +311,7 @@ def test_history_path_scoped_year_composes_that_year( class TestHistoryEnsure: - def test_history_ensure_creates_dir_silently( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_history_ensure_creates_dir_silently(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """ensure normalizes the branch name and is idempotent — stdout stays empty.""" monkeypatch.chdir(tmp_path) runner = CliRunner() @@ -338,9 +328,7 @@ def test_history_ensure_creates_dir_silently( assert second.output == "" assert (tmp_path / ".goga" / "history" / "2031" / "feature-foo-bar").is_dir() - def test_history_ensure_explicit_name_creates_dir( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_history_ensure_explicit_name_creates_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """ensure with an explicit NAME normalizes it — no git involved.""" monkeypatch.chdir(tmp_path) @@ -372,9 +360,7 @@ class TestHistoryPrune: def test_history_prune_command_prints_slugs(self) -> None: """prune echoes one slug per line and forwards --dry-run to the domain.""" runner = CliRunner() - with mock.patch.object( - _history_module, "prune_topics", return_value=["done-c", "orphan-b"] - ) as prune_mock: + with mock.patch.object(_history_module, "prune_topics", return_value=["done-c", "orphan-b"]) as prune_mock: result = runner.invoke(history, ["prune", "--dry-run"]) assert result.exit_code == 0 @@ -429,9 +415,7 @@ def test_history_prune_git_failure_is_clean_error(self, failure: Exception, mess assert message in result.stderr assert "Traceback" not in result.stderr - def test_history_prune_empty_slug_dir_is_clean_error( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_history_prune_empty_slug_dir_is_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A manual empty-slug directory aborts the cleanup before any deletion.""" year_dir = tmp_path / ".goga" / "history" / "2026" (year_dir / "orphan-a").mkdir(parents=True) @@ -482,9 +466,7 @@ def test_history_status_filter_matching_nothing_exit_zero( assert result.exit_code == 0 assert result.output == "" - def test_history_list_absent_history_empty_output( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_history_list_absent_history_empty_output(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An empty workspace has an empty history — list prints nothing, exit 0.""" monkeypatch.chdir(tmp_path) @@ -493,9 +475,7 @@ def test_history_list_absent_history_empty_output( assert result.exit_code == 0 assert result.output == "" - def test_history_empty_year_value_counts_as_absent( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_history_empty_year_value_counts_as_absent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """-y '' is an empty CLI value — the domain reads it as no selection: full tree.""" root = tmp_path / ".goga" / "history" (root / "2025" / "feat-a").mkdir(parents=True) diff --git a/tests/commands/history/test_render.py b/tests/commands/history/test_render.py index cf87ae3f..b89ad3a1 100644 --- a/tests/commands/history/test_render.py +++ b/tests/commands/history/test_render.py @@ -91,9 +91,7 @@ def test_render_topic_statuses_one_segment_per_status( ) -> None: """Every status of a record prints as its own space-separated segment.""" monkeypatch.setenv("NO_COLOR", "1") - render_topic_statuses( - [TopicRecord(topic="release-1-3-0", statuses=["done", "mkdocs.published"])] - ) + render_topic_statuses([TopicRecord(topic="release-1-3-0", statuses=["done", "mkdocs.published"])]) captured = capsys.readouterr() assert captured.out == "release-1-3-0 [done] [mkdocs.published]\n" assert "\x1b" not in captured.out diff --git a/tests/commands/pipeline/test_pipeline_command.py b/tests/commands/pipeline/test_pipeline_command.py index 572af868..0985b0ac 100644 --- a/tests/commands/pipeline/test_pipeline_command.py +++ b/tests/commands/pipeline/test_pipeline_command.py @@ -160,9 +160,7 @@ def test_pipeline_todo_option_has_no_short_form(self) -> None: topic_param = next(p for p in pipeline.params if p.name == "topic") assert set(topic_param.opts) == {"-t", "--topic"} - def test_pipeline_todo_rejects_a_value( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_pipeline_todo_rejects_a_value(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """``--todo`` binds as a flag on the real command — a value is a usage error. A synthetic probe command cannot fail from a regression in the @@ -664,9 +662,7 @@ class TestPipelineTodoFlag: the D7 silent-skip matrix, and the non-TTY abort ordering. """ - def test_pipeline_todo_without_topic_clean_error( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_pipeline_todo_without_topic_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """``--todo`` without ``--topic`` in the run form is a clean error (fix D2). Step 2 passes (a name is given), step 3 errors: exit 1 with the exact @@ -690,9 +686,7 @@ def test_pipeline_todo_without_topic_clean_error( mock_run.assert_not_called() mock_info.assert_not_called() - def test_pipeline_todo_forwarded_to_ensure_topic( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_pipeline_todo_forwarded_to_ensure_topic(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """``--todo`` is forwarded verbatim; the result line echoes once before the dispatch.""" _write_config(tmp_path) monkeypatch.chdir(tmp_path) @@ -747,9 +741,7 @@ def test_pipeline_todo_silently_ignored_in_info_forms( mock_ensure.assert_not_called() mock_info.assert_called_once() - def test_pipeline_todo_non_tty_aborts_before_docker( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_pipeline_todo_non_tty_aborts_before_docker(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """``--todo`` on a non-terminal aborts inside the domain before docker. The REAL ``ensure_topic`` runs (unmocked): the CliRunner stdin is diff --git a/tests/commands/pipeline/test_pipeline_dispatch.py b/tests/commands/pipeline/test_pipeline_dispatch.py index 5e48ba89..715b1cef 100644 --- a/tests/commands/pipeline/test_pipeline_dispatch.py +++ b/tests/commands/pipeline/test_pipeline_dispatch.py @@ -550,9 +550,7 @@ def test_pipeline_topic_unresolved_identifier_creates_and_launches( year = current_year() inventory = [BranchRef(name="main", remote=False)] trees = {"main": [f".goga/history/{year}/other/prd.md"]} - _cleanliness, checkout, _remote, fresh_creation = _wire_topic_domain( - monkeypatch, inventory, trees, "main" - ) + _cleanliness, checkout, _remote, fresh_creation = _wire_topic_domain(monkeypatch, inventory, trees, "main") config = _make_config() runner = CliRunner() diff --git a/tests/commands/topics/test_render.py b/tests/commands/topics/test_render.py index 4d6b791e..5f9943ca 100644 --- a/tests/commands/topics/test_render.py +++ b/tests/commands/topics/test_render.py @@ -146,9 +146,7 @@ def test_render_topic_board_degenerate_narrow_terminal(self, capsys: pytest.Capt assert "[done]" in lines[2] @pytest.mark.parametrize("width", [33, 32]) - def test_render_topic_board_boundary_width_33_32( - self, capsys: pytest.CaptureFixture[str], width: int - ) -> None: + def test_render_topic_board_boundary_width_33_32(self, capsys: pytest.CaptureFixture[str], width: int) -> None: """Width 33 splits evenly into the minimum thirds; 32 stays at them anyway.""" records = [ BoardRecord(topic="feat-a", branch="feat/a", statuses=["done"], current=False, remote=False), @@ -184,9 +182,7 @@ def test_render_topic_board_two_segments_fit_one_line(self, capsys: pytest.Captu assert "[defined] [planned]" in lines[2] assert all(len(line) <= 80 for line in lines) - def test_render_topic_board_three_columns_unchanged_without_info( - self, capsys: pytest.CaptureFixture[str] - ) -> None: + def test_render_topic_board_three_columns_unchanged_without_info(self, capsys: pytest.CaptureFixture[str]) -> None: """Regression gate — without ``info`` the three-column output is byte-identical.""" records = [ BoardRecord( @@ -295,9 +291,7 @@ def test_render_info_column_carries_todo_header(self, capsys: pytest.CaptureFixt assert "Pay retry cap" in lines[2] @pytest.mark.parametrize("todo", [None, ""]) - def test_render_todo_none_renders_empty_cell( - self, capsys: pytest.CaptureFixture[str], todo: str | None - ) -> None: + def test_render_todo_none_renders_empty_cell(self, capsys: pytest.CaptureFixture[str], todo: str | None) -> None: """A todo of None or of the empty string renders an empty padded cell.""" records = [ BoardRecord( diff --git a/tests/commands/topics/test_topics_command.py b/tests/commands/topics/test_topics_command.py index de633c42..d1c79bfa 100644 --- a/tests/commands/topics/test_topics_command.py +++ b/tests/commands/topics/test_topics_command.py @@ -45,6 +45,7 @@ # The facade __all__ lives on the cell package itself. _topics_facade = sys.modules["goga.commands.topics"] + class _TtyStdin(io.BytesIO): """A CliRunner input whose isatty() is True — models the confirm gate's terminal. @@ -365,9 +366,7 @@ def test_topics_board_info_flag_reaches_renderer(self, monkeypatch: pytest.Monke # The lambda tolerates any caller signature: pytest's own terminal # writer probes the width with ``fallback=`` while the patch is live, # and a zero-arg patch aborts the run as an INTERNALERROR. - monkeypatch.setattr( - shutil, "get_terminal_size", lambda *_args, **_kwargs: os.terminal_size((100, 24)) - ) + monkeypatch.setattr(shutil, "get_terminal_size", lambda *_args, **_kwargs: os.terminal_size((100, 24))) with mock.patch.object(_topics_module, "collect_topic_board", return_value=records): result = CliRunner().invoke(topics, ["board", "--info"]) assert result.exit_code == 0 @@ -456,9 +455,7 @@ def test_create_echoes_the_domain_result_line(self) -> None: mock_create.assert_called_once_with("Feature/Foo_Bar", "origin/main", None, False, None, None) assert result.output.splitlines() == ["Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar"] - def test_topics_create_todo_option_reaches_domain( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_topics_create_todo_option_reaches_domain(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """-t hands the domain (name, HEAD, todo, publish, template, year) verbatim.""" monkeypatch.chdir(tmp_path) with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: @@ -486,9 +483,7 @@ def test_create_flag_with_value_passes_todo(self, flag_form: list[str]) -> None: assert result.exit_code == 0 assert mock_create.call_args == mock.call("feat-a", "origin/main", "Payment retry", False, None, None) - def test_create_empty_todo_value_counts_as_absent( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_create_empty_todo_value_counts_as_absent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An explicitly empty --todo value is None at the call — never an entry marker. The CliRunner stdin is never a TTY, which is the point: without a @@ -601,9 +596,7 @@ def test_create_base_ref_flag_beats_config_beats_from_current( "language: python\ntopics:\n base_ref: origin/config-base\n publish_commit: cfg tpl\n", ) with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: - flag_template = CliRunner().invoke( - topics, ["create", "n4", "--publish", "-t", "T", "--commit", "x {slug}"] - ) + flag_template = CliRunner().invoke(topics, ["create", "n4", "--publish", "-t", "T", "--commit", "x {slug}"]) assert flag_template.exit_code == 0 assert mock_create.call_args == mock.call("n4", "origin/config-base", "T", True, "x {slug}", None) @@ -719,9 +712,7 @@ def test_create_publish_flag_template_with_config_base( ) as mock_load, mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create, ): - result = CliRunner().invoke( - topics, ["create", "X", "--publish", "-t", "T", "--commit", "flag: {slug}"] - ) + result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t", "T", "--commit", "flag: {slug}"]) assert result.exit_code == 0 mock_create.assert_called_once_with("X", "origin/config-base", "T", True, "flag: {slug}", None) # The base flag is absent, so the config is read for it. diff --git a/tests/config/test_loader.py b/tests/config/test_loader.py index 7fc72cc7..f240879a 100644 --- a/tests/config/test_loader.py +++ b/tests/config/test_loader.py @@ -3672,8 +3672,7 @@ def test_topics_section_parsed_verbatim(self, goga_project): """Both fields stored verbatim — {slug} braces survive, no grammar check.""" _write_goga_yml( goga_project, - "language: python\ntopics:\n base_ref: origin/release-1.3\n" - ' publish_commit: "chore: {slug}"\n', + 'language: python\ntopics:\n base_ref: origin/release-1.3\n publish_commit: "chore: {slug}"\n', ) config = load_project_config() assert config.topics == TopicsConfig(base_ref="origin/release-1.3", publish_commit="chore: {slug}") @@ -3709,7 +3708,7 @@ def test_topics_base_ref_unset_forms_normalize_to_none(self, goga_project, base_ """base_ref absent/YAML-null/empty/whitespace → None; publish_commit stays verbatim.""" _write_goga_yml( goga_project, - f"language: python\ntopics:\n {base_ref_yaml}\n publish_commit: \"chore: {{slug}}\"\n", + f'language: python\ntopics:\n {base_ref_yaml}\n publish_commit: "chore: {{slug}}"\n', ) config = load_project_config() assert config.topics is not None diff --git a/tests/config/test_project_cell_contract.py b/tests/config/test_project_cell_contract.py index 4715b64c..3a72cc93 100644 --- a/tests/config/test_project_cell_contract.py +++ b/tests/config/test_project_cell_contract.py @@ -15,6 +15,8 @@ load_project_config, ) +from tests.conftest import is_kw_only_dataclass + # --- Helpers --- @@ -95,7 +97,7 @@ def test_topics_config_is_frozen_kw_only_dataclass(self): """TopicsConfig is an immutable kw_only dataclass per `convention`.""" params = TopicsConfig.__dataclass_params__ assert params.frozen is True - assert params.kw_only is True + assert is_kw_only_dataclass(TopicsConfig) def test_topics_config_declares_exactly_the_two_fields(self): """The declared field set is exactly {base_ref, publish_commit}.""" diff --git a/tests/conftest.py b/tests/conftest.py index 560df71b..f4a8bc3d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import dataclasses import importlib import itertools import os @@ -50,6 +51,17 @@ def cwd(path): os.chdir(str(original)) +def is_kw_only_dataclass(cls: type) -> bool: + """Report whether every field of ``cls`` is keyword-only. + + Portable across Python 3.10 - 3.14: the class-level + ``_DataclassParams.kw_only`` attribute exists only from Python 3.12, so + ``cls.__dataclass_params__.kw_only`` is unusable on 3.10/3.11, while + ``dataclasses.fields()`` exposes the same fact on every version. + """ + return all(field.kw_only for field in dataclasses.fields(cls)) + + # --- shared cell-level usages fixtures (used by tests/usages and tests/commands) --- _CONFIG_HEADER = [ diff --git a/tests/history/git/test_refs.py b/tests/history/git/test_refs.py index 4b825d3d..c09f8692 100644 --- a/tests/history/git/test_refs.py +++ b/tests/history/git/test_refs.py @@ -150,9 +150,7 @@ def test_history_git_inventory_matches_topics_git(self) -> None: topics_result = topics_refs.list_branch_refs() assert [ref.name for ref in history_result] == [ref.name for ref in topics_result] - assert [(ref.name, ref.remote) for ref in history_result] == [ - (ref.name, ref.remote) for ref in topics_result - ] + assert [(ref.name, ref.remote) for ref in history_result] == [(ref.name, ref.remote) for ref in topics_result] def test_list_branch_refs_empty_repository(self) -> None: """An empty inventory is the norm, answered by exactly two calls.""" diff --git a/tests/history/statuses/test_assembly.py b/tests/history/statuses/test_assembly.py index a6c37610..13657f3c 100644 --- a/tests/history/statuses/test_assembly.py +++ b/tests/history/statuses/test_assembly.py @@ -187,9 +187,7 @@ def test_module_owns_no_package_enumeration(self) -> None: class TestAssembleEmission: - def test_assemble_emits_the_status_action_with_per_tool_registries( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_assemble_emits_the_status_action_with_per_tool_registries(self, monkeypatch: pytest.MonkeyPatch) -> None: """The cell emits the declared address; the view qualifies entries by the tool identity.""" hook = _hook({"name": "pub", "filepath": "p.md", "after": "planned"}) @@ -212,9 +210,7 @@ def test_assemble_no_package_enumeration_in_the_cell(self, monkeypatch: pytest.M enumeration.assert_not_called() - def test_assemble_registry_without_registrations_leaves_pure_axis( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_assemble_registry_without_registrations_leaves_pure_axis(self, monkeypatch: pytest.MonkeyPatch) -> None: """A subscribed tool whose hook registers nothing leaves the pure built-in axis.""" _fake_emission(monkeypatch, [("quiet", lambda _context: None)]) @@ -222,9 +218,7 @@ def test_assemble_registry_without_registrations_leaves_pure_axis( assert _names(scale) == _BUILTIN_NAMES - def test_assemble_context_for_hands_one_registry_per_tool_identity( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_assemble_context_for_hands_one_registry_per_tool_identity(self, monkeypatch: pytest.MonkeyPatch) -> None: """The view builder reuses one registry per tool identity and never shares it across tools.""" captured = _fake_emission(monkeypatch, []) assemble_status_scale() @@ -414,9 +408,7 @@ def test_assemble_unresolvable_before_anchor_skips( assert "unknown before anchor" in stderr assert _names(scale) == _BUILTIN_NAMES - def test_assemble_same_anchor_block_follows_delivery_order( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_assemble_same_anchor_block_follows_delivery_order(self, monkeypatch: pytest.MonkeyPatch) -> None: """The block follows the delivery order of the emission — the cell does not sort tools. The design-review q1 regression, re-based: sorting the packages is @@ -557,9 +549,7 @@ def test_assemble_rejected_registration_warns_and_continues( assert "Warning: hook mixed of tool a failed on statuses.register_statuses" in stderr assert "at least one anchor is required" in stderr - def test_assemble_broken_import_is_fatal_through_the_emission( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_assemble_broken_import_is_fatal_through_the_emission(self, monkeypatch: pytest.MonkeyPatch) -> None: """A broken package import is the only fatal case — it propagates through the emission.""" def emit_hook_event( diff --git a/tests/history/statuses/test_registry.py b/tests/history/statuses/test_registry.py index 03077765..5fa240ac 100644 --- a/tests/history/statuses/test_registry.py +++ b/tests/history/statuses/test_registry.py @@ -16,6 +16,8 @@ import pytest from goga.history.statuses import Stage, StatusRegistry, StatusScale +from tests.conftest import is_kw_only_dataclass + def _registry(builtin_scale: StatusScale, tool_prefix: str = "mkdocs") -> StatusRegistry: """A registry over the deterministic built-in axis of the cell fixture.""" @@ -61,7 +63,7 @@ def test_stages_property_returns_the_built_in_axis_plus_entries(self, builtin_sc def test_registry_is_not_frozen(self, builtin_scale: StatusScale) -> None: """Registration is add-only state — the registry itself stays mutable.""" assert not StatusRegistry.__dataclass_params__.frozen - assert StatusRegistry.__dataclass_params__.kw_only + assert is_kw_only_dataclass(StatusRegistry) # --- Logic tests --- diff --git a/tests/history/test_facade.py b/tests/history/test_facade.py index 91e6512d..9a02eb36 100644 --- a/tests/history/test_facade.py +++ b/tests/history/test_facade.py @@ -49,10 +49,7 @@ def test_history_facade_exports_twenty_one_names(self) -> None: def test_history_facade_embeds_the_git_branch_reader(self) -> None: """The embedded routine is the git leaf cell's object, not a copy.""" - assert ( - goga.history.resolve_current_branch_name - is goga.history.git.resolve_current_branch_name - ) + assert goga.history.resolve_current_branch_name is goga.history.git.resolve_current_branch_name def test_history_facade_embeds_the_git_branch_inventory(self) -> None: """The embedded inventory names are the git leaf cell's objects, not copies.""" diff --git a/tests/history/test_naming.py b/tests/history/test_naming.py index 312a6107..b6334c92 100644 --- a/tests/history/test_naming.py +++ b/tests/history/test_naming.py @@ -53,8 +53,7 @@ def test_normalize_topic_slug_signature(self) -> None: signature = inspect.signature(normalize_topic_slug) assert list(signature.parameters) == ["name"] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) hints = typing.get_type_hints(normalize_topic_slug) assert hints == {"name": str, "return": str} @@ -111,6 +110,7 @@ def test_current_year_returns_four_digits(self) -> None: def test_current_year_has_no_override_and_is_uncached(self) -> None: """Each call asks the clock anew — two pinned calls, two answers.""" + class _SteppingClock: calls = 0 diff --git a/tests/history/test_paths.py b/tests/history/test_paths.py index 5a2fb636..d334aa50 100644 --- a/tests/history/test_paths.py +++ b/tests/history/test_paths.py @@ -85,8 +85,7 @@ def test_resolve_topic_dir_signature(self) -> None: signature = inspect.signature(resolve_topic_dir) assert list(signature.parameters) == ["topic", "year"] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None hints = typing.get_type_hints(resolve_topic_dir) @@ -97,8 +96,7 @@ def test_resolve_topic_file_signature(self) -> None: signature = inspect.signature(resolve_topic_file) assert list(signature.parameters) == ["topic", "filename", "year"] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None hints = typing.get_type_hints(resolve_topic_file) @@ -109,8 +107,7 @@ def test_topic_exists_signature(self) -> None: signature = inspect.signature(topic_exists) assert list(signature.parameters) == ["topic", "year"] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None hints = typing.get_type_hints(topic_exists) @@ -128,8 +125,7 @@ def test_ensure_topic_dir_signature(self) -> None: signature = inspect.signature(ensure_topic_dir) assert list(signature.parameters) == ["name", "year"] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None hints = typing.get_type_hints(ensure_topic_dir) @@ -142,8 +138,7 @@ def test_remove_topic_dir_signature(self) -> None: signature = inspect.signature(remove_topic_dir) assert list(signature.parameters) == ["name", "year"] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None hints = typing.get_type_hints(remove_topic_dir) @@ -161,9 +156,7 @@ def test_history_root_helper_points_at_the_tree(self) -> None: class TestResolveHistoryRoot: - def test_resolve_history_root_composes_path( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_resolve_history_root_composes_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The composer answers the relative root — pure, nothing is created.""" monkeypatch.chdir(tmp_path) result = resolve_history_root() @@ -205,9 +198,7 @@ def test_resolve_topic_dir_empty_year_string_means_current( class TestResolveTopicFile: - def test_resolve_topic_file_appends_filename( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_resolve_topic_file_appends_filename(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The filename is appended verbatim; the file is not created.""" monkeypatch.chdir(tmp_path) path = resolve_topic_file("history-commands", "plan.md", year="2026") @@ -250,9 +241,7 @@ def test_topic_exists_absent_root_is_false(self, tmp_path: Path, monkeypatch: py class TestEnsureTopicDir: - def test_ensure_topic_dir_creates_explicit_year( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_ensure_topic_dir_creates_explicit_year(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An explicit year scopes creation to that year — the D1 current-year-only fix.""" monkeypatch.chdir(tmp_path) with mock.patch.object(naming, "datetime", _FixedClock): @@ -264,9 +253,7 @@ def test_ensure_topic_dir_creates_explicit_year( assert (tmp_path / ".goga" / "history" / "2025" / "feature-foo-bar").is_dir() assert not (tmp_path / ".goga" / "history" / "2031").exists() - def test_ensure_topic_dir_defaults_to_current_year( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_ensure_topic_dir_defaults_to_current_year(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Without a year the current year applies — the pre-existing behavior stands.""" monkeypatch.chdir(tmp_path) with mock.patch.object(naming, "datetime", _FixedClock): @@ -274,9 +261,7 @@ def test_ensure_topic_dir_defaults_to_current_year( assert created == Path(".goga/history/2031/x") assert (tmp_path / ".goga" / "history" / "2031" / "x").is_dir() - def test_ensure_topic_dir_creates_idempotently( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_ensure_topic_dir_creates_idempotently(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Creation normalizes, defaults to the current year, and is idempotent.""" monkeypatch.chdir(tmp_path) with mock.patch.object(naming, "datetime", _FixedClock): @@ -288,9 +273,7 @@ def test_ensure_topic_dir_creates_idempotently( assert first.is_dir() assert list(first.iterdir()) == [] - def test_ensure_topic_dir_creates_parents( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_ensure_topic_dir_creates_parents(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Missing parents (.goga/history/<year>) are created on the way.""" monkeypatch.chdir(tmp_path) with mock.patch.object(naming, "datetime", _FixedClock): @@ -298,9 +281,7 @@ def test_ensure_topic_dir_creates_parents( assert created == Path(".goga/history/2031/feat-y") assert (tmp_path / ".goga" / "history" / "2031").is_dir() - def test_ensure_topic_dir_empty_slug_raises( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_ensure_topic_dir_empty_slug_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An empty slug is the directory composer's clean error — nothing is created.""" monkeypatch.chdir(tmp_path) with pytest.raises(ValueError, match="normalizes to an empty topic slug"): @@ -321,9 +302,7 @@ def test_ensure_topic_dir_stray_file_propagates_oserror( class TestRemoveTopicDir: - def test_remove_topic_dir_deletes_whole_directory( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_remove_topic_dir_deletes_whole_directory(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The whole directory goes — nested ``completed/`` with it; the year directory stays.""" monkeypatch.chdir(tmp_path) topic_dir = tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" @@ -336,17 +315,13 @@ def test_remove_topic_dir_deletes_whole_directory( assert not (topic_dir / "completed").exists() assert (tmp_path / ".goga" / "history" / "2026").is_dir() - def test_remove_topic_dir_absent_returns_false( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_remove_topic_dir_absent_returns_false(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An absent directory is idempotent absence — False, not an error.""" monkeypatch.chdir(tmp_path) assert remove_topic_dir("absent-topic", "2026") is False assert not (tmp_path / ".goga").exists() - def test_remove_topic_dir_stray_file_returns_false( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_remove_topic_dir_stray_file_returns_false(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A stray file named like the slug does not occupy a topic — it stays in place.""" monkeypatch.chdir(tmp_path) year_dir = tmp_path / ".goga" / "history" / "2026" @@ -356,9 +331,7 @@ def test_remove_topic_dir_stray_file_returns_false( assert remove_topic_dir("feat-a", "2026") is False assert (year_dir / "feat-a").is_file() - def test_remove_topic_dir_empty_slug_raises( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_remove_topic_dir_empty_slug_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An empty slug is the directory composer's clean error — nothing is deleted.""" monkeypatch.chdir(tmp_path) with pytest.raises(ValueError, match="normalizes to an empty topic slug"): diff --git a/tests/history/test_status.py b/tests/history/test_status.py index efd1dac7..abfb0561 100644 --- a/tests/history/test_status.py +++ b/tests/history/test_status.py @@ -32,6 +32,8 @@ ) from goga.history.statuses import Stage, StatusScale +from tests.conftest import is_kw_only_dataclass + class _FixedClock: """Stand-in for ``datetime`` answering a fixed naive date.""" @@ -99,7 +101,7 @@ def test_topic_record_is_frozen_kw_only_dataclass(self) -> None: """``@dataclass(frozen=True, kw_only=True)`` with the fields ``topic`` and ``statuses``.""" assert dataclasses.is_dataclass(TopicRecord) assert TopicRecord.__dataclass_params__.frozen is True - assert TopicRecord.__dataclass_params__.kw_only is True + assert is_kw_only_dataclass(TopicRecord) assert typing.get_type_hints(TopicRecord) == {"topic": str, "statuses": list[str]} record = TopicRecord(topic="t", statuses=["planned", "mkdocs.published"]) assert record.topic == "t" @@ -114,8 +116,7 @@ def test_resolve_topic_status_signature(self) -> None: signature = inspect.signature(resolve_topic_status) assert list(signature.parameters) == ["topic_dir", "scale"] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) hints = typing.get_type_hints(resolve_topic_status) assert hints == {"topic_dir": Path, "scale": StatusScale, "return": list[str]} @@ -125,8 +126,7 @@ def test_collect_topic_statuses_signature(self) -> None: signature = inspect.signature(collect_topic_statuses) assert list(signature.parameters) == ["year", "scale"] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None assert signature.parameters["scale"].default is None @@ -194,9 +194,7 @@ def test_resolve_topic_status_empty_when_no_artifact_present( (stray_dir / "notes.md").write_text("outside the scale", encoding="utf-8") assert resolve_topic_status(stray_dir, _builtin_scale()) == ["empty"] - def test_resolve_topic_status_multi_statuses( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_resolve_topic_status_multi_statuses(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A tool artifact outranks the built-in entry it is anchored after.""" monkeypatch.chdir(tmp_path) year_dir = tmp_path / ".goga" / "history" / "2026" @@ -212,9 +210,7 @@ def test_resolve_topic_status_multi_statuses( class TestCollectTopicStatuses: - def test_collect_topic_statuses_sorted_records( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_collect_topic_statuses_sorted_records(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Only directories count as topics; records are sorted with resolved statuses.""" monkeypatch.chdir(tmp_path) year_dir = tmp_path / ".goga" / "history" / "2026" @@ -230,9 +226,7 @@ def test_collect_topic_statuses_sorted_records( assert records[1].statuses == ["defined"] assert records[2].statuses == ["empty"] - def test_collect_topic_statuses_reuses_scale( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_collect_topic_statuses_reuses_scale(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A passed scale is used as-is — the assembly is not repeated per run.""" monkeypatch.chdir(tmp_path) year_dir = tmp_path / ".goga" / "history" / "2026" @@ -263,9 +257,7 @@ def test_collect_topic_statuses_assembles_scale_once_when_none( assert records[1].statuses == ["empty"] assert assemble.call_count == 1 - def test_collect_topic_statuses_absent_year_empty( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_collect_topic_statuses_absent_year_empty(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An absent year yields an empty list — not an error, and nothing is created.""" monkeypatch.chdir(tmp_path) assert collect_topic_statuses(year="1999", scale=_builtin_scale()) == [] diff --git a/tests/history/test_tree.py b/tests/history/test_tree.py index 5f0a0d0b..5e3cd0fe 100644 --- a/tests/history/test_tree.py +++ b/tests/history/test_tree.py @@ -22,6 +22,8 @@ from goga.history import tree from goga.history.tree import HistoryYear, collect_history_tree +from tests.conftest import is_kw_only_dataclass + # --- Contract tests --- @@ -45,7 +47,7 @@ def test_history_year_is_frozen_kw_only_dataclass(self) -> None: """``@dataclass(frozen=True, kw_only=True)`` with the fields ``year`` and ``topics``.""" assert dataclasses.is_dataclass(HistoryYear) assert HistoryYear.__dataclass_params__.frozen is True - assert HistoryYear.__dataclass_params__.kw_only is True + assert is_kw_only_dataclass(HistoryYear) assert typing.get_type_hints(HistoryYear) == {"year": str, "topics": list[str]} record = HistoryYear(year="2026", topics=["history-commands"]) assert record.year == "2026" diff --git a/tests/hooks/catalog/test_catalog.py b/tests/hooks/catalog/test_catalog.py index 887481f8..52d52ffe 100644 --- a/tests/hooks/catalog/test_catalog.py +++ b/tests/hooks/catalog/test_catalog.py @@ -19,6 +19,8 @@ import pytest from goga.hooks.catalog import Action, declared_actions +from tests.conftest import is_kw_only_dataclass + # --- Contract tests --- @@ -41,7 +43,7 @@ def test_action_is_a_kw_only_frozen_dataclass(self) -> None: assert dataclasses.is_dataclass(Action) assert Action.__dataclass_params__.frozen - assert Action.__dataclass_params__.kw_only + assert is_kw_only_dataclass(Action) with pytest.raises(TypeError): Action("statuses", "register_statuses", "soft") # type: ignore[misc] diff --git a/tests/hooks/registry/test_state.py b/tests/hooks/registry/test_state.py index 8d1a531f..5b8b17a1 100644 --- a/tests/hooks/registry/test_state.py +++ b/tests/hooks/registry/test_state.py @@ -28,6 +28,8 @@ from goga.hooks.registry import HookRegistry, ToolContext, ToolHooks, state from goga.hooks.tools import RejectedRegistration, Subscription +from tests.conftest import is_kw_only_dataclass + _CELL_ALL = ["HookRegistry", "ToolContext", "ToolHooks"] @@ -80,7 +82,7 @@ def test_hook_registry_constructs_with_no_arguments(self) -> None: assert isinstance(registry, HookRegistry) assert dataclasses.is_dataclass(HookRegistry) - assert HookRegistry.__dataclass_params__.kw_only + assert is_kw_only_dataclass(HookRegistry) assert not HookRegistry.__dataclass_params__.frozen assert [(f.name, f.init, f.repr) for f in dataclasses.fields(HookRegistry)] == [ @@ -101,9 +103,7 @@ def test_method_signatures(self) -> None: "self_context": ["self", "tool"], "by_tool": ["self"], } - return_hints = { - name: typing.get_type_hints(getattr(HookRegistry, name))["return"] for name in expected - } + return_hints = {name: typing.get_type_hints(getattr(HookRegistry, name))["return"] for name in expected} for name, parameters in expected.items(): assert list(inspect.signature(getattr(HookRegistry, name)).parameters) == parameters @@ -148,7 +148,7 @@ def test_tool_context_is_kw_only_mutable_and_open(self) -> None: assert context.tool == "t" assert dataclasses.is_dataclass(ToolContext) - assert ToolContext.__dataclass_params__.kw_only + assert is_kw_only_dataclass(ToolContext) assert not ToolContext.__dataclass_params__.frozen assert [f.name for f in dataclasses.fields(ToolContext)] == ["tool"] @@ -184,7 +184,7 @@ def test_tool_hooks_is_a_kw_only_frozen_record(self) -> None: assert dataclasses.is_dataclass(ToolHooks) assert ToolHooks.__dataclass_params__.frozen - assert ToolHooks.__dataclass_params__.kw_only + assert is_kw_only_dataclass(ToolHooks) assert [f.name for f in dataclasses.fields(ToolHooks)] == [ "tool", "subscriptions", @@ -208,9 +208,7 @@ def test_build_once_collects_in_enumeration_order( install_tool_package, ) -> None: """Subscriptions land in enumeration order, qualified by package identity.""" - pin_package_environment( - {"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]} - ) + pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) install_tool_package("goga_tool_a", register_hooks=_subscribe("one")) install_tool_package("goga_tool_b", register_hooks=_subscribe("two")) registry = HookRegistry() @@ -242,9 +240,7 @@ def test_build_once_skips_a_package_without_the_callback_quietly( capsys: pytest.CaptureFixture[str], ) -> None: """A facade without ``register_hooks`` is a quiet skip — no warning.""" - pin_package_environment( - {"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]} - ) + pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) install_tool_package("goga_tool_a") # no callback on the facade install_tool_package("goga_tool_b", register_hooks=_subscribe("two")) registry = HookRegistry() @@ -286,9 +282,7 @@ def test_build_once_callback_crash_warns_and_keeps_partial_registrations( capsys: pytest.CaptureFixture[str], ) -> None: """A crashed callback ends its own registration only — the rest runs.""" - pin_package_environment( - {"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]} - ) + pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) def crashing(hooks: object) -> None: hooks.subscribe("statuses", "register_statuses", "first", _noop_hook) # type: ignore[attr-defined] @@ -352,9 +346,7 @@ def test_build_once_callback_importerror_is_warning_not_fatal( capsys: pytest.CaptureFixture[str], ) -> None: """An import failure raised inside a callback is a crash, not the fatal case.""" - pin_package_environment( - {"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]} - ) + pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) def crashing(hooks: object) -> None: hooks.subscribe("statuses", "register_statuses", "first", _noop_hook) # type: ignore[attr-defined] @@ -380,21 +372,15 @@ def test_subscriptions_for_returns_the_address_subscriptions_in_order( install_tool_package, ) -> None: """Exact address match, enumeration order.""" - pin_package_environment( - {"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]} - ) + pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) install_tool_package("goga_tool_a", register_hooks=_subscribe("one")) install_tool_package("goga_tool_b", register_hooks=_subscribe("two")) registry = HookRegistry() registry.build_once() - assert [ - s.name for s in registry.subscriptions_for("statuses", "register_statuses") - ] == ["one", "two"] - assert [ - s.name for s in registry.subscriptions_for("statuses", "register_other") - ] == [] + assert [s.name for s in registry.subscriptions_for("statuses", "register_statuses")] == ["one", "two"] + assert [s.name for s in registry.subscriptions_for("statuses", "register_other")] == [] def test_subscriptions_for_empty_address_is_not_an_error( self, @@ -428,9 +414,7 @@ def test_by_tool_groups_alphabetically_with_rejections( install_tool_package, ) -> None: """One entry per tool, alphabetical — both lists on the same entry.""" - pin_package_environment( - {"goga_tool_b": ["goga-tool-b"], "goga_tool_a": ["goga-tool-a"]} - ) + pin_package_environment({"goga_tool_b": ["goga-tool-b"], "goga_tool_a": ["goga-tool-a"]}) install_tool_package("goga_tool_b", register_hooks=_subscribe("kept")) def refused(hooks: object) -> None: diff --git a/tests/hooks/tools/test_packages.py b/tests/hooks/tools/test_packages.py index 9f315e03..47365dd9 100644 --- a/tests/hooks/tools/test_packages.py +++ b/tests/hooks/tools/test_packages.py @@ -28,6 +28,8 @@ enumerate_tool_packages, ) +from tests.conftest import is_kw_only_dataclass + # --- Contract tests --- @@ -61,7 +63,7 @@ def test_tool_package_is_kw_only_frozen_with_computed_properties(self) -> None: assert dataclasses.is_dataclass(ToolPackage) assert ToolPackage.__dataclass_params__.frozen - assert ToolPackage.__dataclass_params__.kw_only + assert is_kw_only_dataclass(ToolPackage) assert package.module_name == "goga_tool_x" assert [field.name for field in dataclasses.fields(ToolPackage)] == ["module_name"] diff --git a/tests/hooks/tools/test_registration.py b/tests/hooks/tools/test_registration.py index 47c10c06..96748139 100644 --- a/tests/hooks/tools/test_registration.py +++ b/tests/hooks/tools/test_registration.py @@ -23,6 +23,8 @@ from goga.hooks.catalog import Action from goga.hooks.tools import HookRegistrar, RejectedRegistration, Subscription, registration +from tests.conftest import is_kw_only_dataclass + _CELL_ALL = [ "HookRegistrar", "RejectedRegistration", @@ -84,7 +86,7 @@ def test_hook_registrar_is_a_kw_only_accumulating_dataclass(self) -> None: assert registrar.tool == "t" assert dataclasses.is_dataclass(HookRegistrar) - assert HookRegistrar.__dataclass_params__.kw_only + assert is_kw_only_dataclass(HookRegistrar) assert not HookRegistrar.__dataclass_params__.frozen assert [(f.name, f.init, f.repr) for f in dataclasses.fields(HookRegistrar)] == [ @@ -122,7 +124,7 @@ def test_subscription_is_a_kw_only_frozen_record(self) -> None: assert dataclasses.is_dataclass(Subscription) assert Subscription.__dataclass_params__.frozen - assert Subscription.__dataclass_params__.kw_only + assert is_kw_only_dataclass(Subscription) assert [f.name for f in dataclasses.fields(Subscription)] == [ "tool", "domain", @@ -155,7 +157,7 @@ def test_rejected_registration_is_a_kw_only_frozen_record(self) -> None: assert dataclasses.is_dataclass(RejectedRegistration) assert RejectedRegistration.__dataclass_params__.frozen - assert RejectedRegistration.__dataclass_params__.kw_only + assert is_kw_only_dataclass(RejectedRegistration) assert [f.name for f in dataclasses.fields(RejectedRegistration)] == [ "tool", "domain", @@ -311,9 +313,7 @@ def test_subscribe_non_string_name_is_rejected_with_an_empty_name( assert registrar.subscriptions == [] assert registrar.rejections[0].name == "" assert registrar.rejections[0].reason == "name must be a non-empty string" - assert capsys.readouterr().err.startswith( - "Warning: rejected hook of tool t on statuses.register_statuses:" - ) + assert capsys.readouterr().err.startswith("Warning: rejected hook of tool t on statuses.register_statuses:") def test_subscribe_repeats_are_refused_per_registrar(self) -> None: """Two tools hold separate registrars — the same name applies for both.""" diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index d42f11e4..534cda7f 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -108,9 +108,7 @@ def _git_out(root: Path, *args: str) -> str: Returns: The stripped stdout of the command. """ - result = subprocess.run( - ["git", *args], cwd=root, check=True, capture_output=True, text=True - ) + result = subprocess.run(["git", *args], cwd=root, check=True, capture_output=True, text=True) return result.stdout.strip() @@ -126,9 +124,7 @@ def _worktree_snapshot(root: Path) -> list[str]: state the user sees and the quarantine invariant protects. """ return sorted( - path.relative_to(root).as_posix() - for path in root.rglob("*") - if path.relative_to(root).parts[0] != ".git" + path.relative_to(root).as_posix() for path in root.rglob("*") if path.relative_to(root).parts[0] != ".git" ) @@ -173,9 +169,7 @@ def _current_branch(root: Path) -> str: Returns: The current branch name as git reports it. """ - result = subprocess.run( - ["git", "branch", "--show-current"], cwd=root, check=True, capture_output=True, text=True - ) + result = subprocess.run(["git", "branch", "--show-current"], cwd=root, check=True, capture_output=True, text=True) return result.stdout.strip() @@ -316,9 +310,7 @@ def test_board_empty_year_prints_nothing_and_exits_zero( class TestHistoryStatusToolFilter: """``goga history status -s <tool>.<name>`` against the real assembly.""" - def test_qualified_tool_status_validates_and_filters( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_qualified_tool_status_validates_and_filters(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A registered tool status validates by its qualified name and keeps exactly the topics carrying it. @@ -455,9 +447,7 @@ def test_switch_on_current_host_is_idempotent_without_mutations( assert checkout.called is False assert create_branch.called is False - def test_switch_by_slug_checks_out_local_host( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_switch_by_slug_checks_out_local_host(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A slug identifier resolves to its single local host and checks it out for real.""" _init_topic_repo(tmp_path) _add_solo_branch(tmp_path) @@ -537,9 +527,7 @@ def test_switch_by_slug_creates_branch_from_remote_tracking( assert line == "Created branch feat-a from origin/feat-a" assert _current_branch(tmp_path) == "feat-a" - def test_switch_refuses_dirty_working_tree( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_switch_refuses_dirty_working_tree(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A dirty working tree is a clean error — the repository stays put.""" _init_topic_repo(tmp_path) _add_solo_branch(tmp_path) @@ -585,9 +573,7 @@ def test_create_topic_occupied_local_branch_errors_non_interactively( """An existing branch name is a clean occupancy error — nothing is created.""" _init_topic_repo(tmp_path) monkeypatch.chdir(tmp_path) - monkeypatch.setattr( - sys, "stdin", mock.Mock(**{"isatty.return_value": False}) - ) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) with pytest.raises(click.ClickException, match="already exists"): create_topic("feat-b", "HEAD", year="2025") @@ -602,9 +588,7 @@ def test_create_topic_empty_todo_without_terminal_is_clean_error( is a clean error and nothing is created.""" _init_topic_repo(tmp_path) monkeypatch.chdir(tmp_path) - monkeypatch.setattr( - sys, "stdin", mock.Mock(**{"isatty.return_value": False}) - ) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) with pytest.raises(click.ClickException, match="--todo"): create_topic("feat-empty", "HEAD", todo="", year="2025") @@ -617,9 +601,7 @@ def test_create_topic_empty_todo_without_terminal_is_clean_error( class TestTopicsBoardTodos: """The todo column of ``goga topics board --info`` over real reads.""" - def test_board_survives_hand_edited_non_utf8_todos( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_board_survives_hand_edited_non_utf8_todos(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Todo summaries outside UTF-8 render with the replacement character. Both todo.md reads — the working copy through pathlib and the ref @@ -632,16 +614,12 @@ def test_board_survives_hand_edited_non_utf8_todos( _init_topic_repo(tmp_path) # The committed side: feat-b's todo lives in its ref tree only. _git(tmp_path, "switch", "-q", "feat-b") - (tmp_path / ".goga" / "history" / "2025" / "feat-b" / "todo.md").write_bytes( - b"###\nRem\xffote\n" - ) + (tmp_path / ".goga" / "history" / "2025" / "feat-b" / "todo.md").write_bytes(b"###\nRem\xffote\n") _git(tmp_path, "add", ".goga") _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "feat-b todo") _git(tmp_path, "switch", "-q", "feat-a") # The uncommitted side: the current branch's working-copy todo. - (tmp_path / ".goga" / "history" / "2025" / "feat-a" / "todo.md").write_bytes( - b"###\nPay\xffment\n" - ) + (tmp_path / ".goga" / "history" / "2025" / "feat-a" / "todo.md").write_bytes(b"###\nPay\xffment\n") monkeypatch.chdir(tmp_path) monkeypatch.setenv("COLUMNS", "120") @@ -687,9 +665,7 @@ def test_create_todo_then_board_info_shows_summary_and_todo_status( result = CliRunner().invoke(topics, ["--year", "2025", "board", "--info"]) assert result.exit_code == 0 - assert ("* feat-new", "feat-new", "Pay retry cap", "[todo]") in _board_rows( - result.output, columns=4 - ) + assert ("* feat-new", "feat-new", "Pay retry cap", "[todo]") in _board_rows(result.output, columns=4) def test_board_old_title_txt_only_topic_is_empty_status( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -714,15 +690,10 @@ def test_board_old_title_txt_only_topic_is_empty_status( result = CliRunner().invoke(topics, ["--year", "2025", "board", "--info"]) assert result.exit_code == 0 - assert ("legacy-work", "legacy", "", "[empty]") in _board_rows( - result.output, columns=4 - ) + assert ("legacy-work", "legacy", "", "[empty]") in _board_rows(result.output, columns=4) # The legacy file stays byte-exact in its ref tree — the board read # it and dropped it as an unknown artifact, it never rewrote it. - assert ( - _git_out(tmp_path, "show", "legacy:.goga/history/2025/legacy-work/title.txt") - == "Retired artifact" - ) + assert _git_out(tmp_path, "show", "legacy:.goga/history/2025/legacy-work/title.txt") == "Retired artifact" @requires_git @@ -807,9 +778,7 @@ def test_publish_end_to_end_leaves_user_state_untouched( _worktree_snapshot(tmp_path), ) - line = publish_topic( - "Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}" - ) + line = publish_topic("Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}") after = ( _git_out(tmp_path, "rev-parse", "HEAD"), @@ -833,17 +802,12 @@ def test_publish_creates_single_todo_commit_and_shows_on_remote_board( year = current_year() topic_path = f".goga/history/{year}/feature-foo-bar/todo.md" - publish_topic( - "Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}" - ) + publish_topic("Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}") assert _git_out(tmp_path, "rev-list", "--count", "origin/main..Feature/Foo_Bar") == "1" - assert _git_out(tmp_path, "show", "--name-only", "--format=", "Feature/Foo_Bar").splitlines() == [ - topic_path - ] + assert _git_out(tmp_path, "show", "--name-only", "--format=", "Feature/Foo_Bar").splitlines() == [topic_path] assert ( - _git_out(tmp_path, "show", "-s", "--format=%s", "Feature/Foo_Bar") - == "goga: create topic feature-foo-bar" + _git_out(tmp_path, "show", "-s", "--format=%s", "Feature/Foo_Bar") == "goga: create topic feature-foo-bar" ) assert _git_out(tmp_path, "show", f"Feature/Foo_Bar:{topic_path}") == "Payment retry" assert _git_out(tmp_path, "config", "branch.Feature/Foo_Bar.remote") == "origin" @@ -869,25 +833,19 @@ def test_publish_failed_push_rolls_back_and_rerun_succeeds( porcelain_before = _git_out(tmp_path, "status", "--porcelain") with pytest.raises(click.ClickException, match="git failed:"): - publish_topic( - "Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}" - ) + publish_topic("Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}") assert "Feature/Foo_Bar" not in _git_out(tmp_path, "for-each-ref", "refs/heads") assert _git_out(tmp_path, "status", "--porcelain") == porcelain_before _git(tmp_path, "remote", "set-url", "--push", "origin", str(origin)) - line = publish_topic( - "Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}" - ) + line = publish_topic("Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}") assert "refs/heads/Feature/Foo_Bar" in _git_out(tmp_path, "for-each-ref", "refs/heads") assert _git_out(tmp_path, "rev-parse", "--verify", "refs/remotes/origin/Feature/Foo_Bar") assert "published topic" in line - def test_publish_non_ascii_todo_survives_utf8( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_publish_non_ascii_todo_survives_utf8(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A non-ASCII todo round-trips byte-exact — UTF-8 with one trailing newline, in the branch tree and on the remote board.""" _init_publish_repo(tmp_path) @@ -896,9 +854,7 @@ def test_publish_non_ascii_todo_survives_utf8( year = current_year() topic_path = f".goga/history/{year}/feature-foo-bar/todo.md" - publish_topic( - "Feature/Foo_Bar", "Оплата повторно", "origin/main", "goga: create topic {slug}" - ) + publish_topic("Feature/Foo_Bar", "Оплата повторно", "origin/main", "goga: create topic {slug}") shown = subprocess.run( ["git", "show", f"Feature/Foo_Bar:{topic_path}"], @@ -979,9 +935,7 @@ def test_publish_from_a_subdirectory_still_sees_the_branch_tree_conflict( assert "Feature/Foo_Bar" not in _git_out(tmp_path, "for-each-ref", "refs/heads") assert "Feature/Foo_Bar" not in _git_out(tmp_path, "ls-remote", "--heads", "origin") - def test_publish_sibling_slug_is_not_a_conflict( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_publish_sibling_slug_is_not_a_conflict(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A sibling slug sharing only the prefix text stays free. ``feature-foo-bar`` hosted by another branch must not block @@ -1075,9 +1029,7 @@ def test_publish_non_utf8_remote_output_rolls_back_and_surfaces_clean_error( publish_topic("Feature/Foo_Bar", "Todo", "origin/main", "goga: create topic {slug}") # The planted branch was rolled back — nothing of the cycle survives. - assert "refs/heads/Feature/Foo_Bar" not in _git_out( - tmp_path, "for-each-ref", "refs/heads" - ) + assert "refs/heads/Feature/Foo_Bar" not in _git_out(tmp_path, "for-each-ref", "refs/heads") assert not (tmp_path / ".goga").exists() def test_publish_newline_in_name_cannot_inject_a_second_command( @@ -1102,9 +1054,7 @@ def test_publish_newline_in_name_cannot_inject_a_second_command( assert _git_out(tmp_path, "rev-parse", "refs/heads/main") == base assert _git_out(tmp_path, "for-each-ref", "--format=%(refname)", "refs/heads") == "refs/heads/main" - assert "refs/remotes/origin/evil" not in _git_out( - tmp_path, "for-each-ref", "refs/remotes/origin" - ) + assert "refs/remotes/origin/evil" not in _git_out(tmp_path, "for-each-ref", "refs/remotes/origin") def test_publish_empty_template_does_not_wait_for_stdin( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -1135,9 +1085,7 @@ def test_publish_empty_template_does_not_wait_for_stdin( def cycle() -> None: try: os.dup2(read_fd, 0) - lines.append( - publish_topic("Feature/Foo_Bar", "Payment retry", "origin/main", "") - ) + lines.append(publish_topic("Feature/Foo_Bar", "Payment retry", "origin/main", "")) except BaseException as exc: # recorded, re-raised on the main thread below failure.append(exc) finally: @@ -1152,20 +1100,15 @@ def cycle() -> None: os.close(saved_stdin) assert not alive, ( - "publish_topic with an empty template never returned — " - "a git invocation is waiting on the caller's stdin" + "publish_topic with an empty template never returned — a git invocation is waiting on the caller's stdin" ) if failure: raise failure[0] - assert lines[0] == ( - f"Created branch Feature/Foo_Bar and published topic {year}/feature-foo-bar" - ) + assert lines[0] == (f"Created branch Feature/Foo_Bar and published topic {year}/feature-foo-bar") # The published commit carries the empty message verbatim. assert _git_out(tmp_path, "show", "-s", "--format=%s", "Feature/Foo_Bar") == "" assert ( - _git_out( - tmp_path, "show", f"Feature/Foo_Bar:.goga/history/{year}/feature-foo-bar/todo.md" - ) + _git_out(tmp_path, "show", f"Feature/Foo_Bar:.goga/history/{year}/feature-foo-bar/todo.md") == "Payment retry" ) @@ -1302,9 +1245,7 @@ def test_delete_current_branch_hosting_target_is_an_error( assert _git_out(tmp_path, "rev-parse", "--verify", "refs/heads/Feature/Foo_Bar") - def test_delete_cli_round_trip_with_yes( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_delete_cli_round_trip_with_yes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The CLI surface round-trips: resolution, skipped confirmation, removal, one result line — and without ``--yes`` a non-terminal is a clean error before anything is deleted.""" @@ -1318,9 +1259,7 @@ def test_delete_cli_round_trip_with_yes( assert "interactive terminal" in declined.output assert _git_out(tmp_path, "rev-parse", "--verify", "refs/heads/Feature/Foo_Bar") - result = CliRunner().invoke( - topics, ["--year", year, "delete", "feature-foo-bar", "--yes"] - ) + result = CliRunner().invoke(topics, ["--year", year, "delete", "feature-foo-bar", "--yes"]) assert result.exit_code == 0 assert result.output == f"Deleted 1 topic(s) of {year}: feature-foo-bar\n" @@ -1347,9 +1286,7 @@ def test_delete_unpublished_topic_by_exact_name_over_real_git( targets = resolve_delete_targets(["feature-foo"], year=year) - assert targets == [ - DeleteTarget(topic="feature-foo", branch=None, remote=None, has_dir=True) - ] + assert targets == [DeleteTarget(topic="feature-foo", branch=None, remote=None, has_dir=True)] line = delete_topics(targets, year=year) assert line == f"Deleted 1 topic(s) of {year}: feature-foo" diff --git a/tests/pipeline/compiler/test_compile_flow_memory.py b/tests/pipeline/compiler/test_compile_flow_memory.py index 922e1b5e..9431b51a 100644 --- a/tests/pipeline/compiler/test_compile_flow_memory.py +++ b/tests/pipeline/compiler/test_compile_flow_memory.py @@ -249,15 +249,7 @@ def test_compile_flow_alignment_emits_block_and_marks_every_stage(self, tmp_path def test_compile_flow_alignment_authored_mode_carries_verbatim(self, tmp_path: Path) -> None: """An authored alignment ``mode`` reaches the block verbatim (materialization is the fallback).""" - workflow_text = ( - "memory:\n" - " method: alignment\n" - " path: p\n" - " mode: r\n" - "stages:\n" - " build:\n" - " memory: true\n" - ) + workflow_text = "memory:\n method: alignment\n path: p\n mode: r\nstages:\n build:\n memory: true\n" _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) assert flow_doc.memory is not None @@ -268,13 +260,7 @@ def test_compile_flow_alignment_authored_mode_carries_verbatim(self, tmp_path: P def test_compile_flow_reflect_slot_after_script_timeout(self, tmp_path: Path) -> None: """The stage ``reflect`` key occupies the canonical slot immediately after ``script_timeout``.""" pipeline_text = ( - "name: demo\n" - "description: Demo pipeline\n" - "---\n" - "build:\n" - " title: Build\n" - " script: make build\n" - " timeout: 5m\n" + "name: demo\ndescription: Demo pipeline\n---\nbuild:\n title: Build\n script: make build\n timeout: 5m\n" ) _pipeline_doc, flow_doc, _text = _compile( tmp_path, @@ -302,14 +288,7 @@ def test_compile_flow_reflect_uniform_across_loop_copies(self, tmp_path: Path) - def test_compile_flow_alignment_uniform_across_loop_copies(self, tmp_path: Path) -> None: """Every loop-expanded copy carries its base's ``memory_use`` — participants and opt-outs alike.""" - workflow_text = ( - "memory:\n" - " method: alignment\n" - "stages:\n" - " brainstorm:\n" - " loop: 3\n" - " memory: true\n" - ) + workflow_text = "memory:\n method: alignment\nstages:\n brainstorm:\n loop: 3\n memory: true\n" _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) copies = [stage for stage in flow_doc.stages if stage.id.startswith("brainstorm")] @@ -410,18 +389,9 @@ def test_compile_flow_skip_of_only_participating_stage_emits_no_block(self, tmp_ assert "memory:" not in text assert all("reflect" not in stage.fields and "memory_use" not in stage.fields for stage in flow_doc.stages) - def test_compile_flow_alignment_skip_of_only_participating_stage_emits_no_block( - self, tmp_path: Path - ) -> None: + def test_compile_flow_alignment_skip_of_only_participating_stage_emits_no_block(self, tmp_path: Path) -> None: """Under alignment a skipped participant dies with its instruction — no block, no keys.""" - workflow_text = ( - "memory:\n" - " method: alignment\n" - "stages:\n" - " build:\n" - " memory: true\n" - " skip: true\n" - ) + workflow_text = "memory:\n method: alignment\nstages:\n build:\n memory: true\n skip: true\n" _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) assert flow_doc.memory is None @@ -431,13 +401,7 @@ def test_compile_flow_alignment_skip_of_only_participating_stage_emits_no_block( def test_compile_flow_reflect_block_carries_mode_r_and_memory_use_false(self, tmp_path: Path) -> None: """Emission case 6 — the reflect-method block carries mode: r and memory_use: false.""" workflow_text = ( - "memory:\n" - " max_rules: 9\n" - " commit: true\n" - "stages:\n" - " brainstorm:\n" - " reflect:\n" - " file: shared.md\n" + "memory:\n max_rules: 9\n commit: true\nstages:\n brainstorm:\n reflect:\n file: shared.md\n" ) _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) @@ -542,9 +506,7 @@ def test_memory_emission_default_config_supplies_block_values(self) -> None: emission = _memory_emission(workflow, effective, {"build": ["build"]}) - assert emission.block == FlowMemory( - path=".goga/memory", mode="r", memory_use=False, max_rules=25, commit=False - ) + assert emission.block == FlowMemory(path=".goga/memory", mode="r", memory_use=False, max_rules=25, commit=False) assert emission.keys_by_id == {"build": {"reflect": {"file": "a.md", "mode": "rw"}}} def test_memory_emission_alignment_marks_every_final_id(self) -> None: diff --git a/tests/pipeline/compiler/test_compile_flow_memory_integration.py b/tests/pipeline/compiler/test_compile_flow_memory_integration.py index 3382c968..95f68c50 100644 --- a/tests/pipeline/compiler/test_compile_flow_memory_integration.py +++ b/tests/pipeline/compiler/test_compile_flow_memory_integration.py @@ -80,25 +80,13 @@ # A reflect-method workflow that participates (emission case 6 — a block with # a reflect instruction): the block carries the fixed mode: r and the global # memory_use: false alongside path / max_rules / commit. -_REFLECT_WORKFLOW = ( - "memory:\n" - " max_rules: 40\n" - "stages:\n" - " brainstorm:\n" - " reflect:\n" - " file: shared.md\n" -) +_REFLECT_WORKFLOW = "memory:\n max_rules: 40\nstages:\n brainstorm:\n reflect:\n file: shared.md\n" # An alignment-method workflow that participates (emission case 4): the block # carries the composed path, the materialized mode (rw by default), and the # global memory_use: false. _ALIGNMENT_WORKFLOW = ( - "memory:\n" - " method: alignment\n" - " path: goga-development\n" - "stages:\n" - " brainstorm:\n" - " memory: true\n" + "memory:\n method: alignment\n path: goga-development\nstages:\n brainstorm:\n memory: true\n" ) diff --git a/tests/pipeline/compiler/test_compile_flow_notes.py b/tests/pipeline/compiler/test_compile_flow_notes.py index e3560226..59dc425b 100644 --- a/tests/pipeline/compiler/test_compile_flow_notes.py +++ b/tests/pipeline/compiler/test_compile_flow_notes.py @@ -83,11 +83,7 @@ def test_notes_assemble_buttons_after_description(self, tmp_path: Path) -> None: flow_text = _compile( tmp_path, _HEADER + "s:\n title: S\n prompt: do work\n", - "stages:\n" - " s:\n" - " prompt: override\n" - " notes:\n" - " fix: Fix it\n", + "stages:\n s:\n prompt: override\n notes:\n fix: Fix it\n", ) keys = list(yaml.safe_load(flow_text)["stages"][0]) @@ -166,12 +162,7 @@ def test_multiline_note_value_compiles_block_literal(self, tmp_path: Path) -> No flow_text = _compile( tmp_path, _HEADER + "s:\n title: S\n prompt: do work\n", - "stages:\n" - " s:\n" - " notes:\n" - " fix: |-\n" - " Line1\n" - " Line2\n", + "stages:\n s:\n notes:\n fix: |-\n Line1\n Line2\n", ) stage = yaml.safe_load(flow_text)["stages"][0] diff --git a/tests/pipeline/compiler/test_flow_memory_contract.py b/tests/pipeline/compiler/test_flow_memory_contract.py index 32894cab..0c549744 100644 --- a/tests/pipeline/compiler/test_flow_memory_contract.py +++ b/tests/pipeline/compiler/test_flow_memory_contract.py @@ -30,9 +30,7 @@ def test_flow_memory_importable_from_facade(self) -> None: def test_flow_memory_has_path_property(self) -> None: """FlowMemory exposes a ``path`` property.""" assert hasattr(FlowMemory(path=".goga/memory", max_rules=25, commit=False), "path") - assert ( - FlowMemory(path=".goga/memory/x", max_rules=25, commit=False).path == ".goga/memory/x" - ) + assert FlowMemory(path=".goga/memory/x", max_rules=25, commit=False).path == ".goga/memory/x" def test_flow_memory_has_mode_property(self) -> None: """FlowMemory exposes a ``mode`` property defaulting to None.""" @@ -46,10 +44,7 @@ def test_flow_memory_has_memory_use_property(self) -> None: assert hasattr(FlowMemory(path=".goga/memory", max_rules=25, commit=False), "memory_use") block = FlowMemory(path=".goga/memory", max_rules=25, commit=False) assert block.memory_use is None - assert ( - FlowMemory(path=".goga/memory", memory_use=True, max_rules=25, commit=False).memory_use - is True - ) + assert FlowMemory(path=".goga/memory", memory_use=True, max_rules=25, commit=False).memory_use is True def test_flow_memory_has_max_rules_property(self) -> None: """FlowMemory exposes a ``max_rules`` property.""" diff --git a/tests/pipeline/compiler/test_flow_memory_logic.py b/tests/pipeline/compiler/test_flow_memory_logic.py index bd2eee3d..46807d10 100644 --- a/tests/pipeline/compiler/test_flow_memory_logic.py +++ b/tests/pipeline/compiler/test_flow_memory_logic.py @@ -64,8 +64,6 @@ def test_flow_memory_none_fields_distinct_from_values(self) -> None: def test_flow_memory_equality_of_identical_constructions(self) -> None: """Two blocks with identical fields compare equal (dataclass equality).""" left = FlowMemory(path=".goga/memory", max_rules=25, commit=False) - right = FlowMemory( - path=".goga/memory", mode=None, memory_use=None, max_rules=25, commit=False - ) + right = FlowMemory(path=".goga/memory", mode=None, memory_use=None, max_rules=25, commit=False) assert left == right diff --git a/tests/pipeline/compiler/test_serialize_flow_buttons_slot.py b/tests/pipeline/compiler/test_serialize_flow_buttons_slot.py index 7ed6efe8..5f8b3a78 100644 --- a/tests/pipeline/compiler/test_serialize_flow_buttons_slot.py +++ b/tests/pipeline/compiler/test_serialize_flow_buttons_slot.py @@ -83,9 +83,7 @@ def test_serialize_buttons_round_trips_through_safe_load(self) -> None: loaded = yaml.safe_load(text) assert loaded["stages"][0]["buttons"] == {"probe": "L1\nL2", "num": "123", "flag": "yes"} - assert all( - isinstance(value, str) for value in loaded["stages"][0]["buttons"].values() - ) + assert all(isinstance(value, str) for value in loaded["stages"][0]["buttons"].values()) def test_serialize_buttons_preserves_insertion_order(self) -> None: """The buttons mapping serializes in authored order — never sorted. diff --git a/tests/pipeline/compiler/test_serialize_flow_memory_slot.py b/tests/pipeline/compiler/test_serialize_flow_memory_slot.py index a3ad9c68..718f0667 100644 --- a/tests/pipeline/compiler/test_serialize_flow_memory_slot.py +++ b/tests/pipeline/compiler/test_serialize_flow_memory_slot.py @@ -75,12 +75,7 @@ def test_serialize_flow_memory_block_position_and_key_order(self) -> None: # The exact block literal — every value a plain scalar, 2-space indent # (beautiful_yaml indent=2; the nested stage keys sit at 4, not 2). assert ( - "memory:\n" - " path: .goga/memory/x\n" - " mode: rw\n" - " memory_use: true\n" - " max_rules: 25\n" - " commit: false\n" + "memory:\n path: .goga/memory/x\n mode: rw\n memory_use: true\n max_rules: 25\n commit: false\n" ) in text def test_serialize_flow_none_fields_omitted_from_block(self) -> None: diff --git a/tests/pipeline/workflow/test_parse_workflow_logic.py b/tests/pipeline/workflow/test_parse_workflow_logic.py index 20fb526c..bea863d2 100644 --- a/tests/pipeline/workflow/test_parse_workflow_logic.py +++ b/tests/pipeline/workflow/test_parse_workflow_logic.py @@ -836,14 +836,7 @@ def test_parse_workflow_notes_forbidden_in_extend_entry(self, tmp_path: Path) -> workflow_path = _write( tmp_path, "workflow.yml", - "stages:\n" - " deploy:\n" - " agent: codex\n" - "extend:\n" - " extra:\n" - " after: [deploy]\n" - " notes:\n" - " fix: x\n", + "stages:\n deploy:\n agent: codex\nextend:\n extra:\n after: [deploy]\n notes:\n fix: x\n", ) with pytest.raises(WorkflowSyntaxError) as exc_info: diff --git a/tests/topics/git/test_publish.py b/tests/topics/git/test_publish.py index adfb8cb3..ba3bf44f 100644 --- a/tests/topics/git/test_publish.py +++ b/tests/topics/git/test_publish.py @@ -105,8 +105,7 @@ def test_parameters_are_positional_or_keyword_with_contract_hints(self) -> None: for routine, declared in hints.items(): parameters = inspect.signature(routine).parameters assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in parameters.values() ), routine assert all(parameter.default is inspect.Parameter.empty for parameter in parameters.values()), routine assert typing.get_type_hints(routine) == declared, routine diff --git a/tests/topics/git/test_trees.py b/tests/topics/git/test_trees.py index a9c37935..07fd18c5 100644 --- a/tests/topics/git/test_trees.py +++ b/tests/topics/git/test_trees.py @@ -135,8 +135,7 @@ def test_file_signature_takes_ref_and_path_and_returns_optional_str(self) -> Non assert list(signature.parameters) == ["ref", "path"] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) hints = typing.get_type_hints(read_ref_file) diff --git a/tests/topics/test_board.py b/tests/topics/test_board.py index 029b41e6..20a0c0fc 100644 --- a/tests/topics/test_board.py +++ b/tests/topics/test_board.py @@ -28,6 +28,8 @@ from goga.topics import BoardRecord, board, collect_topic_board from goga.topics.git import BranchRef +from tests.conftest import is_kw_only_dataclass + # --- Shared scenario helpers --- @@ -55,7 +57,6 @@ def read(ref: str, path: str) -> str | None: def _wire_board( # noqa: PLR0913, PLR0917 — the five board patch points plus the scenario files - monkeypatch: pytest.MonkeyPatch, scale: StatusScale, inventory: list[BranchRef], @@ -142,7 +143,7 @@ def test_board_record_is_a_frozen_kw_only_dataclass(self) -> None: """``@dataclass(frozen=True, kw_only=True)`` with the six declared fields.""" assert dataclasses.is_dataclass(BoardRecord) assert BoardRecord.__dataclass_params__.frozen is True - assert BoardRecord.__dataclass_params__.kw_only is True + assert is_kw_only_dataclass(BoardRecord) assert typing.get_type_hints(BoardRecord) == { "topic": str, "branch": str, @@ -151,9 +152,7 @@ def test_board_record_is_a_frozen_kw_only_dataclass(self) -> None: "remote": bool, "todo": str | None, } - record = BoardRecord( - topic="feat-a", branch="feat/a", statuses=["planned"], current=True, remote=False - ) + record = BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=True, remote=False) assert record.topic == "feat-a" assert record.branch == "feat/a" assert record.statuses == ["planned"] @@ -180,9 +179,7 @@ def test_board_record_declares_todo_field(self) -> None: # The default keeps every pre-todo constructor valid. record = BoardRecord(topic="a", branch="b", statuses=[], current=False, remote=False) assert record.todo is None - with_todo = BoardRecord( - topic="a", branch="b", statuses=[], current=False, remote=False, todo="Payment retry" - ) + with_todo = BoardRecord(topic="a", branch="b", statuses=[], current=False, remote=False, todo="Payment retry") assert with_todo.todo == "Payment retry" def test_collect_topic_board_signature(self) -> None: @@ -190,8 +187,7 @@ def test_collect_topic_board_signature(self) -> None: signature = inspect.signature(collect_topic_board) assert list(signature.parameters) == ["year", "remote"] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None assert signature.parameters["remote"].default is False diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index 6c74d3bf..bdff1e89 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -157,8 +157,7 @@ def test_check_slug_occupancy_signature(self) -> None: signature = inspect.signature(check_slug_occupancy) assert list(signature.parameters) == ["slug", "year"] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None hints = typing.get_type_hints(check_slug_occupancy) @@ -173,8 +172,7 @@ def test_check_branch_occupancy_signature(self) -> None: signature = inspect.signature(check_branch_occupancy) assert list(signature.parameters) == ["branch_name", "slug", "year"] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None hints = typing.get_type_hints(check_branch_occupancy) @@ -190,8 +188,7 @@ def test_enter_topic_todo_signature(self) -> None: signature = inspect.signature(enter_topic_todo) assert list(signature.parameters) == ["topic", "year"] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None hints = typing.get_type_hints(enter_topic_todo) @@ -214,8 +211,7 @@ def test_create_topic_signature(self) -> None: "year", ] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["todo"].default is None assert signature.parameters["publish"].default is False @@ -243,9 +239,7 @@ def test_no_cleanliness_probe_in_creation(self) -> None: class TestCheckBranchOccupancy: - def test_check_branch_occupancy_oracle_order( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_check_branch_occupancy_oracle_order(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The first occupied oracle wins — remote twin before the topic dir.""" monkeypatch.chdir(tmp_path) inventory = [BranchRef(name="origin/feat/x", remote=True)] @@ -256,9 +250,7 @@ def test_check_branch_occupancy_oracle_order( assert conflict == "remote-tracking branch 'feat/x' already exists" - def test_check_branch_occupancy_local_ref_oracle( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_check_branch_occupancy_local_ref_oracle(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A local branch of the name occupies it — the first oracle.""" monkeypatch.chdir(tmp_path) inventory = [ @@ -272,9 +264,7 @@ def test_check_branch_occupancy_local_ref_oracle( assert conflict == "branch 'feat/x' already exists" - def test_check_branch_occupancy_topic_dir_oracle( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_check_branch_occupancy_topic_dir_oracle(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The topic directory of the year occupies the slug — the last oracle.""" monkeypatch.chdir(tmp_path) _wire_inventory(monkeypatch, []) @@ -307,9 +297,7 @@ def test_check_branch_occupancy_default_year_is_current( assert conflict == "history topic 'feat-x' already exists for 2026" - def test_check_branch_occupancy_free_everywhere( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_check_branch_occupancy_free_everywhere(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Every oracle free — ``None``, not an error.""" monkeypatch.chdir(tmp_path) inventory = [BranchRef(name="origin/feat/other", remote=True)] @@ -331,22 +319,16 @@ def test_check_slug_occupancy_returns_first_hosting_branch( BranchRef(name="alpha", remote=False), BranchRef(name="beta", remote=True), ] - reader = mock.Mock( - side_effect=[[], [".goga/history/2026/feature-foo/todo.md"]] - ) + reader = mock.Mock(side_effect=[[], [".goga/history/2026/feature-foo/todo.md"]]) listing = _wire_slug_oracle(monkeypatch, inventory, reader) conflict = check_slug_occupancy("feature-foo", "2026") - assert conflict == ( - "topic 'feature-foo' of 2026 is already hosted by branch 'beta'" - ) + assert conflict == ("topic 'feature-foo' of 2026 is already hosted by branch 'beta'") assert reader.call_args.args == ("beta", ".goga/history/2026/feature-foo/") listing.assert_called_once_with() - def test_check_slug_occupancy_stops_at_first_hit( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_check_slug_occupancy_stops_at_first_hit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The first occupied ref wins — the remaining refs are not probed.""" monkeypatch.chdir(tmp_path) inventory = [ @@ -365,14 +347,10 @@ def test_check_slug_occupancy_stops_at_first_hit( conflict = check_slug_occupancy("feature-foo", "2026") - assert conflict == ( - "topic 'feature-foo' of 2026 is already hosted by branch 'alpha'" - ) + assert conflict == ("topic 'feature-foo' of 2026 is already hosted by branch 'alpha'") assert reader.call_count == 1 - def test_check_slug_occupancy_free_slug_returns_none( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_check_slug_occupancy_free_slug_returns_none(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """No ref hosts the slug — None, one probe per ref.""" monkeypatch.chdir(tmp_path) inventory = [ @@ -434,9 +412,7 @@ def test_check_slug_occupancy_default_year_is_current( conflict = check_slug_occupancy("feature-foo") - assert conflict == ( - "topic 'feature-foo' of 2026 is already hosted by branch 'alpha'" - ) + assert conflict == ("topic 'feature-foo' of 2026 is already hosted by branch 'alpha'") assert reader.call_args.args == ("alpha", ".goga/history/2026/feature-foo/") @@ -444,9 +420,7 @@ def test_check_slug_occupancy_default_year_is_current( class TestCreateTopic: - def test_create_topic_normal_path_order( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_create_topic_normal_path_order(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The normal path runs its actions in the fixed order. The branch is planted at the base commit the preflight resolved, @@ -456,9 +430,7 @@ def test_create_topic_normal_path_order( """ monkeypatch.chdir(tmp_path) wired = _wire_creation(monkeypatch, current="main", base_commit="c0ffee") - wired.ensure_topic_dir.side_effect = lambda name, year: _topic_dir( - tmp_path, year, name - ) + wired.ensure_topic_dir.side_effect = lambda name, year: _topic_dir(tmp_path, year, name) monkeypatch.setattr(creation, "ensure_topic_dir", wired.ensure_topic_dir) wired.write_todo.side_effect = creation._write_todo monkeypatch.setattr(creation, "_write_todo", wired.write_todo) @@ -478,9 +450,7 @@ def test_create_topic_normal_path_order( todo_file = tmp_path / ".goga" / "history" / "2026" / "feature-foo" / "todo.md" assert todo_file.read_text(encoding="utf-8") == "Fix.\n" - def test_create_topic_base_passed_to_the_plant( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_create_topic_base_passed_to_the_plant(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The base is resolved once and the branch is planted at that commit.""" monkeypatch.chdir(tmp_path) wired = _wire_creation(monkeypatch, base_commit="abc123") @@ -490,9 +460,7 @@ def test_create_topic_base_passed_to_the_plant( wired.resolve_ref_commit.assert_called_once_with("origin/main") wired.create_branch.assert_called_once_with("feat-a", "abc123") - def test_create_topic_publication_ask_yes_delegates( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_create_topic_publication_ask_yes_delegates(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An accepted ask delegates the whole work to the publication cycle. The delegation reaches ``publish_topic`` at its definition site — @@ -512,9 +480,7 @@ def test_create_topic_publication_ask_yes_delegates( assert result == "published line" confirm.assert_called_once_with("Publish the branch to origin?") - published.assert_called_once_with( - "feature-foo", "Fix.", "origin/main", None, "2026" - ) + published.assert_called_once_with("feature-foo", "Fix.", "origin/main", None, "2026") wired.create_branch.assert_not_called() wired.checkout.assert_not_called() @@ -525,9 +491,7 @@ def test_create_topic_publication_ask_empty_answer_is_no( monkeypatch.chdir(tmp_path) wired = _wire_creation(monkeypatch) _tty(monkeypatch) - monkeypatch.setattr( - click.termui, "visible_prompt_func", mock.Mock(return_value="") - ) + monkeypatch.setattr(click.termui, "visible_prompt_func", mock.Mock(return_value="")) result = create_topic("feature-foo", "origin/main", todo="Fix.", year="2026") @@ -579,9 +543,7 @@ def test_create_topic_rollback_failure_still_surfaces_checkout_reason( assert "checkout refused" in raised.value.message assert "ref lock" not in raised.value.message - def test_create_topic_editor_todo_on_tty( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_create_topic_editor_todo_on_tty(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Without a value the terminal opens the editor; the saved text is written with exactly one trailing newline. @@ -624,9 +586,7 @@ def test_create_topic_base_resolved_in_preflight_error( wired.create_branch.assert_not_called() wired.checkout.assert_not_called() - def test_create_topic_empty_slug_preflight_error( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_create_topic_empty_slug_preflight_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An empty slug is the first preflight error — nothing else runs. The current-branch check, the occupancy oracles, the base @@ -635,9 +595,7 @@ def test_create_topic_empty_slug_preflight_error( monkeypatch.chdir(tmp_path) wired = _wire_creation(monkeypatch) probes = mock.Mock() - monkeypatch.setattr( - creation, "resolve_current_branch_name", probes.current_branch - ) + monkeypatch.setattr(creation, "resolve_current_branch_name", probes.current_branch) monkeypatch.setattr(creation, "check_branch_occupancy", probes.branch_oracle) monkeypatch.setattr(creation, "check_slug_occupancy", probes.slug_oracle) marker = tmp_path / "editor-launched" @@ -652,9 +610,7 @@ def test_create_topic_empty_slug_preflight_error( wired.create_branch.assert_not_called() assert not marker.exists() - def test_create_topic_todo_non_tty_clean_error( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_create_topic_todo_non_tty_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """No todo value without a terminal is a clean error naming the value option — before any mutation.""" monkeypatch.chdir(tmp_path) @@ -670,9 +626,7 @@ def test_create_topic_todo_non_tty_clean_error( wired.create_branch.assert_not_called() wired.checkout.assert_not_called() - def test_create_topic_publish_without_todo_error( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_create_topic_publish_without_todo_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The publication path with a cancelled editor entry is a clean error asking for the todo — a todo-less publish never happens.""" monkeypatch.chdir(tmp_path) @@ -707,9 +661,7 @@ def test_create_topic_publish_with_editor_todo_delegates_without_ask( result = create_topic("feature-foo", "origin/main", publish=True, year="2026") assert result == "published line" - published.assert_called_once_with( - "feature-foo", "From editor.\n", "origin/main", None, "2026" - ) + published.assert_called_once_with("feature-foo", "From editor.\n", "origin/main", None, "2026") confirm.assert_not_called() wired.create_branch.assert_not_called() wired.checkout.assert_not_called() @@ -753,9 +705,7 @@ def test_create_topic_occupied_name_error_no_reask( with pytest.raises(click.ClickException) as raised: create_topic("feat/x", "HEAD") - assert raised.value.message == ( - "branch 'feat/x' already exists — run 'goga topics board' to see the board" - ) + assert raised.value.message == ("branch 'feat/x' already exists — run 'goga topics board' to see the board") prompt.assert_not_called() wired.create_branch.assert_not_called() assert not (tmp_path / ".goga" / "history").exists() @@ -779,9 +729,7 @@ def test_create_topic_creates_branch_and_dir_with_cancelled_entry( assert topic_dir.is_dir() assert not (topic_dir / "todo.md").exists() - def test_create_topic_default_year_is_current( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_create_topic_default_year_is_current(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Without a year the topic directory lands in the current one.""" monkeypatch.chdir(tmp_path) _wire_creation(monkeypatch, current="main") @@ -794,26 +742,18 @@ def test_create_topic_default_year_is_current( assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" assert (tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar").is_dir() - def test_create_topic_with_todo_value( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_create_topic_with_todo_value(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A free name with a todo value: the branch, the directory, the todo file.""" monkeypatch.chdir(tmp_path) _wire_creation(monkeypatch, current="main") - result = create_topic( - "Feature/Foo_Bar", "HEAD", todo="Payment retry", year="2026" - ) + result = create_topic("Feature/Foo_Bar", "HEAD", todo="Payment retry", year="2026") assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" - todo_file = ( - tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" / "todo.md" - ) + todo_file = tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" / "todo.md" assert todo_file.read_bytes() == b"Payment retry\n" - def test_create_topic_writes_multiline_todo( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_create_topic_writes_multiline_todo(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A multi-line todo: the file carries the text verbatim plus one newline.""" monkeypatch.chdir(tmp_path) _wire_creation(monkeypatch, current="main") @@ -828,9 +768,7 @@ def test_create_topic_writes_multiline_todo( assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" topic_dir = tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" # Empty lines inside the text stay as entered; one trailing newline. - assert (topic_dir / "todo.md").read_bytes() == ( - b"Fix payment retries.\n\nRetries ignore the cap.\n" - ) + assert (topic_dir / "todo.md").read_bytes() == (b"Fix payment retries.\n\nRetries ignore the cap.\n") # The todo file is the single artifact of the topic directory. assert [path.name for path in topic_dir.iterdir()] == ["todo.md"] @@ -862,10 +800,7 @@ def test_create_topic_todo_write_failure_is_clean_error( with pytest.raises(click.ClickException) as raised: create_topic("Feature/Foo_Bar", "HEAD", todo="T", year="2026") - assert ( - "cannot create the topic directory or write the todo file" - in raised.value.message - ) + assert "cannot create the topic directory or write the todo file" in raised.value.message # The traced order — the branch mutations run before the todo write. wired.create_branch.assert_called_once_with("Feature/Foo_Bar", "c0ffee") @@ -874,9 +809,7 @@ def test_create_topic_todo_write_failure_is_clean_error( class TestEnterTopicTodo: - def test_enter_topic_todo_edits_existing_file( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_enter_topic_todo_edits_existing_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An existing todo.md seeds the session; the saved text overwrites it. Variant: a cancelled session — an editor that never writes — @@ -896,9 +829,7 @@ def test_enter_topic_todo_edits_existing_file( assert enter_topic_todo("feature-foo", year="2026") is False assert todo_file.read_text(encoding="utf-8") == "Old line.\n" - def test_enter_topic_todo_seeds_existing_content( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_enter_topic_todo_seeds_existing_content(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The session starts from the existing todo.md — the prefill proves it. The editor script copies the prefilled temporary file aside instead @@ -916,9 +847,7 @@ def test_enter_topic_todo_seeds_existing_content( assert prefill.read_text(encoding="utf-8") == "Old line.\n" assert todo_file.read_text(encoding="utf-8") == "Old line.\n" - def test_enter_topic_todo_missing_file_empty_entry( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_enter_topic_todo_missing_file_empty_entry(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A missing todo.md starts from an empty entry — the fresh-entry path. The topic directory exists without the file — the state after @@ -1039,14 +968,10 @@ def test_missing_git_binary_of_the_slug_oracle_surfaces_as_clean_error( assert "git" in raised.value.message - def test_missing_git_binary_surfaces_as_clean_error( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_missing_git_binary_surfaces_as_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A missing git binary is a clean error on both public entries.""" monkeypatch.chdir(tmp_path) - monkeypatch.setattr( - creation, "list_branch_refs", mock.Mock(side_effect=FileNotFoundError("git")) - ) + monkeypatch.setattr(creation, "list_branch_refs", mock.Mock(side_effect=FileNotFoundError("git"))) with pytest.raises(click.ClickException) as raised: check_branch_occupancy("feat/x", "feat-x", "2026") @@ -1064,9 +989,7 @@ def test_create_mutation_failure_surfaces_as_clean_error( cmd=["git", "branch", "feat/x"], stderr="fatal: invalid branch name", ) - monkeypatch.setattr( - creation, "create_branch_at_commit", mock.Mock(side_effect=failure) - ) + monkeypatch.setattr(creation, "create_branch_at_commit", mock.Mock(side_effect=failure)) with pytest.raises(click.ClickException) as raised: create_topic("feat/x", "HEAD", todo="T", year="2026") @@ -1110,10 +1033,7 @@ def test_stray_file_at_topic_path_surfaces_as_clean_error( with pytest.raises(click.ClickException) as raised: create_topic("feat-x", "HEAD", todo="T", year="2026") - assert ( - "cannot create the topic directory or write the todo file" - in raised.value.message - ) + assert "cannot create the topic directory or write the todo file" in raised.value.message assert "feat-x" in raised.value.message # The traced order — the branch mutations run before the directory. wired.create_branch.assert_called_once_with("feat-x", "c0ffee") diff --git a/tests/topics/test_deletion.py b/tests/topics/test_deletion.py index f782a804..7fd84eb6 100644 --- a/tests/topics/test_deletion.py +++ b/tests/topics/test_deletion.py @@ -31,6 +31,8 @@ from goga.topics import DeleteTarget, delete_topics, deletion, resolve_delete_targets from goga.topics.git import BranchRef +from tests.conftest import is_kw_only_dataclass + # --- Shared scenario helpers --- @@ -144,7 +146,7 @@ def test_delete_target_is_a_frozen_kw_only_dataclass(self) -> None: """``@dataclass(frozen=True, kw_only=True)`` with the four declared fields.""" assert dataclasses.is_dataclass(DeleteTarget) assert DeleteTarget.__dataclass_params__.frozen is True - assert DeleteTarget.__dataclass_params__.kw_only is True + assert is_kw_only_dataclass(DeleteTarget) assert typing.get_type_hints(DeleteTarget) == { "topic": str, "branch": str | None, @@ -166,8 +168,7 @@ def test_resolve_delete_targets_signature(self) -> None: signature = inspect.signature(resolve_delete_targets) assert list(signature.parameters) == ["identifiers", "year"] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None assert typing.get_type_hints(resolve_delete_targets) == { @@ -188,8 +189,7 @@ def test_delete_topics_signature(self) -> None: signature = inspect.signature(delete_topics) assert list(signature.parameters) == ["targets", "year"] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["year"].default is None assert typing.get_type_hints(delete_topics) == { @@ -229,9 +229,7 @@ def test_resolve_delete_targets_collapses_local_and_origin_twin( targets = resolve_delete_targets(["feature-foo", "origin/feature-foo"], year="2026") - assert targets == [ - DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True) - ] + assert targets == [DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True)] def test_resolve_delete_targets_twin_collapse_order_independent( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -244,9 +242,7 @@ def test_resolve_delete_targets_twin_collapse_order_independent( twin_first = resolve_delete_targets(["origin/feature-foo", "feature-foo"], year="2026") local_first = resolve_delete_targets(["feature-foo", "origin/feature-foo"], year="2026") - expected = [ - DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True) - ] + expected = [DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True)] assert twin_first == expected assert local_first == expected @@ -271,9 +267,7 @@ def test_resolve_delete_targets_ambiguous_error_all_or_nothing( assert "feature-foo" in raised.value.message assert "feature-foobar" in raised.value.message - def test_resolve_delete_targets_current_branch_guard( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_resolve_delete_targets_current_branch_guard(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A target hosted by the current branch is a clean error — switch away.""" monkeypatch.chdir(tmp_path) inventory = [BranchRef(name="feature-foo", remote=False)] @@ -437,9 +431,7 @@ def test_resolve_delete_targets_prefix_tier_matches_branch_and_slug( targets = resolve_delete_targets(["feat"], year="2026") - assert targets == [ - DeleteTarget(topic="feature-foo", branch="feature-foo", remote=None, has_dir=False) - ] + assert targets == [DeleteTarget(topic="feature-foo", branch="feature-foo", remote=None, has_dir=False)] def test_resolve_delete_targets_prefix_of_remote_short_name_matches( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -456,9 +448,7 @@ def test_resolve_delete_targets_prefix_of_remote_short_name_matches( targets = resolve_delete_targets(["feature-fo"], year="2026") - assert targets == [ - DeleteTarget(topic="feature-foo", branch=None, remote="feature-foo", has_dir=False) - ] + assert targets == [DeleteTarget(topic="feature-foo", branch=None, remote="feature-foo", has_dir=False)] def test_resolve_delete_targets_local_short_name_prefix_does_not_match( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -533,9 +523,7 @@ def test_resolve_delete_targets_prefers_the_origin_twin_among_remotes( targets = resolve_delete_targets(["feature-x"], year="2026") - assert targets == [ - DeleteTarget(topic="feature-x", branch=None, remote="feature-x", has_dir=False) - ] + assert targets == [DeleteTarget(topic="feature-x", branch=None, remote="feature-x", has_dir=False)] def test_resolve_delete_targets_current_branch_guard_slug_arm( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -581,9 +569,7 @@ def test_resolve_delete_targets_merged_host_keeps_the_directory( targets = resolve_delete_targets(["feature-foo"], year="2026") - assert targets == [ - DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=False) - ] + assert targets == [DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=False)] def test_resolve_delete_targets_several_same_slug_branches_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -617,9 +603,7 @@ def test_resolve_delete_targets_several_same_slug_branches_error( class TestDeletionInfrastructureBoundary: - def test_git_failure_surfaces_as_clean_error( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_git_failure_surfaces_as_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A git infrastructure failure with stderr becomes a ``ClickException``.""" monkeypatch.chdir(tmp_path) failure = subprocess.CalledProcessError( @@ -632,9 +616,7 @@ def test_git_failure_surfaces_as_clean_error( assert "fatal: not a git repository" in raised.value.message - def test_missing_git_binary_surfaces_as_clean_error( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_missing_git_binary_surfaces_as_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A missing git binary during the resolution is a clean error.""" monkeypatch.chdir(tmp_path) monkeypatch.setattr(deletion, "list_branch_refs", mock.Mock(side_effect=FileNotFoundError("git"))) @@ -650,9 +632,7 @@ def test_history_tree_read_failure_surfaces_as_clean_error( """An OS failure of the history-tree read becomes a ``ClickException``.""" monkeypatch.chdir(tmp_path) _wire_resolution(monkeypatch, _twin_inventory(), _twin_trees(), None) - monkeypatch.setattr( - deletion, "collect_history_tree", mock.Mock(side_effect=OSError("history tree unreadable")) - ) + monkeypatch.setattr(deletion, "collect_history_tree", mock.Mock(side_effect=OSError("history tree unreadable"))) with pytest.raises(click.ClickException) as raised: resolve_delete_targets(["feature-foo"], year="2026") @@ -671,9 +651,7 @@ def test_detached_head_skips_the_current_branch_guard( targets = resolve_delete_targets(["feature-foo"], year="2026") - assert targets == [ - DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True) - ] + assert targets == [DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True)] # --- Logic tests: the confirmed removal --- @@ -719,9 +697,7 @@ def test_delete_topics_full_success_removes_all_three( mock.call.directory("feature-foo", "2026"), ] - def test_delete_topics_remote_only_target_no_restore( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_delete_topics_remote_only_target_no_restore(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A remote-only target has nothing to restore — the error propagates directly.""" monkeypatch.chdir(tmp_path) target = DeleteTarget(topic="ghost", branch=None, remote="ghost", has_dir=False) @@ -736,9 +712,7 @@ def test_delete_topics_remote_only_target_no_restore( assert "deny" in raised.value.message wired.restore.assert_not_called() - def test_delete_topics_idempotent_directory_absence( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_delete_topics_idempotent_directory_absence(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An already-absent directory is a False, not an error — the topic still reports.""" monkeypatch.chdir(tmp_path) target = DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True) diff --git a/tests/topics/test_ensuring.py b/tests/topics/test_ensuring.py index 80c58863..d8fc24ca 100644 --- a/tests/topics/test_ensuring.py +++ b/tests/topics/test_ensuring.py @@ -347,10 +347,7 @@ def test_ensure_topic_stray_file_at_topic_path_surfaces_as_clean_error( with pytest.raises(click.ClickException) as raised: ensure_topic("feat-x", year="2026") - assert ( - "cannot create the topic directory or write the todo file" - in raised.value.message - ) + assert "cannot create the topic directory or write the todo file" in raised.value.message assert "feat-x" in raised.value.message create_and_switch.assert_called_once_with("feat-x") @@ -592,9 +589,7 @@ def test_ensure_topic_todo_current_branch_matching_no_candidate_creates_director class TestEnsuringInfrastructureBoundary: - def test_git_failure_surfaces_as_clean_error( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_git_failure_surfaces_as_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A raw git infrastructure failure escaping a delegate becomes a ``ClickException`` carrying the git reason.""" monkeypatch.chdir(tmp_path) @@ -610,9 +605,7 @@ def test_git_failure_surfaces_as_clean_error( assert "fatal: bad object" in raised.value.message - def test_missing_git_binary_surfaces_as_clean_error( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_missing_git_binary_surfaces_as_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A raw missing-git-binary failure escaping a delegate is a clean error.""" monkeypatch.chdir(tmp_path) diff --git a/tests/topics/test_publishing.py b/tests/topics/test_publishing.py index 7fd79723..f17d789e 100644 --- a/tests/topics/test_publishing.py +++ b/tests/topics/test_publishing.py @@ -56,20 +56,12 @@ class _Cycle: def __init__(self) -> None: self.recorder = mock.Mock(name="fast-cycle") - self.resolve_current_branch_name = self._attach( - "resolve_current_branch_name", return_value="main" - ) - self.check_branch_occupancy = self._attach( - "check_branch_occupancy", return_value=None - ) - self.check_slug_occupancy = self._attach( - "check_slug_occupancy", return_value=None - ) + self.resolve_current_branch_name = self._attach("resolve_current_branch_name", return_value="main") + self.check_branch_occupancy = self._attach("check_branch_occupancy", return_value=None) + self.check_slug_occupancy = self._attach("check_slug_occupancy", return_value=None) self.origin_configured = self._attach("origin_configured", return_value=True) self.resolve_ref_commit = self._attach("resolve_ref_commit", return_value="<base>") - self.commit_file_on_base = self._attach( - "commit_file_on_base", return_value="<commit>" - ) + self.commit_file_on_base = self._attach("commit_file_on_base", return_value="<commit>") self.create_branch_at_commit = self._attach("create_branch_at_commit") self.push_branch = self._attach("push_branch") self.delete_local_branch = self._attach("delete_local_branch") @@ -144,8 +136,7 @@ def test_publish_topic_signature(self) -> None: "year", ] assert all( - parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - for parameter in signature.parameters.values() + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() ) assert signature.parameters["commit_message"].default is None assert signature.parameters["year"].default is None @@ -167,9 +158,7 @@ def test_publish_topic_default_template_binds(self) -> None: rework — the default moved into the domain, so omitting the template is the supported call, not an error. """ - assert inspect.signature(publish_topic).bind( - "b", "t", "origin/main", commit_message=None, year="2026" - ) + assert inspect.signature(publish_topic).bind("b", "t", "origin/main", commit_message=None, year="2026") def test_no_working_copy_write_in_publishing(self) -> None: """A shallow source guardrail against working-copy writes. @@ -250,21 +239,14 @@ def test_publish_topic_commits_todo_file( # noqa: PLR0913, PLR0917 — the para cycle = _wire_cycle(monkeypatch) cycle.resolve_ref_commit.return_value = "abc123" - result = publish_topic( - "Feature/Foo_Bar", todo, "origin/main", template, year="2026" - ) + result = publish_topic("Feature/Foo_Bar", todo, "origin/main", template, year="2026") - assert ( - cycle.commit_file_on_base.call_args.args[1] - == ".goga/history/2026/feature-foo-bar/todo.md" - ) + assert cycle.commit_file_on_base.call_args.args[1] == ".goga/history/2026/feature-foo-bar/todo.md" assert cycle.commit_file_on_base.call_args.args[2] == expected_content assert cycle.commit_file_on_base.call_args.args[3] == expected_message assert cycle.create_branch_at_commit.call_args.args[0] == "Feature/Foo_Bar" cycle.resolve_current_branch_name.assert_called_once_with() - cycle.check_branch_occupancy.assert_called_once_with( - "Feature/Foo_Bar", "feature-foo-bar", "2026" - ) + cycle.check_branch_occupancy.assert_called_once_with("Feature/Foo_Bar", "feature-foo-bar", "2026") cycle.check_slug_occupancy.assert_called_once_with("feature-foo-bar", "2026") cycle.origin_configured.assert_called_once_with() cycle.resolve_ref_commit.assert_called_once_with("origin/main") @@ -275,9 +257,7 @@ def test_publish_topic_commits_todo_file( # noqa: PLR0913, PLR0917 — the para # build -> plant -> push. assert cycle.recorder.mock_calls == [ mock.call.resolve_current_branch_name(), - mock.call.check_branch_occupancy( - "Feature/Foo_Bar", "feature-foo-bar", "2026" - ), + mock.call.check_branch_occupancy("Feature/Foo_Bar", "feature-foo-bar", "2026"), mock.call.check_slug_occupancy("feature-foo-bar", "2026"), mock.call.origin_configured(), mock.call.resolve_ref_commit("origin/main"), @@ -290,14 +270,10 @@ def test_publish_topic_commits_todo_file( # noqa: PLR0913, PLR0917 — the para mock.call.create_branch_at_commit("Feature/Foo_Bar", "<commit>"), mock.call.push_branch("Feature/Foo_Bar"), ] - assert result == ( - "Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar" - ) + assert result == ("Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar") assert "\n" not in result - def test_publish_topic_empty_todo_clean_error( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_publish_topic_empty_todo_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An empty todo is one clean error — before every oracle and git call. The gate sits between the slug normalization and the current-branch @@ -313,8 +289,7 @@ def test_publish_topic_empty_todo_clean_error( publish_topic("X", "", "origin/main", "tmpl") assert raised.value.message == ( - "the fast path needs a non-empty todo" - " — pass the text or enter it interactively" + "the fast path needs a non-empty todo — pass the text or enter it interactively" ) cycle.resolve_current_branch_name.assert_not_called() cycle.check_branch_occupancy.assert_not_called() @@ -336,8 +311,7 @@ def test_publish_topic_current_branch_hosting_slug_is_clean_error( publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") assert raised.value.message == ( - "branch Feature/Foo_Bar already hosts topic 2026/feature-foo-bar" - " — the fast path is only for fresh work" + "branch Feature/Foo_Bar already hosts topic 2026/feature-foo-bar — the fast path is only for fresh work" ) _assert_no_mutation(cycle) @@ -353,9 +327,7 @@ def test_publish_topic_conflict_without_terminal_fails_with_board_hint( monkeypatch.chdir(tmp_path) _non_interactive(monkeypatch) cycle = _wire_cycle(monkeypatch) - cycle.check_slug_occupancy.return_value = ( - "topic 'feature-foo-bar' of 2026 is already hosted by branch 'alpha'" - ) + cycle.check_slug_occupancy.return_value = "topic 'feature-foo-bar' of 2026 is already hosted by branch 'alpha'" with pytest.raises(click.ClickException) as raised: publish_topic("Feature/Foo_Bar", "T", "origin/main", "m", "2026") @@ -385,8 +357,7 @@ def test_publish_topic_branch_occupancy_conflict_skips_the_slug_oracle( publish_topic("Feature/Foo_Bar", "T", "origin/main", "m", "2026") assert raised.value.message == ( - "branch 'Feature/Foo_Bar' already exists" - " — run 'goga topics board' to see the board" + "branch 'Feature/Foo_Bar' already exists — run 'goga topics board' to see the board" ) cycle.check_slug_occupancy.assert_not_called() _assert_no_mutation(cycle) @@ -441,9 +412,7 @@ def test_publish_topic_rollback_oserror_still_surfaces_push_reason( cycle.push_branch.side_effect = subprocess.CalledProcessError( 1, ["git", "push"], stderr="error: failed to push some refs" ) - cycle.delete_local_branch.side_effect = PermissionError( - "no more process handles" - ) + cycle.delete_local_branch.side_effect = PermissionError("no more process handles") with pytest.raises(click.ClickException) as raised: publish_topic("Feature/Foo_Bar", "T", "origin/main", "m", "2026") @@ -478,10 +447,7 @@ def test_publish_topic_without_origin_is_clean_error_before_mutations( with pytest.raises(click.ClickException) as raised: publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") - assert ( - raised.value.message - == "origin is not configured — the fast mode publishes to origin" - ) + assert raised.value.message == "origin is not configured — the fast mode publishes to origin" _assert_no_mutation(cycle) def test_publish_topic_detached_head_does_not_interfere( @@ -494,9 +460,7 @@ def test_publish_topic_detached_head_does_not_interfere( result = publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") - assert result == ( - "Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar" - ) + assert result == ("Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar") cycle.resolve_current_branch_name.assert_called_once_with() cycle.push_branch.assert_called_once_with("Feature/Foo_Bar") @@ -525,16 +489,12 @@ def test_publish_topic_default_message_when_none( cycle = _wire_cycle(monkeypatch) cycle.resolve_ref_commit.return_value = "c0" - result = publish_topic( - "feature-foo", "Fix.", "origin/main", commit_message, year="2026" - ) + result = publish_topic("feature-foo", "Fix.", "origin/main", commit_message, year="2026") assert cycle.commit_file_on_base.call_args.args[3] == expected_message assert result == "Created branch feature-foo and published topic 2026/feature-foo" - def test_publish_topic_no_reask_on_conflict( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_publish_topic_no_reask_on_conflict(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An occupancy conflict on a terminal is a clean error — no prompt. The re-ask cycle is abolished: ``click.prompt`` must never run, the @@ -549,8 +509,7 @@ def test_publish_topic_no_reask_on_conflict( publish_topic("feature-foo", "T", "origin/main", "m", "2026") assert raised.value.message == ( - "branch 'feature-foo' already exists" - " — run 'goga topics board' to see the board" + "branch 'feature-foo' already exists — run 'goga topics board' to see the board" ) prompt.assert_not_called() _assert_no_mutation(cycle) @@ -579,9 +538,7 @@ def test_publish_topic_editor_todo_single_trailing_newline( assert cycle.commit_file_on_base.call_args.args[2] == "Fix.\n" - def test_publish_topic_empty_slug_is_clean_error( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_publish_topic_empty_slug_is_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A name that normalizes to nothing is one clean error — no re-ask. The reason names the entered name; nothing is probed, prompted, or @@ -594,9 +551,7 @@ def test_publish_topic_empty_slug_is_clean_error( with pytest.raises(click.ClickException) as raised: publish_topic("///", "T", "origin/main", "m") - assert raised.value.message == ( - "branch name '///' normalizes to an empty topic slug" - ) + assert raised.value.message == ("branch name '///' normalizes to an empty topic slug") prompt.assert_not_called() cycle.resolve_current_branch_name.assert_not_called() cycle.check_branch_occupancy.assert_not_called() @@ -607,9 +562,7 @@ def test_publish_topic_empty_slug_is_clean_error( class TestPublishingInfrastructureBoundary: - def test_missing_git_binary_surfaces_as_clean_error( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_missing_git_binary_surfaces_as_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A missing git binary during the cycle is a clean error.""" monkeypatch.chdir(tmp_path) cycle = _wire_cycle(monkeypatch) @@ -642,9 +595,7 @@ def test_unwritable_repository_surfaces_as_clean_error( cycle.create_branch_at_commit.assert_not_called() cycle.push_branch.assert_not_called() - def test_publish_topic_oserror_push_rolls_back_too( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_publish_topic_oserror_push_rolls_back_too(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An OS failure of the push rolls the planted branch back as well. ``push_branch`` can fail at spawn level (``PermissionError`` and kin diff --git a/tests/topics/test_switching.py b/tests/topics/test_switching.py index d48a9cc7..75fed4a3 100644 --- a/tests/topics/test_switching.py +++ b/tests/topics/test_switching.py @@ -37,6 +37,8 @@ ) from goga.topics.git import BranchRef +from tests.conftest import is_kw_only_dataclass + # --- Shared scenario helpers --- @@ -143,7 +145,7 @@ def test_switch_candidate_is_a_frozen_kw_only_dataclass(self) -> None: """``@dataclass(frozen=True, kw_only=True)`` with the five declared fields.""" assert dataclasses.is_dataclass(SwitchCandidate) assert SwitchCandidate.__dataclass_params__.frozen is True - assert SwitchCandidate.__dataclass_params__.kw_only is True + assert is_kw_only_dataclass(SwitchCandidate) assert typing.get_type_hints(SwitchCandidate) == { "branch": str, "topic": str | None, From bbc308384bddf5b7a234a8dc0012754634e73856 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 23:20:16 +0300 Subject: [PATCH 210/229] feat: add memory for code design --- .goga/memory/incidents.md | 30 ++++++++++++++++++++++++++++++ .goga/workflows/bugfix.yml | 3 +++ .goga/workflows/development.yml | 3 +++ 3 files changed, 36 insertions(+) create mode 100644 .goga/memory/incidents.md diff --git a/.goga/memory/incidents.md b/.goga/memory/incidents.md new file mode 100644 index 00000000..6a6c6dfc --- /dev/null +++ b/.goga/memory/incidents.md @@ -0,0 +1,30 @@ +# Project rules + +## Independent Root-Cause Isolation + +When multiple failure classes arrive together, reproduce and prove each cause separately against authoritative sources +such as official documentation and actual runtime sources before fixing anything; never assume a single shared cause or +diagnose solely from an aggregated CI report. + +## Specification-Implementation Co-Evolution + +When a defect surfaces at a contract boundary, fix the implementation to satisfy the existing specification and amend +the specification only to state the boundary explicitly, then confirm consistency through the project's specification +validation gates; never rewrite the specification to legitimize buggy behavior. + +## Explicit Version-Stable Validation + +Encode edge-case semantics explicitly in production validation logic so behavior is identical on every supported runtime +version, rather than relying on standard-library behavior that silently changes between versions. + +## Honest Verification Scope + +Validate on the environments where the defects actually reproduce, rely on existing parameterized coverage and the CI +matrix for environments unavailable locally, disclose local-coverage gaps explicitly in every report, and never +fabricate coverage by mocking internal components to force unreachable branches. + +## Preservation of Out-of-Scope User Changes + +Leave pre-existing modifications made directly by the user untouched, exclude them from the reported change scope, and +flag them explicitly in scope and validation reports; never attribute them to the current work, revert them as noise, or +let bulk operations sweep them in. diff --git a/.goga/workflows/bugfix.yml b/.goga/workflows/bugfix.yml index cb38c33a..7209e90e 100644 --- a/.goga/workflows/bugfix.yml +++ b/.goga/workflows/bugfix.yml @@ -1,6 +1,9 @@ prompt: | Answer (feedbacks, proposes, questions and etc) in Russian language. +memory: + max_rules: 15 + stages: hotfix: prompt: | diff --git a/.goga/workflows/development.yml b/.goga/workflows/development.yml index 723f2fe9..c5a78188 100644 --- a/.goga/workflows/development.yml +++ b/.goga/workflows/development.yml @@ -30,6 +30,9 @@ stages: approve: auto code-design: approve: auto + reflect: + file: incidents.md + mode: r design-review: approve: auto coding-plan: From ab7907e8af5d9df052698c5fdb38f2b3886f5e6a Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 23:22:32 +0300 Subject: [PATCH 211/229] feat: update memory --- .goga/memory/incidents.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.goga/memory/incidents.md b/.goga/memory/incidents.md index 6a6c6dfc..7deae6d8 100644 --- a/.goga/memory/incidents.md +++ b/.goga/memory/incidents.md @@ -22,9 +22,3 @@ version, rather than relying on standard-library behavior that silently changes Validate on the environments where the defects actually reproduce, rely on existing parameterized coverage and the CI matrix for environments unavailable locally, disclose local-coverage gaps explicitly in every report, and never fabricate coverage by mocking internal components to force unreachable branches. - -## Preservation of Out-of-Scope User Changes - -Leave pre-existing modifications made directly by the user untouched, exclude them from the reported change scope, and -flag them explicitly in scope and validation reports; never attribute them to the current work, revert them as noise, or -let bulk operations sweep them in. From f74369d37ed3e4f7fa9d5ca3c38582954bd33b2f Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 23:25:53 +0300 Subject: [PATCH 212/229] feat: update memory --- .goga/memory/{incidents.md => code-design.md} | 0 .goga/workflows/bugfix.yml | 2 +- .goga/workflows/development.yml | 4 +++- 3 files changed, 4 insertions(+), 2 deletions(-) rename .goga/memory/{incidents.md => code-design.md} (100%) diff --git a/.goga/memory/incidents.md b/.goga/memory/code-design.md similarity index 100% rename from .goga/memory/incidents.md rename to .goga/memory/code-design.md diff --git a/.goga/workflows/bugfix.yml b/.goga/workflows/bugfix.yml index 7209e90e..a0949a17 100644 --- a/.goga/workflows/bugfix.yml +++ b/.goga/workflows/bugfix.yml @@ -16,4 +16,4 @@ stages: - Don't implementation without approve from user - Don't research before you receive the task from user reflect: - file: incidents.md + file: code-design.md diff --git a/.goga/workflows/development.yml b/.goga/workflows/development.yml index c5a78188..6bd0795d 100644 --- a/.goga/workflows/development.yml +++ b/.goga/workflows/development.yml @@ -31,10 +31,12 @@ stages: code-design: approve: auto reflect: - file: incidents.md + file: code-design.md mode: r design-review: approve: auto + reflect: + file: code-design.md coding-plan: approve: auto plan-review: From c54afd64f546885a2df2a304227b7a82517fc634 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 21:03:55 +0000 Subject: [PATCH 213/229] docs: fix configuration anchors, trace cli pages, bump edit_uri to 1.3.x - fix five broken anchors in lint/configuration pages; add an Env layering section to the home configuration page (mkdocs build --strict is clean) - add traceability entries for the cli/history, cli/topics, cli/hooks pages - point edit_uri at the 1.3.x maintenance branch --- .goga/tools/mkdocs/traceability.yml | 16 ++++++++++++++++ docs/cli/lint.md | 2 +- docs/configuration/agents.md | 4 ++-- docs/configuration/home.md | 2 ++ mkdocs.yml | 2 +- 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.goga/tools/mkdocs/traceability.yml b/.goga/tools/mkdocs/traceability.yml index ac475fcf..3010ae83 100644 --- a/.goga/tools/mkdocs/traceability.yml +++ b/.goga/tools/mkdocs/traceability.yml @@ -154,6 +154,22 @@ docs/cli/pipeline.md: - goga/pipeline - goga/docker +docs/cli/history.md: + - goga/commands/history + - goga/history + +docs/cli/topics.md: + - goga/commands/topics + - goga/topics + - goga/topics/git + - goga/topics/editor + - goga/history + - goga/config + +docs/cli/hooks.md: + - goga/commands/hooks + - goga/hooks + docs/pipelines/index.md: - goga/pipeline - goga/pipeline/compiler diff --git a/docs/cli/lint.md b/docs/cli/lint.md index 71474863..9f2d61cf 100644 --- a/docs/cli/lint.md +++ b/docs/cli/lint.md @@ -25,7 +25,7 @@ lint: - build/dist ``` -A directory is pruned when its exact normalized relative path matches an `ignore` entry. Matching is literal — glob patterns are **not** interpreted, and a trailing separator is insignificant (`.venv/` and `.venv` are equivalent). Only full relative paths match: `ignore: [.venv]` prunes a top-level `.venv` but not a nested `a/b/.venv`. The `lint` section is optional; when it is absent or the config cannot be loaded, lint behavior is unchanged (every directory is linted). See [Configuration](../configuration/index.md#lint). +A directory is pruned when its exact normalized relative path matches an `ignore` entry. Matching is literal — glob patterns are **not** interpreted, and a trailing separator is insignificant (`.venv/` and `.venv` are equivalent). Only full relative paths match: `ignore: [.venv]` prunes a top-level `.venv` but not a nested `a/b/.venv`. The `lint` section is optional; when it is absent or the config cannot be loaded, lint behavior is unchanged (every directory is linted). See [Configuration](../configuration/project.md#lint). ## Arguments diff --git a/docs/configuration/agents.md b/docs/configuration/agents.md index d4d281fe..fa1ec906 100644 --- a/docs/configuration/agents.md +++ b/docs/configuration/agents.md @@ -41,7 +41,7 @@ The wrapper class describes how each wrapper produces the Claude Code stream-jso ## Environment variables per agent -Env variables are forwarded into the container through the standard env layering (`home.env` → project `<scope>.env` → CLI `-e` / `extra_env`) — see [Home configuration](./index.md#home-configuration). +Env variables are forwarded into the container through the standard env layering (`home.env` → project `<scope>.env` → CLI `-e` / `extra_env`) — see [Home configuration](home.md#env-layering). ### claude @@ -108,7 +108,7 @@ Any name works as `agent: <name>` as long as `/home/goga/bin/<name>-as-claude.sh Two paths, both through a custom Dockerfile: -**Path A — via the `dockerfile:` field.** When `.goga/config.yml` declares a top-level `dockerfile` (see [Top-level](./index.md#top-level) and [Example configuration](./index.md#example-configuration)), `goga build --update` / `goga pipeline --update` build the image from that Dockerfile: +**Path A — via the `dockerfile:` field.** When `.goga/config.yml` declares a top-level `dockerfile` (see [Top-level](project.md#top-level) and [Example configuration](project.md#example-configuration)), `goga build --update` / `goga pipeline --update` build the image from that Dockerfile: ```dockerfile FROM qarium/goga-python-<python-version>:<goga-version> # or any baseline language image diff --git a/docs/configuration/home.md b/docs/configuration/home.md index c4ab203d..04da77fa 100644 --- a/docs/configuration/home.md +++ b/docs/configuration/home.md @@ -30,6 +30,8 @@ malformed entry (an unterminated quote) fails to load with a clean error. | `docker.run` | list of strings | Shell fragments appended to every `docker run` invocation in both `goga build` and `goga pipeline`. Each entry is shell-tokenized (e.g. `-v /host:/container` → `-v` + volume spec) | | `docker.build` | list of strings | Shell fragments appended to image builds only — forwarded by both `goga build` and `goga pipeline` (`docker_build_if_not_exist` / `docker_update`, build branch only; ignored on image pull). Each entry is shell-tokenized like `docker.run` | +## Env layering + The env layering formula is `{**home.env, **project_env, **cli_env}` — `home.env` is the base, project config wins over it, and CLI extra env wins last. Unknown keys are ignored. diff --git a/mkdocs.yml b/mkdocs.yml index 50c14c8d..695f5da0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,7 +4,7 @@ site_name: Goga site_description: A tool for working with the codemanifest specification — assembly, extension, and plan-building workflow site_url: https://qarium.github.io/goga/ repo_url: https://github.com/qarium/goga -edit_uri: edit/1.1.x/docs/ +edit_uri: edit/1.3.x/docs/ theme: name: material From 7cb280dc019f1fb81510bbf414cce1dcfac3ec0e Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 21:23:11 +0000 Subject: [PATCH 214/229] fix: bump suggested onboarding image tags to 1.3 --- docs/cli/build.md | 2 +- docs/cli/config.md | 2 +- docs/cli/init.md | 10 +++---- docs/configuration/project.md | 14 +++++----- goga/onboarding/CODEMANIFEST | 10 +++---- goga/onboarding/questionnaire.py | 36 +++++++++++++------------- tests/onboarding/test_questionnaire.py | 6 ++--- 7 files changed, 40 insertions(+), 40 deletions(-) diff --git a/docs/cli/build.md b/docs/cli/build.md index 73f3f771..58b1ff41 100644 --- a/docs/cli/build.md +++ b/docs/cli/build.md @@ -163,7 +163,7 @@ Build settings are loaded from `.goga/config.yml`. Example configuration: ```yaml language: python -image: qarium/goga-python-3.12:1.2 +image: qarium/goga-python-3.12:1.3 # dockerfile: .goga/Dockerfile # optional — when set, `--update` builds the image from this Dockerfile instead of pulling pipeline: agent: claude diff --git a/docs/cli/config.md b/docs/cli/config.md index 11cc3c6f..d2eff8cd 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -62,7 +62,7 @@ Values are read from `.goga/config.yml`. A minimal configuration: ```yaml language: python -image: qarium/goga-python-3.12:1.2 # top-level image, shared by build and pipeline (build.image is rejected) +image: qarium/goga-python-3.12:1.3 # top-level image, shared by build and pipeline (build.image is rejected) build: task_executor: agent: claude # optional at the loader level; goga build raises a ClickException when it is None diff --git a/docs/cli/init.md b/docs/cli/init.md index 5497be01..1be29f11 100644 --- a/docs/cli/init.md +++ b/docs/cli/init.md @@ -61,11 +61,11 @@ The wizard proceeds through the following steps in order. **The entire survey is | Language | Images | |---|---| - | python | `qarium/goga-python-3.10:1.2` ... `qarium/goga-python-3.14:1.2` | - | golang | `qarium/goga-golang-1.23:1.2` ... `qarium/goga-golang-1.26:1.2` | - | javascript | `qarium/goga-node-22:1.2`, `qarium/goga-node-24:1.2` | - | kotlin | `qarium/goga-kotlin-2.0:1.2` ... `qarium/goga-kotlin-2.3:1.2` | - | swift | `qarium/goga-swift-6.0:1.2` ... `qarium/goga-swift-6.2:1.2` | + | python | `qarium/goga-python-3.10:1.3` ... `qarium/goga-python-3.14:1.3` | + | golang | `qarium/goga-golang-1.23:1.3` ... `qarium/goga-golang-1.26:1.3` | + | javascript | `qarium/goga-node-22:1.3`, `qarium/goga-node-24:1.3` | + | kotlin | `qarium/goga-kotlin-2.0:1.3` ... `qarium/goga-kotlin-2.3:1.3` | + | swift | `qarium/goga-swift-6.0:1.3` ... `qarium/goga-swift-6.2:1.3` | 8. **Environment Variables** -- Configure environment variables for the build. Suggested keys are offered per agent (e.g., `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL` for Claude; `CODEX_MODEL` for Codex). You can also add arbitrary custom variables. diff --git a/docs/configuration/project.md b/docs/configuration/project.md index 2e1af1b9..bdf84a97 100644 --- a/docs/configuration/project.md +++ b/docs/configuration/project.md @@ -16,7 +16,7 @@ For the machine-wide `~/.goga/config.yml`, see [Home Configuration](home.md). ```yaml language: python -image: qarium/goga-python-3.14:1.2 +image: qarium/goga-python-3.14:1.3 # dockerfile: .goga/Dockerfile # optional — when set, `--update` builds from this Dockerfile instead of pulling build: @@ -91,7 +91,7 @@ codemanifest: | Field | Type | Required | Description | |-------|------|----------|-------------| | `language` | `string` | Yes | Project language. One of: `python`, `golang`, `kotlin`, `swift`, `javascript` | -| `image` | `string` | No | Docker image used by `goga build` and `goga pipeline` (e.g. `qarium/goga-python-3.14:1.2`). Consumers raise an error when it is unset. | +| `image` | `string` | No | Docker image used by `goga build` and `goga pipeline` (e.g. `qarium/goga-python-3.14:1.3`). Consumers raise an error when it is unset. | | `dockerfile` | `string` | No | Path to a project Dockerfile. When set, `goga build --update` and `goga pipeline --update` build the image locally from this Dockerfile (fatal on build failure). When unset (default), `--update` pulls `image` from the registry instead (non-fatal warning on pull failure) | | `build` | mapping | No | Build pipeline settings. Optional at the loader level; `goga build` raises a `ClickException` when the section is absent | | `pipeline` | mapping | No | Pipeline (afm) execution settings. Optional at the loader level; `goga pipeline` raises a `ClickException` when the section is absent | @@ -201,11 +201,11 @@ goga provides prebuilt language images for build execution: | Language | Images | |----------|--------| -| Python | `qarium/goga-python-3.10:1.2` through `qarium/goga-python-3.14:1.2` | -| Go | `qarium/goga-golang-1.23:1.2` through `qarium/goga-golang-1.26:1.2` | -| JavaScript | `qarium/goga-node-22:1.2`, `qarium/goga-node-24:1.2` | -| Kotlin | `qarium/goga-kotlin-2.0:1.2` through `qarium/goga-kotlin-2.3:1.2` | -| Swift | `qarium/goga-swift-6.0:1.2` through `qarium/goga-swift-6.2:1.2` | +| Python | `qarium/goga-python-3.10:1.3` through `qarium/goga-python-3.14:1.3` | +| Go | `qarium/goga-golang-1.23:1.3` through `qarium/goga-golang-1.26:1.3` | +| JavaScript | `qarium/goga-node-22:1.3`, `qarium/goga-node-24:1.3` | +| Kotlin | `qarium/goga-kotlin-2.0:1.3` through `qarium/goga-kotlin-2.3:1.3` | +| Swift | `qarium/goga-swift-6.0:1.3` through `qarium/goga-swift-6.2:1.3` | ## Validation errors diff --git a/goga/onboarding/CODEMANIFEST b/goga/onboarding/CODEMANIFEST index 00dd6905..a23f16be 100644 --- a/goga/onboarding/CODEMANIFEST +++ b/goga/onboarding/CODEMANIFEST @@ -19,11 +19,11 @@ Usages: For languages with predefined images, display a list of suggestions; default to the last entry. Accept arbitrary user input for the image name. Language → available image mapping: - - python: qarium/goga-python-{3.10-3.14}:1.2 - - golang: qarium/goga-golang-{1.23, 1.24, 1.25, 1.26}:1.2 - - javascript: qarium/goga-node-{22, 24}:1.2 - - kotlin: qarium/goga-kotlin-{2.0, 2.1, 2.2, 2.3}:1.2 - - swift: qarium/goga-swift-{6.0, 6.1, 6.2}:1.2 + - python: qarium/goga-python-{3.10-3.14}:1.3 + - golang: qarium/goga-golang-{1.23, 1.24, 1.25, 1.26}:1.3 + - javascript: qarium/goga-node-{22, 24}:1.3 + - kotlin: qarium/goga-kotlin-{2.0, 2.1, 2.2, 2.3}:1.3 + - swift: qarium/goga-swift-{6.0, 6.1, 6.2}:1.3 agent_env_defaults: | Map each agent to a list of environment variable keys for prompting. Display the keys to the user; collect corresponding values. diff --git a/goga/onboarding/questionnaire.py b/goga/onboarding/questionnaire.py index cff56550..fb2229d2 100644 --- a/goga/onboarding/questionnaire.py +++ b/goga/onboarding/questionnaire.py @@ -9,32 +9,32 @@ _IMAGE_MAP: dict[str, list[str]] = { "python": [ - "qarium/goga-python-3.10:1.2", - "qarium/goga-python-3.11:1.2", - "qarium/goga-python-3.12:1.2", - "qarium/goga-python-3.13:1.2", - "qarium/goga-python-3.14:1.2", + "qarium/goga-python-3.10:1.3", + "qarium/goga-python-3.11:1.3", + "qarium/goga-python-3.12:1.3", + "qarium/goga-python-3.13:1.3", + "qarium/goga-python-3.14:1.3", ], "golang": [ - "qarium/goga-golang-1.23:1.2", - "qarium/goga-golang-1.24:1.2", - "qarium/goga-golang-1.25:1.2", - "qarium/goga-golang-1.26:1.2", + "qarium/goga-golang-1.23:1.3", + "qarium/goga-golang-1.24:1.3", + "qarium/goga-golang-1.25:1.3", + "qarium/goga-golang-1.26:1.3", ], "javascript": [ - "qarium/goga-node-22:1.2", - "qarium/goga-node-24:1.2", + "qarium/goga-node-22:1.3", + "qarium/goga-node-24:1.3", ], "kotlin": [ - "qarium/goga-kotlin-2.0:1.2", - "qarium/goga-kotlin-2.1:1.2", - "qarium/goga-kotlin-2.2:1.2", - "qarium/goga-kotlin-2.3:1.2", + "qarium/goga-kotlin-2.0:1.3", + "qarium/goga-kotlin-2.1:1.3", + "qarium/goga-kotlin-2.2:1.3", + "qarium/goga-kotlin-2.3:1.3", ], "swift": [ - "qarium/goga-swift-6.0:1.2", - "qarium/goga-swift-6.1:1.2", - "qarium/goga-swift-6.2:1.2", + "qarium/goga-swift-6.0:1.3", + "qarium/goga-swift-6.1:1.3", + "qarium/goga-swift-6.2:1.3", ], } diff --git a/tests/onboarding/test_questionnaire.py b/tests/onboarding/test_questionnaire.py index ad3258e8..e7ea88be 100644 --- a/tests/onboarding/test_questionnaire.py +++ b/tests/onboarding/test_questionnaire.py @@ -425,14 +425,14 @@ def test_all_languages_have_image_map_entries(self) -> None: for language in _LANGUAGES: assert language in _IMAGE_MAP, f"Language '{language}' missing from _IMAGE_MAP" - def test_image_map_defaults_use_version_1_2(self) -> None: - """All suggested Docker images use the current default tag `:1.2`.""" + def test_image_map_defaults_use_version_1_3(self) -> None: + """All suggested Docker images use the current default tag `:1.3`.""" from goga.onboarding.questionnaire import _IMAGE_MAP for language, images in _IMAGE_MAP.items(): assert images, f"Language '{language}' has no image entries" for image in images: - assert image.endswith(":1.2"), f"Image '{image}' for language '{language}' must use the :1.2 tag" + assert image.endswith(":1.3"), f"Image '{image}' for language '{language}' must use the :1.3 tag" def test_questionnaire_ask_goga_config_duplicate_usage_name_skipped(self) -> None: prompts = iter( From 5da0ceb84ae943d8ae44eb220a30a181bcf8d2f6 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 21:25:02 +0000 Subject: [PATCH 215/229] docs: end public docstring summary first lines with a period The convention requires the docstring first line to end with a period. Thirteen public summaries were wrapped mid-sentence so the first physical line carried no period: seven fit on a single line and were collapsed, six were split into a summary sentence plus a continuation sentence. --- goga/commands/topics/render.py | 5 +++-- goga/history/prune.py | 3 +-- goga/topics/creation.py | 14 ++++++++------ goga/topics/deletion.py | 9 +++------ goga/topics/ensuring.py | 7 ++++--- goga/topics/git/publish.py | 3 +-- goga/topics/publishing.py | 7 ++++--- goga/topics/switching.py | 9 ++++----- 8 files changed, 28 insertions(+), 29 deletions(-) diff --git a/goga/commands/topics/render.py b/goga/commands/topics/render.py index c5bc21d4..6f44b5c7 100644 --- a/goga/commands/topics/render.py +++ b/goga/commands/topics/render.py @@ -26,8 +26,9 @@ def render_topic_board(records: list[BoardRecord], width: int, info: bool = False) -> None: - """Render the board as a table: topic, branch, and statuses — under - ``info`` the todo column sits between branch and statuses. + """Render the board as a table: topic, branch, and statuses. + + Under ``info``, the todo column sits between branch and statuses. Args: records: The collected board records — already sorted by the domain. diff --git a/goga/history/prune.py b/goga/history/prune.py index 9578e721..9697bd43 100644 --- a/goga/history/prune.py +++ b/goga/history/prune.py @@ -18,8 +18,7 @@ def prune_topics(year: str | None = None, dry_run: bool = False) -> list[str]: - """Delete the orphan topics of one year — the topics no branch of the - repository inventory hosts. + """Delete the orphan topics of one year — the topics no branch of the repository inventory hosts. Args: year: Optional year as four digits — ``None`` and the empty string diff --git a/goga/topics/creation.py b/goga/topics/creation.py index 940bc145..d688435f 100644 --- a/goga/topics/creation.py +++ b/goga/topics/creation.py @@ -99,8 +99,7 @@ def check_branch_occupancy(branch_name: str, slug: str, year: str | None = None) def check_slug_occupancy(slug: str, year: str | None = None) -> str | None: - """Decide whether any branch of the inventory already hosts the topic - directory of the slug. + """Decide whether any branch of the inventory already hosts the topic directory of the slug. Reads the branch trees through ``read_ref_tree_paths`` — the local branches and the remote-tracking refs as they exist locally, without @@ -147,8 +146,9 @@ def create_topic( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared signat commit_message: str | None = None, year: str | None = None, ) -> str: - """Create fresh work — a branch off an explicit base with the name as - entered, checked out, with its topic directory of the year and an + """Create fresh work — a branch off an explicit base with the name as entered. + + The branch is checked out, with its topic directory of the year and an optional todo; the publication ask may hand the work to the fast publication cycle instead. @@ -244,8 +244,10 @@ def create_topic( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared signat def enter_topic_todo(topic: str, year: str | None = None) -> bool: - """Enter the todo of a topic — the editor session with the topic's - todo.md and the write of the saved text, without a commit. + """Enter the todo of a topic. + + The editor session with the topic's todo.md and the write of the saved + text, without a commit. Args: topic: Topic input — a branch name or an already-normalized slug. diff --git a/goga/topics/deletion.py b/goga/topics/deletion.py index d21e094b..44bdb2fb 100644 --- a/goga/topics/deletion.py +++ b/goga/topics/deletion.py @@ -46,8 +46,7 @@ @dataclass(frozen=True, kw_only=True) class DeleteTarget: - """One identified deletion target — a topic with its hosting refs and - directory. + """One identified deletion target — a topic with its hosting refs and directory. Attributes: topic: The topic slug. @@ -65,8 +64,7 @@ class DeleteTarget: def resolve_delete_targets(identifiers: list[str], year: str | None = None) -> list[DeleteTarget]: - """Resolve deletion identifiers into targets — every check before any - removal. + """Resolve deletion identifiers into targets — every check before any removal. Args: identifiers: The user inputs — branch names, topic slugs, or their @@ -447,8 +445,7 @@ def _guard_current_branch(targets: list[DeleteTarget]) -> None: def delete_topics(targets: list[DeleteTarget], year: str | None = None) -> str: - """Delete confirmed targets — the local branch, the origin twin, and - the topic directory of each. + """Delete confirmed targets — the local branch, the origin twin, and the topic directory of each. Args: targets: The confirmed targets, as resolved by diff --git a/goga/topics/ensuring.py b/goga/topics/ensuring.py index fe545c6b..271ca8bc 100644 --- a/goga/topics/ensuring.py +++ b/goga/topics/ensuring.py @@ -39,9 +39,10 @@ def ensure_topic(identifier: str, todo: bool = False, year: str | None = None) -> str: - """Bring the repository onto the requested work, creating it when nothing - hosts the identifier; with the todo flag, enter the todo of the work - after the switch or the creation. + """Bring the repository onto the requested work, creating it when nothing hosts the identifier. + + With the todo flag, enter the todo of the work after the switch or the + creation. Args: identifier: The user input — a branch name, a topic slug, or their diff --git a/goga/topics/git/publish.py b/goga/topics/git/publish.py index 0ab92234..3def2190 100644 --- a/goga/topics/git/publish.py +++ b/goga/topics/git/publish.py @@ -56,8 +56,7 @@ def resolve_ref_commit(ref: str) -> str: def commit_file_on_base(base: str, path: str, content: str, message: str) -> str: - """Build one commit that adds a single file on top of a parent commit — - without touching the working copy. + """Build one commit that adds a single file on top of a parent commit — without touching the working copy. Args: base: The parent commit hash. diff --git a/goga/topics/publishing.py b/goga/topics/publishing.py index 1d6a4778..b63880d4 100644 --- a/goga/topics/publishing.py +++ b/goga/topics/publishing.py @@ -52,9 +52,10 @@ def publish_topic( commit_message: str | None = None, year: str | None = None, ) -> str: - """Create fresh work and publish it — a branch off an explicit base - carrying one commit with the topic todo, pushed to origin, while the - caller stays on their branch. + """Create fresh work and publish it. + + A branch off an explicit base carrying one commit with the topic todo, + pushed to origin, while the caller stays on their branch. Args: branch_name: Branch name as entered by the user. diff --git a/goga/topics/switching.py b/goga/topics/switching.py index 72892de2..c318aadf 100644 --- a/goga/topics/switching.py +++ b/goga/topics/switching.py @@ -41,8 +41,7 @@ @dataclass(frozen=True, kw_only=True) class SwitchCandidate: - """One candidate of a switch-identifier resolution — a branch that may - host the requested work. + """One candidate of a switch-identifier resolution — a branch that may host the requested work. Attributes: branch: The display name of the candidate branch. @@ -121,9 +120,9 @@ def resolve_switch_candidates(identifier: str, year: str | None = None) -> list[ def switch_topic(identifier: str, todo: bool = False, year: str | None = None) -> str: - """Bring the repository onto the branch hosting the requested work; - with the todo flag, enter the todo of the switched topic after the - switch. + """Bring the repository onto the branch hosting the requested work. + + With the todo flag, enter the todo of the switched topic after the switch. Args: identifier: The user input — a branch name, a topic slug, or their From 85d482d634cd567d4b1a73ced92346dcf6afe0b6 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 21:28:16 +0000 Subject: [PATCH 216/229] style: separate logical blocks with one blank line in function bodies The convention requires one blank line between logical blocks inside function and method bodies: initialization before conditionals and loops, data preparation before processing, processing before the return. Inserted 217 blank lines at 217 verified boundaries across 46 in-scope files; tightly coupled guard pairs (a value computed and immediately tested) were reviewed and kept as-is. --- goga/commands/history/history.py | 5 ++++ goga/commands/hooks/hooks.py | 1 + goga/commands/install/hook.py | 4 ++++ goga/commands/install/install.py | 5 ++++ goga/config/project/loader.py | 2 ++ goga/history/git/branch.py | 1 + goga/history/paths.py | 1 + goga/history/prune.py | 2 ++ goga/history/statuses/assembly.py | 5 ++++ goga/history/statuses/registry.py | 1 + goga/history/statuses/scale.py | 4 ++++ goga/pipeline/compiler/compile_flow.py | 2 ++ goga/pipeline/compiler/serialize_flow.py | 1 + goga/topics/board.py | 3 +++ goga/topics/creation.py | 2 ++ goga/topics/deletion.py | 8 +++++++ goga/topics/ensuring.py | 1 + goga/topics/publishing.py | 1 + tests/build/test_build.py | 3 +++ tests/build/test_review_options.py | 1 + tests/commands/history/test_history.py | 3 +++ .../commands/history/test_history_command.py | 9 ++++++++ tests/commands/history/test_render.py | 2 ++ tests/commands/install/test_hook.py | 19 +++++++++++++++ tests/commands/install/test_install.py | 3 +++ tests/commands/install/test_integration.py | 1 + .../pipeline/test_pipeline_command.py | 5 ++++ .../pipeline/test_pipeline_dispatch.py | 8 +++++++ tests/commands/topics/test_topics_command.py | 23 +++++++++++++++++++ tests/config/test_loader.py | 5 ++++ tests/history/git/test_refs.py | 6 +++++ tests/history/statuses/test_assembly.py | 1 + tests/history/test_paths.py | 12 ++++++++++ tests/history/test_prune.py | 17 ++++++++++++++ tests/history/test_status.py | 3 +++ tests/integration/test_base_ref_end_to_end.py | 5 ++++ tests/integration/test_topic_workflows.py | 2 ++ .../compiler/test_compile_flow_memory.py | 1 + .../workflow/test_parse_workflow_memory.py | 1 + tests/topics/git/test_publish.py | 17 ++++++++++++++ tests/topics/git/test_refs.py | 3 +++ tests/topics/git/test_switch.py | 6 +++++ tests/topics/git/test_trees.py | 8 +++++++ tests/topics/test_deletion.py | 1 + tests/topics/test_ensuring.py | 1 + tests/topics/test_publishing.py | 2 ++ 46 files changed, 217 insertions(+) diff --git a/goga/commands/history/history.py b/goga/commands/history/history.py index 9f99b73d..0632a112 100644 --- a/goga/commands/history/history.py +++ b/goga/commands/history/history.py @@ -61,6 +61,7 @@ def _resolve_topic_input(topic: str | None) -> str: branch = resolve_current_branch_name() if branch is None: raise click.ClickException("cannot determine the current git branch — pass a topic explicitly") + return branch @@ -119,12 +120,14 @@ def status(scope: _HistoryScope, topic: str | None = None, statuses: tuple[str, raise click.ClickException(f"unknown status name: {name!r}") from exc filter_slug: str | None = None + if topic is not None: filter_slug = normalize_topic_slug(topic) if filter_slug == "": raise click.ClickException(f"topic filter {topic!r} normalizes to an empty topic slug") records = collect_topic_statuses(scope.year, scale) + if topic is not None: records = [record for record in records if filter_slug in record.topic] if statuses: @@ -155,6 +158,7 @@ def path(scope: _HistoryScope, topic: str | None = None, filename: str | None = on disk. """ resolved_topic = _resolve_topic_input(topic) + try: if filename is not None: resolved_path = resolve_topic_file(resolved_topic, filename, scope.year) @@ -181,6 +185,7 @@ def ensure(scope: _HistoryScope, name: str | None = None) -> None: created belongs to the caller). """ resolved_name = _resolve_topic_input(name) + try: ensure_topic_dir(resolved_name, scope.year) except ValueError as exc: diff --git a/goga/commands/hooks/hooks.py b/goga/commands/hooks/hooks.py index 7ecf8fe7..e3c54db7 100644 --- a/goga/commands/hooks/hooks.py +++ b/goga/commands/hooks/hooks.py @@ -66,6 +66,7 @@ def hooks(ctx: click.Context, tools: tuple[str, ...] = ()) -> None: raise click.ClickException(str(exc)) from exc view = registry.by_tool() + if tools: view = _slice_view(view, tools) render_hooks_tree(view) diff --git a/goga/commands/install/hook.py b/goga/commands/install/hook.py index 4434d525..687d42f9 100644 --- a/goga/commands/install/hook.py +++ b/goga/commands/install/hook.py @@ -47,6 +47,7 @@ def resolve_initiating_user() -> str: sudo_user = os.environ.get("SUDO_USER") if sudo_user: return sudo_user + return getpass.getuser() @@ -89,6 +90,7 @@ def call_install_hook(tool: str, user: str) -> bool: # multi-word identifier makes the facade import miss and the hook degrade # to the quiet-skip path. module_name = f"goga_tool_{tool.replace('-', '_').replace('.', '_').lower()}" + try: module = importlib.import_module(module_name) except ModuleNotFoundError as exc: @@ -105,6 +107,7 @@ def call_install_hook(tool: str, user: str) -> bool: install(user=user) else: install() + return True @@ -131,6 +134,7 @@ def run_install_hooks(tools: list[str]) -> None: return user = resolve_initiating_user() + for tool in tools: try: invoked = call_install_hook(tool, user) diff --git a/goga/commands/install/install.py b/goga/commands/install/install.py index b4560ee3..e5df9a9e 100644 --- a/goga/commands/install/install.py +++ b/goga/commands/install/install.py @@ -106,6 +106,7 @@ def _parse_local(value: str) -> tuple[str, str | None]: raise click.ClickException(f"malformed --local value {value!r}: tool name must not contain a path separator") if ":" in tool: raise click.ClickException(f"malformed --local value {value!r}: tool name must not contain ':'") + return path, tool @@ -131,6 +132,7 @@ def _local_hook_targets(local_path: str, local_tool: str | None) -> list[str]: extra={"path": local_path, "hint": "pass :<tool-name> to enable the post-install hook"}, ) return [] + return [local_tool] @@ -152,11 +154,13 @@ def _resolve_bulk_pkgs(tools: dict[str, str]) -> list[str]: click.ClickException: when a tool's version form is rejected. """ pkgs: list[str] = [] + for tool_name, form in tools.items(): try: pkgs.append(_resolve_pkg(tool_name, form)) except ValueError as exc: raise click.ClickException(f"invalid version for tool {tool_name!r}: {exc}") from exc + return pkgs @@ -275,6 +279,7 @@ def install( # noqa: PLR0913, PLR0917 — Click callback arity is contract-mand local_path: str | None = None local_tool: str | None = None + if local is not None: # 0.3. VALIDATION — the :<tool-name> suffix grammar; a malformed suffix # aborts before any pip, a well-formed one names the hook target. diff --git a/goga/config/project/loader.py b/goga/config/project/loader.py index a2b21a08..8e43d320 100644 --- a/goga/config/project/loader.py +++ b/goga/config/project/loader.py @@ -210,6 +210,7 @@ def _parse_topics_field(value, key: str) -> str | None: return None if not isinstance(value, str): raise ValueError(f"{key} must be a string in .goga/config.yml") + return value.strip() or None @@ -456,6 +457,7 @@ def _optional_mapping(data: dict, key: str) -> dict | None: return None if not isinstance(section, dict): raise ValueError(f"'{key}' must be a mapping in .goga/config.yml") + return section diff --git a/goga/history/git/branch.py b/goga/history/git/branch.py index fa59db8f..9817fb21 100644 --- a/goga/history/git/branch.py +++ b/goga/history/git/branch.py @@ -45,4 +45,5 @@ def resolve_current_branch_name() -> str | None: value = result.stdout.strip() if value == "": return None + return value diff --git a/goga/history/paths.py b/goga/history/paths.py index ef0f3ecb..c5006d7e 100644 --- a/goga/history/paths.py +++ b/goga/history/paths.py @@ -89,6 +89,7 @@ def resolve_topic_file(topic: str, filename: str, year: str | None = None) -> Pa """ if PurePath(filename).suffix in ("", "."): raise ValueError(f"filename {filename!r} must carry an extension") + return resolve_topic_dir(topic, year) / filename diff --git a/goga/history/prune.py b/goga/history/prune.py index 9697bd43..d312177b 100644 --- a/goga/history/prune.py +++ b/goga/history/prune.py @@ -90,7 +90,9 @@ def prune_topics(year: str | None = None, dry_run: bool = False) -> list[str]: normalize_topic_slug(ref.name.partition("/")[2] if ref.remote else ref.name) for ref in list_branch_refs() } orphans = sorted({normalize_topic_slug(topic) for topic in year_topics} - hosted) + if not dry_run: for slug in orphans: remove_topic_dir(slug, resolved_year) + return orphans diff --git a/goga/history/statuses/assembly.py b/goga/history/statuses/assembly.py index 87b65b77..177ed161 100644 --- a/goga/history/statuses/assembly.py +++ b/goga/history/statuses/assembly.py @@ -79,11 +79,13 @@ def context_for(tool: str) -> StatusRegistry: """Build the context view of one receiving tool — at most one registry per tool identity.""" if tool not in registries: registries[tool] = StatusRegistry(builtin_stages=list(_BUILTIN_AXIS), tool_prefix=tool) + return registries[tool] emit_hook_event(registry, _ACTION_DOMAIN, _ACTION_NAME, context_for) stages = list(_BUILTIN_AXIS) + for status_registry in registries.values(): for entry in status_registry.stages[len(_BUILTIN_AXIS) :]: try: @@ -92,6 +94,7 @@ def context_for(tool: str) -> StatusRegistry: print(f"Warning: skipping status registration {entry.name}: {exc}", file=sys.stderr) continue stages.insert(index, entry) + return StatusScale(stages=stages) @@ -112,6 +115,7 @@ def _placement_index(stages: list[Stage], entry: Stage) -> int: positions = {stage.name: index for index, stage in enumerate(stages)} after = entry.after before = entry.before + if after is not None and after not in positions: raise ValueError(f"status entry {entry.name!r}: unknown after anchor {after!r}") if before is not None and before not in positions: @@ -126,4 +130,5 @@ def _placement_index(stages: list[Stage], entry: Stage) -> int: return positions[after] + 1 + block if not positions[after] < positions[before]: raise ValueError(f"status entry {entry.name!r}: anchor range {after!r}..{before!r} is invalid") + return positions[before] diff --git a/goga/history/statuses/registry.py b/goga/history/statuses/registry.py index 8e10fbe0..dea68fb9 100644 --- a/goga/history/statuses/registry.py +++ b/goga/history/statuses/registry.py @@ -84,6 +84,7 @@ def register( registered by another tool. Do not modify built-in entries. """ qualified = f"{self.tool_prefix}.{name}" + if not isinstance(name, str) or not name: raise ValueError(f"status entry {qualified!r}: name must be a non-empty string") if not isinstance(filepath, str) or not filepath: diff --git a/goga/history/statuses/scale.py b/goga/history/statuses/scale.py index 5b5c05cd..400b8f05 100644 --- a/goga/history/statuses/scale.py +++ b/goga/history/statuses/scale.py @@ -138,8 +138,10 @@ def _strictly_above(self) -> dict[str, set[str]]: """ above: dict[str, set[str]] = {stage.name: set() for stage in self.stages} axis = [stage for stage in self.stages if stage.before is None and stage.after is None] + for below, upper in pairwise(axis): above[below.name].add(upper.name) + for stage in self.stages: if stage.after is not None and stage.after in above: above[stage.after].add(stage.name) @@ -149,6 +151,7 @@ def _strictly_above(self) -> dict[str, set[str]]: # small, and the loop stays safe even on a cyclic hand-built input. names = list(above) changed = True + while changed: changed = False for name in names: @@ -158,4 +161,5 @@ def _strictly_above(self) -> dict[str, set[str]]: if missing: above[name] = uppers | missing changed = True + return above diff --git a/goga/pipeline/compiler/compile_flow.py b/goga/pipeline/compiler/compile_flow.py index 2e947467..d2fc5281 100644 --- a/goga/pipeline/compiler/compile_flow.py +++ b/goga/pipeline/compiler/compile_flow.py @@ -1028,6 +1028,7 @@ def _memory_emission( method = config.method participating_ids: set[str] = set() + for base_name, produced_ids in expanded_ids.items(): stage = effective.get(base_name) instr_reflect = stage.reflect if stage is not None else None @@ -1050,6 +1051,7 @@ def _memory_emission( ) keys_by_id: dict[str, dict[str, Any]] = {} + for base_name, produced_ids in expanded_ids.items(): for produced_id in produced_ids: if method == "alignment": diff --git a/goga/pipeline/compiler/serialize_flow.py b/goga/pipeline/compiler/serialize_flow.py index 92e7eb1e..b83222c4 100644 --- a/goga/pipeline/compiler/serialize_flow.py +++ b/goga/pipeline/compiler/serialize_flow.py @@ -194,6 +194,7 @@ def serialize_flow(doc: FlowDocument) -> str: top["name"] = doc.name top["description"] = doc.description + if doc.memory is not None: top["memory"] = { key: value diff --git a/goga/topics/board.py b/goga/topics/board.py index d62ec13e..cb872ab3 100644 --- a/goga/topics/board.py +++ b/goga/topics/board.py @@ -162,6 +162,7 @@ def _board_records(year: str | None, remote: bool) -> list[BoardRecord]: topics_by_ref = _year_topics_by_ref(refs, resolved_year) rows: dict[tuple[str, str], _Row] = {} + for ref in refs: if remote or current is None or ref.name != current: for slug, artifacts in topics_by_ref[ref.name].items(): @@ -299,6 +300,7 @@ def _todo_summary(content: str | None) -> str | None: """ if content is None: return None + return next( (line.lstrip("#").strip() for line in content.splitlines() if line.lstrip("#").strip()), "", @@ -349,6 +351,7 @@ def _marks_current(branch: str, current: str | None, remote: bool) -> bool: """ if current is None: return False + return _short_name(branch) == current if remote else branch == current diff --git a/goga/topics/creation.py b/goga/topics/creation.py index d688435f..a8cd4215 100644 --- a/goga/topics/creation.py +++ b/goga/topics/creation.py @@ -436,6 +436,7 @@ def _resolve_todo(todo: str | None) -> str | None: raise click.ClickException( "the todo needs a value — pass --todo/-t or run the creation on an interactive terminal" ) + return edit_text() @@ -455,6 +456,7 @@ def _publication_asked(publish: bool, todo: str | None) -> bool: """ if not publish and todo is not None and sys.stdin.isatty(): return click.confirm("Publish the branch to origin?") + return publish diff --git a/goga/topics/deletion.py b/goga/topics/deletion.py index 44bdb2fb..3264f115 100644 --- a/goga/topics/deletion.py +++ b/goga/topics/deletion.py @@ -155,6 +155,7 @@ def _resolve_delete_targets(identifiers: list[str], year: str | None) -> list[De disk = _disk_slugs(resolved_year) topics: list[str] = [] + for identifier in identifiers: topic = _identify(identifier, refs, hosted, disk) if topic not in topics: @@ -217,6 +218,7 @@ def _disk_slugs(year: str) -> set[str]: for record in collect_history_tree(): if record.year == year: return set(record.topics) + return set() @@ -284,6 +286,7 @@ def _tier_exact_branch(identifier: str, refs: list[BranchRef], hosted: dict[str, ] if not matched: return None + return set().union(*(hosted[ref.name] for ref in matched)) @@ -305,6 +308,7 @@ def _tier_exact_slug(slug: str, hosted: dict[str, set[str]], disk: set[str]) -> return None if any(slug in slugs for slugs in hosted.values()) or slug in disk: return {slug} + return None @@ -337,13 +341,16 @@ def _tier_prefix( for ref in refs if ref.name.startswith(identifier) or (ref.remote and _short_name(ref.name).startswith(identifier)) ] + for ref in matched: topics |= hosted[ref.name] + if slug != "": topics |= {hosted_slug for slugs in hosted.values() for hosted_slug in slugs if hosted_slug.startswith(slug)} topics |= {disk_slug for disk_slug in disk if disk_slug.startswith(slug)} if not matched and not topics: return None + return topics @@ -439,6 +446,7 @@ def _guard_current_branch(targets: list[DeleteTarget]) -> None: if current is None: return slug = normalize_topic_slug(current) + for target in targets: if target.branch == current or slug == target.topic: raise click.ClickException(f"the current branch hosts topic {target.topic!r} — switch away before deleting") diff --git a/goga/topics/ensuring.py b/goga/topics/ensuring.py index 271ca8bc..f38f52d6 100644 --- a/goga/topics/ensuring.py +++ b/goga/topics/ensuring.py @@ -177,6 +177,7 @@ def _create_fresh_work(identifier: str, todo: bool, year: str | None) -> str: create_and_switch_branch(identifier) ensure_topic_dir(identifier, year) + if todo: enter_topic_todo(identifier, year) diff --git a/goga/topics/publishing.py b/goga/topics/publishing.py index b63880d4..ed48073f 100644 --- a/goga/topics/publishing.py +++ b/goga/topics/publishing.py @@ -157,6 +157,7 @@ def _publish_topic( ) create_branch_at_commit(branch_name, commit) + try: push_branch(branch_name) except (subprocess.CalledProcessError, OSError): diff --git a/tests/build/test_build.py b/tests/build/test_build.py index 1a346da6..8cd769e6 100644 --- a/tests/build/test_build.py +++ b/tests/build/test_build.py @@ -1187,6 +1187,7 @@ def test_full_pass_carries_review_scoped_options(self, tmp_path, monkeypatch) -> # Same agent as the task executor and an empty review env -> a single # full pass, which IS review-carrying: the scoped options ride along. config = _make_config(review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/1.2.x", patience=3)) + with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: result = _run_build_in_tmp( tmp_path, @@ -1234,6 +1235,7 @@ def test_cli_scoped_options_override_config_on_review_pass(self, tmp_path, monke # base_ref/review_patience, the config declares different values, and # the CLI wins on the review-carrying (here: single full) pass. config = _make_config(review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/main", patience=3)) + with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: result = _run_build_in_tmp( tmp_path, @@ -1268,6 +1270,7 @@ def test_skip_run_omits_review_scoped_options(self, tmp_path, monkeypatch) -> No # A skip run has no review phase of any kind: even with review bounds # declared, the single tasks-only pass carries universal options only. config = _make_config(review_executor=ReviewExecutorConfig(skip=True, base_ref="origin/1.2.x", patience=3)) + with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: result = _run_build_in_tmp( tmp_path, diff --git a/tests/build/test_review_options.py b/tests/build/test_review_options.py index d8050906..ea2d1629 100644 --- a/tests/build/test_review_options.py +++ b/tests/build/test_review_options.py @@ -70,6 +70,7 @@ def test_review_options_declares_base_ref_and_patience_fields(self) -> None: def test_resolve_review_options_docstring_lists_three_keys(self) -> None: """The docstring names the three cli_options keys; the old one-key wording is gone.""" doc = resolve_review_options.__doc__ or "" + for key in ("skip_review", "base_ref", "review_patience"): assert key in doc assert "only `skip_review` is read" not in doc diff --git a/tests/commands/history/test_history.py b/tests/commands/history/test_history.py index d882dd3f..31632e21 100644 --- a/tests/commands/history/test_history.py +++ b/tests/commands/history/test_history.py @@ -206,6 +206,7 @@ def test_history_status_unknown_status_name(self) -> None: def test_history_status_broken_scale_assembly_fails_cleanly(self) -> None: """A fatal scale assembly error (broken goga_tool_* import) surfaces clean.""" runner = CliRunner() + with mock.patch.object( _history_module, "assemble_status_scale", @@ -226,6 +227,7 @@ def test_history_status_empty_topic_filter_is_error(self) -> None: def test_history_path_no_branch_fails_cleanly(self) -> None: """path without a positional and without a determinable branch fails clean.""" runner = CliRunner() + with mock.patch.object(_history_module, "resolve_current_branch_name", return_value=None): result = runner.invoke(history, ["path"]) assert result.exit_code == 1 @@ -242,6 +244,7 @@ def test_history_path_extensionless_file_fails(self) -> None: def test_history_ensure_no_branch_fails_cleanly(self) -> None: """ensure without a positional and without a determinable branch fails clean.""" runner = CliRunner() + with mock.patch.object(_history_module, "resolve_current_branch_name", return_value=None): result = runner.invoke(history, ["ensure"]) assert result.exit_code == 1 diff --git a/tests/commands/history/test_history_command.py b/tests/commands/history/test_history_command.py index bd847e94..c742145c 100644 --- a/tests/commands/history/test_history_command.py +++ b/tests/commands/history/test_history_command.py @@ -77,6 +77,7 @@ class TestHistoryList: def test_history_list_renders_tree(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """list prints the inventory: years with topics, no statuses, no non-years.""" root = tmp_path / ".goga" / "history" + for relative in ("2025/b-topic", "2025/a-topic", "2026/history-commands", "backups", "20a6"): (root / relative).mkdir(parents=True) (root / "notes.md").write_text("not a year\n", encoding="utf-8") @@ -189,6 +190,7 @@ def test_history_status_defaults_to_current_year_unfiltered( history_root = tmp_path / ".goga" / "history" (history_root / "2025" / "old-topic").mkdir(parents=True) year_dir = history_root / "2031" + for topic in ("alpha", "mid", "zeta"): (year_dir / topic).mkdir(parents=True) (year_dir / "alpha" / "plan.md").write_text("plan\n", encoding="utf-8") @@ -205,6 +207,7 @@ def test_history_status_defaults_to_current_year_unfiltered( def test_history_status_repeatable_status_filter(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """-s repeats: one -s per name keeps several statuses, drops the rest.""" year_dir = tmp_path / ".goga" / "history" / "2026" + for topic in ("done-topic", "planned-topic", "defined-topic"): (year_dir / topic).mkdir(parents=True) (year_dir / "done-topic" / "completed").mkdir() @@ -270,6 +273,7 @@ def test_history_path_prints_file_path_only(self, tmp_path: Path, monkeypatch: p """path answers the branch-defaulted artifact path — one line, nothing created.""" monkeypatch.chdir(tmp_path) runner = CliRunner() + with ( mock.patch.object(naming, "datetime", _FixedClock), mock.patch.object(_history_module, "resolve_current_branch_name", return_value="history-commands"), @@ -286,6 +290,7 @@ def test_history_path_without_file_prints_topic_dir(self, tmp_path: Path, monkey """path without -f answers the branch-defaulted topic directory.""" monkeypatch.chdir(tmp_path) runner = CliRunner() + with ( mock.patch.object(naming, "datetime", _FixedClock), mock.patch.object(_history_module, "resolve_current_branch_name", return_value="Feature/Foo_Bar"), @@ -315,6 +320,7 @@ def test_history_ensure_creates_dir_silently(self, tmp_path: Path, monkeypatch: """ensure normalizes the branch name and is idempotent — stdout stays empty.""" monkeypatch.chdir(tmp_path) runner = CliRunner() + with ( mock.patch.object(naming, "datetime", _FixedClock), mock.patch.object(_history_module, "resolve_current_branch_name", return_value="Feature/Foo_Bar"), @@ -360,6 +366,7 @@ class TestHistoryPrune: def test_history_prune_command_prints_slugs(self) -> None: """prune echoes one slug per line and forwards --dry-run to the domain.""" runner = CliRunner() + with mock.patch.object(_history_module, "prune_topics", return_value=["done-c", "orphan-b"]) as prune_mock: result = runner.invoke(history, ["prune", "--dry-run"]) @@ -370,6 +377,7 @@ def test_history_prune_command_prints_slugs(self) -> None: def test_history_prune_scoped_year_passes_year(self) -> None: """prune forwards the scoped year and --dry-run; the slug list prints.""" runner = CliRunner() + with mock.patch.object(_history_module, "prune_topics", return_value=["orphan-topic"]) as prune_mock: result = runner.invoke(history, ["-y", "2025", "prune", "--dry-run"]) @@ -407,6 +415,7 @@ def test_history_prune_empty_year_value_takes_current_year( def test_history_prune_git_failure_is_clean_error(self, failure: Exception, message: str) -> None: """A domain failure surfaces as a clean error — exit 1, stderr, no traceback.""" runner = CliRunner() + with mock.patch.object(_history_module, "prune_topics", side_effect=failure): result = runner.invoke(history, ["prune"]) diff --git a/tests/commands/history/test_render.py b/tests/commands/history/test_render.py index b89ad3a1..c7bf3410 100644 --- a/tests/commands/history/test_render.py +++ b/tests/commands/history/test_render.py @@ -101,6 +101,7 @@ def test_render_topic_statuses_colors_status_segment( ) -> None: """One color on the status segments; the topic stays plain with no newline.""" monkeypatch.delenv("NO_COLOR", raising=False) + with mock.patch.object(render.click, "secho") as secho_mock: render_topic_statuses([TopicRecord(topic="t", statuses=["planned"])]) assert secho_mock.call_args == mock.call("[planned]", fg="cyan") @@ -111,6 +112,7 @@ def test_render_topic_statuses_colors_every_segment( ) -> None: """The colored call carries the whole segment sequence of the record.""" monkeypatch.delenv("NO_COLOR", raising=False) + with mock.patch.object(render.click, "secho") as secho_mock: render_topic_statuses([TopicRecord(topic="t", statuses=["done", "mkdocs.published"])]) assert secho_mock.call_args == mock.call("[done] [mkdocs.published]", fg="cyan") diff --git a/tests/commands/install/test_hook.py b/tests/commands/install/test_hook.py index 778db81d..d5710e8f 100644 --- a/tests/commands/install/test_hook.py +++ b/tests/commands/install/test_hook.py @@ -84,16 +84,19 @@ def _raise_unresolvable() -> str: def test_resolve_initiating_user_falls_back_to_os_user(self, monkeypatch: pytest.MonkeyPatch) -> None: """No (or set-but-empty) ``SUDO_USER`` → the OS user name.""" monkeypatch.delenv("SUDO_USER", raising=False) + with mock.patch.object(hook_module.getpass, "getuser", return_value="bob"): assert hook_module.resolve_initiating_user() == "bob" # Set but EMPTY is treated as unset — the OS user is the answer too. monkeypatch.setenv("SUDO_USER", "") + with mock.patch.object(hook_module.getpass, "getuser", return_value="bob"): assert hook_module.resolve_initiating_user() == "bob" def test_resolve_initiating_user_identity_failure_propagates(self, monkeypatch: pytest.MonkeyPatch) -> None: """No fallback name is invented when identity resolution fails.""" monkeypatch.delenv("SUDO_USER", raising=False) + with ( mock.patch.object(hook_module.getpass, "getuser", side_effect=KeyError("uid not found")), pytest.raises(KeyError), @@ -113,6 +116,7 @@ def _fake_install(user: str | None = None) -> None: calls.append({"user": user}) fake_module = types.SimpleNamespace(install=_fake_install) + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module) as mock_import: invoked = hook_module.call_install_hook("fake", "alice") assert invoked is True @@ -132,6 +136,7 @@ def _fake_install(user: str | None = None) -> None: calls.append({"user": user}) fake_module = types.SimpleNamespace(install=_fake_install) + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module) as mock_import: invoked = hook_module.call_install_hook("hello-world", "alice") assert invoked is True @@ -148,6 +153,7 @@ def _fake_install(user: str | None = None) -> None: # importlib.import_module consults sys.modules first, so a registered # throwaway facade is found without a file on sys.path. fake_facade = types.SimpleNamespace(install=_fake_install) + with mock.patch.dict(sys.modules, {"goga_tool_hello_world": fake_facade}): invoked = hook_module.call_install_hook("hello-world", "alice") assert invoked is True @@ -167,6 +173,7 @@ def _fake_install(user: str | None = None) -> None: calls.append({"user": user}) fake_module = types.SimpleNamespace(install=_fake_install) + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module) as mock_import: invoked = hook_module.call_install_hook("Hello-World", "alice") assert invoked is True @@ -181,6 +188,7 @@ def _fake_install(user: str | None = None) -> None: calls.append({"user": user}) fake_facade = types.SimpleNamespace(install=_fake_install) + with mock.patch.dict(sys.modules, {"goga_tool_hello_world": fake_facade}): invoked = hook_module.call_install_hook("Hello-World", "alice") assert invoked is True @@ -194,6 +202,7 @@ def _fake_install(*, user: str | None = None) -> None: calls.append({"user": user}) fake_module = types.SimpleNamespace(install=_fake_install) + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module): invoked = hook_module.call_install_hook("fake", "alice") assert invoked is True @@ -209,6 +218,7 @@ def _fake_install() -> None: calls.append(()) fake_module = types.SimpleNamespace(install=_fake_install) + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module): invoked = hook_module.call_install_hook("fake", "alice") assert invoked is True @@ -224,6 +234,7 @@ def _fake_install(user: str | None = None, /) -> None: calls.append({"user": user}) fake_module = types.SimpleNamespace(install=_fake_install) + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module): invoked = hook_module.call_install_hook("fake", "alice") assert invoked is True @@ -238,6 +249,7 @@ def _fake_install(**kwargs: object) -> None: calls.append(dict(kwargs)) fake_module = types.SimpleNamespace(install=_fake_install) + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module): invoked = hook_module.call_install_hook("fake", "alice") assert invoked is True @@ -246,12 +258,14 @@ def _fake_install(**kwargs: object) -> None: def test_call_install_hook_missing_facade_module_skips_quietly(self) -> None: """A truly missing ``goga_tool_<tool>`` facade is a skip, not a failure.""" error = ModuleNotFoundError("No module named 'goga_tool_ghost'", name="goga_tool_ghost") + with mock.patch.object(hook_module.importlib, "import_module", side_effect=error): assert hook_module.call_install_hook("ghost", "alice") is False def test_call_install_hook_broken_facade_import_is_failure_not_skip(self) -> None: """A DIFFERENT missing module (the facade's own import broke) propagates.""" error = ModuleNotFoundError("No module named 'dep'", name="dep") + with ( mock.patch.object(hook_module.importlib, "import_module", side_effect=error), pytest.raises(ModuleNotFoundError), @@ -276,6 +290,7 @@ def _fake_install(user: str | None = None) -> None: raise ValueError("boom") fake_module = types.SimpleNamespace(install=_fake_install) + with ( mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module), pytest.raises(ValueError, match=r"^boom$"), @@ -302,6 +317,7 @@ def _install_b(user: str | None = None) -> None: monkeypatch.setitem(sys.modules, "goga_tool_a", types.SimpleNamespace(install=_install_a)) monkeypatch.setitem(sys.modules, "goga_tool_b", types.SimpleNamespace(install=_install_b)) monkeypatch.delenv("SUDO_USER", raising=False) + with mock.patch.object(hook_module.getpass, "getuser", return_value="alice"): hook_module.run_install_hooks(["a", "b"]) assert log == [("a", "alice"), ("b", "alice")] @@ -319,6 +335,7 @@ def _install_b(user: str | None = None) -> None: monkeypatch.setitem(sys.modules, "goga_tool_a", types.SimpleNamespace(install=_boom)) monkeypatch.setitem(sys.modules, "goga_tool_b", types.SimpleNamespace(install=_install_b)) monkeypatch.delenv("SUDO_USER", raising=False) + with ( mock.patch.object(hook_module.getpass, "getuser", return_value="alice"), pytest.raises(RuntimeError) as excinfo, @@ -331,6 +348,7 @@ def _install_b(user: str | None = None) -> None: def test_run_install_hooks_user_resolution_failure_not_wrapped(self, monkeypatch: pytest.MonkeyPatch) -> None: """An identity-resolution failure is not a hook failure — no tool context.""" monkeypatch.delenv("SUDO_USER", raising=False) + with ( mock.patch.object(hook_module.getpass, "getuser", side_effect=KeyError("uid not found")), pytest.raises(KeyError), @@ -363,6 +381,7 @@ def _fake_import(name: str) -> types.SimpleNamespace: raise ModuleNotFoundError(f"No module named {name!r}", name=name) monkeypatch.delenv("SUDO_USER", raising=False) + with ( mock.patch.object(hook_module.importlib, "import_module", side_effect=_fake_import), mock.patch.object(hook_module.getpass, "getuser", return_value="alice"), diff --git a/tests/commands/install/test_install.py b/tests/commands/install/test_install.py index 1ef966b8..85d11f3a 100644 --- a/tests/commands/install/test_install.py +++ b/tests/commands/install/test_install.py @@ -559,6 +559,7 @@ def test_install_bulk_path_passes_config_keys_as_hook_targets( ) -> None: _write_config(tmp_path, "language: python\ntools:\n viewer: latest\n afm: 1.0.x\n") monkeypatch.chdir(tmp_path) + with ( mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()), mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, @@ -621,6 +622,7 @@ def test_install_local_with_suffix_targets_tool_hook(self) -> None: def test_install_bulk_hooks_follow_yaml_order(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _write_config(tmp_path, "language: python\ntools:\n viewer: latest\n afm: 1.0.x\n") monkeypatch.chdir(tmp_path) + with ( mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, @@ -714,6 +716,7 @@ def test_install_local_without_suffix_warns_and_runs_no_hook(self, caplog: pytes def test_install_empty_mode_touches_nothing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _write_config(tmp_path, "language: python\ntools: {}\n") monkeypatch.chdir(tmp_path) + with ( mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, diff --git a/tests/commands/install/test_integration.py b/tests/commands/install/test_integration.py index cf7619a6..ae996bc3 100644 --- a/tests/commands/install/test_integration.py +++ b/tests/commands/install/test_integration.py @@ -343,6 +343,7 @@ def test_install_identity_resolution_failure_is_clean_cli_error(self, monkeypatc activation re-sync is never reached. """ monkeypatch.delenv("SUDO_USER", raising=False) + with ( mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, mock.patch.object(hook_module.getpass, "getuser", side_effect=KeyError("uid not found")), diff --git a/tests/commands/pipeline/test_pipeline_command.py b/tests/commands/pipeline/test_pipeline_command.py index 0985b0ac..d9e5f85e 100644 --- a/tests/commands/pipeline/test_pipeline_command.py +++ b/tests/commands/pipeline/test_pipeline_command.py @@ -171,6 +171,7 @@ def test_pipeline_todo_rejects_a_value(self, tmp_path: Path, monkeypatch: pytest _write_config(tmp_path) monkeypatch.chdir(tmp_path) runner = CliRunner() + with mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure: result = runner.invoke(pipeline, ["development", "--topic", "x", "--todo=text"]) @@ -673,6 +674,7 @@ def test_pipeline_todo_without_topic_clean_error(self, tmp_path: Path, monkeypat _write_config(tmp_path) monkeypatch.chdir(tmp_path) runner = CliRunner() + with ( mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure, mock.patch.object(_pipeline_module, "run_pipeline_container") as mock_run, @@ -697,6 +699,7 @@ def test_pipeline_todo_forwarded_to_ensure_topic(self, tmp_path: Path, monkeypat order.attach_mock(mock_ensure, "ensure_topic") order.attach_mock(mock_run, "run_container") runner = CliRunner() + with ( mock.patch.object(_pipeline_module, "ensure_topic", mock_ensure), mock.patch.object(_pipeline_module, "run_pipeline_container", mock_run), @@ -730,6 +733,7 @@ def test_pipeline_todo_silently_ignored_in_info_forms( _write_config(tmp_path) monkeypatch.chdir(tmp_path) runner = CliRunner() + with ( mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure, mock.patch.object(_pipeline_module, "run_pipeline_info_container", return_value=0) as mock_info, @@ -752,6 +756,7 @@ def test_pipeline_todo_non_tty_aborts_before_docker(self, tmp_path: Path, monkey _write_config(tmp_path) monkeypatch.chdir(tmp_path) runner = CliRunner() + with ( mock.patch.object(_pipeline_module, "run_pipeline_container") as mock_run, mock.patch.object(_pipeline_module, "run_pipeline_info_container") as mock_info, diff --git a/tests/commands/pipeline/test_pipeline_dispatch.py b/tests/commands/pipeline/test_pipeline_dispatch.py index 715b1cef..d5c63037 100644 --- a/tests/commands/pipeline/test_pipeline_dispatch.py +++ b/tests/commands/pipeline/test_pipeline_dispatch.py @@ -149,6 +149,7 @@ def test_pipeline_topic_option_contract_both_forms_one_option(self) -> None: config = _make_config() runner = CliRunner() + for argv in (["--topic", "x", "my-pipeline"], ["-t", "x", "my-pipeline"]): with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), @@ -321,6 +322,7 @@ def test_pipeline_topic_option_switches_before_docker(self) -> None: order.attach_mock(mock_ensure, "ensure_topic") order.attach_mock(mock_run, "run_container") runner = CliRunner() + with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), mock.patch.object(_pipeline_module, "ensure_topic", mock_ensure), @@ -354,6 +356,7 @@ def test_pipeline_topic_ignored_in_list_and_info_forms(self, argv: list[str]) -> """The flat list, overview, and card forms ignore -t — no procedure, no line.""" config = _make_config() runner = CliRunner() + with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure, @@ -370,6 +373,7 @@ def test_pipeline_missing_name_with_topic_no_switch(self) -> None: """A step-2 form error exits 1 before any git action of the topic procedure.""" config = _make_config() runner = CliRunner() + with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure, @@ -386,6 +390,7 @@ def test_pipeline_has_no_branch_option(self) -> None: """-b is gone from the surface: unknown option, and absent from --help.""" config = _make_config() runner = CliRunner() + with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure, @@ -497,6 +502,7 @@ def test_pipeline_topic_flow_switches_and_launches(self, tmp_path: Path, monkeyp config = _make_config() runner = CliRunner() + with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), mock.patch.object(_pipeline_module, "run_pipeline_container", return_value=0) as mock_run, @@ -526,6 +532,7 @@ def test_pipeline_topic_idempotent_host_skips_git_mutations( config = _make_config() runner = CliRunner() + with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), mock.patch.object(_pipeline_module, "run_pipeline_container", return_value=0) as mock_run, @@ -554,6 +561,7 @@ def test_pipeline_topic_unresolved_identifier_creates_and_launches( config = _make_config() runner = CliRunner() + with ( mock.patch.object(_pipeline_module, "load_project_config", return_value=config), mock.patch.object(_pipeline_module, "run_pipeline_container", return_value=0) as mock_run, diff --git a/tests/commands/topics/test_topics_command.py b/tests/commands/topics/test_topics_command.py index d1c79bfa..853d17af 100644 --- a/tests/commands/topics/test_topics_command.py +++ b/tests/commands/topics/test_topics_command.py @@ -306,6 +306,7 @@ def test_delete_help_lists_the_surface(self) -> None: def test_year_defaults_to_none_for_the_domain(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Without --year the subcommands hand the domain the current-year None.""" monkeypatch.chdir(tmp_path) + with mock.patch.object(_topics_module, "create_topic") as mock_create: mock_create.return_value = "Created branch X and topic 2026/x" result = CliRunner().invoke(topics, ["create", "X", "--from-current"]) @@ -319,6 +320,7 @@ def test_board_collects_and_renders_the_board(self) -> None: records = [ BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=True, remote=False), ] + with ( mock.patch.object(_topics_module, "collect_topic_board", return_value=records) as mock_collect, mock.patch.dict("os.environ", {"COLUMNS": "100"}), @@ -367,6 +369,7 @@ def test_topics_board_info_flag_reaches_renderer(self, monkeypatch: pytest.Monke # writer probes the width with ``fallback=`` while the patch is live, # and a zero-arg patch aborts the run as an INTERNALERROR. monkeypatch.setattr(shutil, "get_terminal_size", lambda *_args, **_kwargs: os.terminal_size((100, 24))) + with mock.patch.object(_topics_module, "collect_topic_board", return_value=records): result = CliRunner().invoke(topics, ["board", "--info"]) assert result.exit_code == 0 @@ -382,6 +385,7 @@ def test_topics_board_info_short_form_binds_the_same_table(self) -> None: records = [ BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=False, remote=False, todo="T"), ] + with ( mock.patch.object(_topics_module, "collect_topic_board", return_value=records), mock.patch.dict("os.environ", {"COLUMNS": "100"}), @@ -409,6 +413,7 @@ def test_board_measures_the_terminal_width(self, columns: int, expected: int) -> records = [ BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=False, remote=False), ] + with ( mock.patch.object(_topics_module, "collect_topic_board", return_value=records), mock.patch.dict("os.environ", {"COLUMNS": str(columns)}), @@ -458,6 +463,7 @@ def test_create_echoes_the_domain_result_line(self) -> None: def test_topics_create_todo_option_reaches_domain(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """-t hands the domain (name, HEAD, todo, publish, template, year) verbatim.""" monkeypatch.chdir(tmp_path) + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "--from-current", "-t", "Payment retry"]) assert result.exit_code == 0 @@ -490,6 +496,7 @@ def test_create_empty_todo_value_counts_as_absent(self, tmp_path: Path, monkeypa value option there is no CLI-side entry that could need one. """ monkeypatch.chdir(tmp_path) + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: result = CliRunner().invoke(topics, ["create", "feat-a", "--from-current", "--todo", ""]) assert result.exit_code == 0 @@ -573,6 +580,7 @@ def test_create_base_ref_flag_beats_config_beats_from_current( tmp_path, "language: python\ntopics:\n base_ref: origin/config-base\n publish_commit: cfg tpl\n", ) + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: flag_base = CliRunner().invoke(topics, ["create", "n1", "--base-ref", "origin/flag-base"]) config_base = CliRunner().invoke(topics, ["create", "n2"]) @@ -584,6 +592,7 @@ def test_create_base_ref_flag_beats_config_beats_from_current( # A config without topics.base_ref: --from-current yields the HEAD. _write_config(tmp_path, "language: python\n") + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: from_current = CliRunner().invoke(topics, ["create", "n3", "--from-current"]) assert from_current.exit_code == 0 @@ -595,6 +604,7 @@ def test_create_base_ref_flag_beats_config_beats_from_current( tmp_path, "language: python\ntopics:\n base_ref: origin/config-base\n publish_commit: cfg tpl\n", ) + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: flag_template = CliRunner().invoke(topics, ["create", "n4", "--publish", "-t", "T", "--commit", "x {slug}"]) assert flag_template.exit_code == 0 @@ -605,6 +615,7 @@ def test_create_base_ref_flag_beats_config_beats_from_current( empty_dir = tmp_path / "empty" empty_dir.mkdir() monkeypatch.chdir(empty_dir) + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: missing = CliRunner().invoke(topics, ["create", "n5", "--from-current"]) assert missing.exit_code == 0 @@ -613,6 +624,7 @@ def test_create_base_ref_flag_beats_config_beats_from_current( def test_create_no_base_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Nothing set: the error names --base-ref, --from-current, and the config line.""" monkeypatch.chdir(tmp_path) + with mock.patch.object(_topics_module, "create_topic") as mock_create: result = CliRunner().invoke(topics, ["create", "name"]) assert result.exit_code == 1 @@ -625,6 +637,7 @@ def test_create_no_base_clean_error(self, tmp_path: Path, monkeypatch: pytest.Mo def test_create_from_current_passes_head(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """--from-current passes the literal string HEAD — no CLI-side resolution.""" monkeypatch.chdir(tmp_path) + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: result = CliRunner().invoke(topics, ["create", "name", "--from-current"]) assert result.exit_code == 0 @@ -653,6 +666,7 @@ def test_create_both_values_given_reads_no_configuration( tmp_path, "language: python\ntopics:\n base_ref: origin/config-base\n publish_commit: 'config: {slug}'\n", ) + with ( mock.patch.object(_topics_module, "load_project_config") as mock_load, mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create, @@ -684,6 +698,7 @@ def test_create_publish_config_template_beats_domain_default( tmp_path, "language: python\ntopics:\n base_ref: origin/config-base\n publish_commit: 'config: {slug}'\n", ) + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t", "T"]) assert result.exit_code == 0 @@ -695,6 +710,7 @@ def test_create_publish_no_template_anywhere_passes_none( """No --commit and no topics.publish_commit: the template is None — the domain default.""" monkeypatch.chdir(tmp_path) _write_config(tmp_path, "language: python\ntopics:\n base_ref: origin/config-base\n") + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t", "T"]) assert result.exit_code == 0 @@ -706,6 +722,7 @@ def test_create_publish_flag_template_with_config_base( """A config base with a flag template — the flag template wins.""" monkeypatch.chdir(tmp_path) _write_config(tmp_path, "language: python\ntopics:\n base_ref: origin/config-base\n") + with ( mock.patch.object( _topics_module, "load_project_config", wraps=_topics_module.load_project_config @@ -736,6 +753,7 @@ def test_create_invalid_config_surfaces_its_own_error( """A malformed topics section surfaces the loader's error, not a 'no base' guess.""" monkeypatch.chdir(tmp_path) _write_config(tmp_path, "language: python\ntopics: 5\n") + with mock.patch.object(_topics_module, "create_topic") as mock_create: result = CliRunner().invoke(topics, ["create", "X", "--from-current"]) assert result.exit_code == 1 @@ -752,6 +770,7 @@ def test_create_unreadable_config_surfaces_clean_error( monkeypatch.chdir(tmp_path) (tmp_path / ".goga").mkdir() (tmp_path / ".goga" / "config.yml").mkdir() + with mock.patch.object(_topics_module, "create_topic") as mock_create: result = CliRunner().invoke(topics, ["create", "X", "--from-current"]) assert result.exit_code == 1 @@ -788,6 +807,7 @@ def test_delete_confirmed_delegates_and_echoes(self) -> None: DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True), DeleteTarget(topic="release-1-3-0", branch=None, remote=None, has_dir=True), ] + with ( mock.patch.object(click, "confirm", return_value=True) as mock_confirm, mock.patch.object(_topics_module, "resolve_delete_targets", return_value=targets) as mock_resolve, @@ -811,6 +831,7 @@ def test_delete_confirmed_delegates_and_echoes(self) -> None: def test_delete_declined_confirmation_exits_zero(self) -> None: """A declined confirmation exits 0 with nothing deleted.""" targets = [DeleteTarget(topic="feature-foo", branch="feature-foo", remote=None, has_dir=True)] + with ( mock.patch.object(click, "confirm", return_value=False) as mock_confirm, mock.patch.object(_topics_module, "resolve_delete_targets", return_value=targets) as mock_resolve, @@ -826,6 +847,7 @@ def test_delete_declined_confirmation_exits_zero(self) -> None: def test_delete_requires_terminal_without_yes(self) -> None: """A non-TTY without --yes is a clean error — after the read-only resolution.""" targets = [DeleteTarget(topic="feature-foo", branch="feature-foo", remote=None, has_dir=True)] + with ( mock.patch.object(click, "confirm") as mock_confirm, mock.patch.object(_topics_module, "resolve_delete_targets", return_value=targets) as mock_resolve, @@ -842,6 +864,7 @@ def test_delete_requires_terminal_without_yes(self) -> None: def test_delete_yes_short_form_scoped_to_subcommand(self) -> None: """``topics -y 2025 delete -y x``: the group -y binds the year, the subcommand -y the skip.""" targets = [DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True)] + with ( mock.patch.object(click, "confirm") as mock_confirm, mock.patch.object(_topics_module, "resolve_delete_targets", return_value=targets) as mock_resolve, diff --git a/tests/config/test_loader.py b/tests/config/test_loader.py index f240879a..eca715e6 100644 --- a/tests/config/test_loader.py +++ b/tests/config/test_loader.py @@ -3454,6 +3454,7 @@ def test_review_executor_base_ref_non_string_raises(self, goga_project): base_ref: 12 """, ) + with pytest.raises(ValueError, match=r"review_executor\.base_ref must be a string"): load_project_config() @@ -3475,6 +3476,7 @@ def test_review_executor_patience_non_int_raises(self, goga_project, patience_sn {patience_snippet} """, ) + with pytest.raises(ValueError, match=r"review_executor\.patience must be an int"): load_project_config() @@ -3491,6 +3493,7 @@ def test_review_executor_patience_yaml_bool_rejected(self, goga_project): patience: true """, ) + with pytest.raises(ValueError, match=r"review_executor\.patience must be an int"): load_project_config() @@ -3680,6 +3683,7 @@ def test_topics_section_parsed_verbatim(self, goga_project): def test_topics_section_not_mapping_raises_value_error(self, goga_project): """topics: 5 → ValueError with the exact message (not AttributeError).""" _write_goga_yml(goga_project, "language: python\ntopics: 5\n") + with pytest.raises(ValueError, match=r"^'topics' must be a mapping in \.goga/config\.yml$"): load_project_config() @@ -3693,6 +3697,7 @@ def test_topics_section_not_mapping_raises_value_error(self, goga_project): def test_topics_field_not_string_raises_value_error(self, goga_project, bad_yaml, message): """A non-string topics field is a structural type error with the dotted key.""" _write_goga_yml(goga_project, f"language: python\n{bad_yaml}") + with pytest.raises(ValueError, match=f"^{re.escape(message)}$"): load_project_config() diff --git a/tests/history/git/test_refs.py b/tests/history/git/test_refs.py index c09f8692..9172d968 100644 --- a/tests/history/git/test_refs.py +++ b/tests/history/git/test_refs.py @@ -91,6 +91,7 @@ def test_list_branch_refs_signature(self) -> None: def test_git_invocations_follow_the_git_practice(self) -> None: """Two ``for-each-ref`` calls — check/capture/text and a muted prompt.""" run = mock.Mock(side_effect=_answering_run()) + with mock.patch("goga.history.git.refs.subprocess.run", run): list_branch_refs() @@ -119,6 +120,7 @@ def test_list_branch_refs_merges_and_sorts(self) -> None: remotes="origin/HEAD\norigin/feat/a\n", ) ) + with mock.patch("goga.history.git.refs.subprocess.run", run): refs = list_branch_refs() @@ -142,6 +144,7 @@ def test_history_git_inventory_matches_topics_git(self) -> None: remotes="origin/HEAD\norigin/feat/a\n", ) ) + with ( mock.patch("goga.history.git.refs.subprocess.run", run), mock.patch("goga.topics.git.refs.subprocess.run", run), @@ -155,6 +158,7 @@ def test_history_git_inventory_matches_topics_git(self) -> None: def test_list_branch_refs_empty_repository(self) -> None: """An empty inventory is the norm, answered by exactly two calls.""" run = mock.Mock(side_effect=_answering_run(heads="", remotes="")) + with mock.patch("goga.history.git.refs.subprocess.run", run): refs = list_branch_refs() @@ -165,6 +169,7 @@ def test_list_branch_refs_propagates_git_failure(self) -> None: """A git infrastructure failure of the listing propagates unwrapped.""" failure = subprocess.CalledProcessError(returncode=128, cmd=["git", "for-each-ref"]) run = mock.Mock(side_effect=failure) + with ( mock.patch("goga.history.git.refs.subprocess.run", run), pytest.raises(subprocess.CalledProcessError), @@ -174,6 +179,7 @@ def test_list_branch_refs_propagates_git_failure(self) -> None: def test_list_branch_refs_propagates_missing_binary(self) -> None: """A missing git binary surfaces as the OS-level error of the call.""" run = mock.Mock(side_effect=FileNotFoundError("git")) + with ( mock.patch("goga.history.git.refs.subprocess.run", run), pytest.raises(FileNotFoundError, match="git"), diff --git a/tests/history/statuses/test_assembly.py b/tests/history/statuses/test_assembly.py index 13657f3c..812137b8 100644 --- a/tests/history/statuses/test_assembly.py +++ b/tests/history/statuses/test_assembly.py @@ -95,6 +95,7 @@ def emit_hook_event( captured["context_for"] = context_for views: dict[str, Any] = {} + for tool, hook in tools: if tool not in views: views[tool] = context_for(tool) diff --git a/tests/history/test_paths.py b/tests/history/test_paths.py index d334aa50..69d9534d 100644 --- a/tests/history/test_paths.py +++ b/tests/history/test_paths.py @@ -169,6 +169,7 @@ class TestResolveTopicDir: def test_resolve_topic_dir_composes_and_normalizes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Branch input normalizes; no year means the current year; nothing is created.""" monkeypatch.chdir(tmp_path) + with mock.patch.object(naming, "datetime", _FixedClock): assert resolve_topic_dir("Feature/Foo_Bar") == Path(".goga/history/2031/feature-foo-bar") assert resolve_topic_dir("release-1-3-0", year="2025") == Path(".goga/history/2025/release-1-3-0") @@ -177,6 +178,7 @@ def test_resolve_topic_dir_composes_and_normalizes(self, tmp_path: Path, monkeyp def test_resolve_topic_dir_is_idempotent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The same input yields the same path on every call.""" monkeypatch.chdir(tmp_path) + with mock.patch.object(naming, "datetime", _FixedClock): first = resolve_topic_dir("My Tool") second = resolve_topic_dir("My Tool") @@ -185,6 +187,7 @@ def test_resolve_topic_dir_is_idempotent(self, tmp_path: Path, monkeypatch: pyte def test_resolve_topic_dir_empty_slug_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A fully non-ASCII topic raises the clean empty-slug error, no fallback.""" monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="normalizes to an empty topic slug"): resolve_topic_dir("Релиз/Один") @@ -193,6 +196,7 @@ def test_resolve_topic_dir_empty_year_string_means_current( ) -> None: """A falsy year (``""`` from an empty CLI value) means "not set", not path degradation.""" monkeypatch.chdir(tmp_path) + with mock.patch.object(naming, "datetime", _FixedClock): assert resolve_topic_dir("feat-x", year="") == Path(".goga/history/2031/feat-x") @@ -211,6 +215,7 @@ def test_resolve_topic_file_rejects_extensionless( ) -> None: """An extensionless filename — including dotfiles and trailing dots — is a clean error.""" monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="must carry an extension"): resolve_topic_file("history-commands", filename, year="2026") @@ -219,6 +224,7 @@ def test_resolve_topic_file_empty_slug_raises_via_dir_composer( ) -> None: """The file composer reuses the single directory composer — its empty-slug error stands.""" monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="normalizes to an empty topic slug"): resolve_topic_file("Релиз/Один", "plan.md", year="2026") @@ -244,6 +250,7 @@ class TestEnsureTopicDir: def test_ensure_topic_dir_creates_explicit_year(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An explicit year scopes creation to that year — the D1 current-year-only fix.""" monkeypatch.chdir(tmp_path) + with mock.patch.object(naming, "datetime", _FixedClock): created = ensure_topic_dir("Feature/Foo_Bar", year="2025") repeated = ensure_topic_dir("Feature/Foo_Bar", year="2025") @@ -256,6 +263,7 @@ def test_ensure_topic_dir_creates_explicit_year(self, tmp_path: Path, monkeypatc def test_ensure_topic_dir_defaults_to_current_year(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Without a year the current year applies — the pre-existing behavior stands.""" monkeypatch.chdir(tmp_path) + with mock.patch.object(naming, "datetime", _FixedClock): created = ensure_topic_dir("X") assert created == Path(".goga/history/2031/x") @@ -264,6 +272,7 @@ def test_ensure_topic_dir_defaults_to_current_year(self, tmp_path: Path, monkeyp def test_ensure_topic_dir_creates_idempotently(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Creation normalizes, defaults to the current year, and is idempotent.""" monkeypatch.chdir(tmp_path) + with mock.patch.object(naming, "datetime", _FixedClock): first = ensure_topic_dir("Feature/X") second = ensure_topic_dir("feature-x") @@ -276,6 +285,7 @@ def test_ensure_topic_dir_creates_idempotently(self, tmp_path: Path, monkeypatch def test_ensure_topic_dir_creates_parents(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Missing parents (.goga/history/<year>) are created on the way.""" monkeypatch.chdir(tmp_path) + with mock.patch.object(naming, "datetime", _FixedClock): created = ensure_topic_dir("feat-y") assert created == Path(".goga/history/2031/feat-y") @@ -284,6 +294,7 @@ def test_ensure_topic_dir_creates_parents(self, tmp_path: Path, monkeypatch: pyt def test_ensure_topic_dir_empty_slug_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An empty slug is the directory composer's clean error — nothing is created.""" monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="normalizes to an empty topic slug"): ensure_topic_dir("Релиз/Один") assert not (tmp_path / ".goga").exists() @@ -334,5 +345,6 @@ def test_remove_topic_dir_stray_file_returns_false(self, tmp_path: Path, monkeyp def test_remove_topic_dir_empty_slug_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An empty slug is the directory composer's clean error — nothing is deleted.""" monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError, match="normalizes to an empty topic slug"): remove_topic_dir("", "2026") diff --git a/tests/history/test_prune.py b/tests/history/test_prune.py index 21c257b1..790d088e 100644 --- a/tests/history/test_prune.py +++ b/tests/history/test_prune.py @@ -40,10 +40,12 @@ def _topic(root: Path, year: str, name: str, *artifacts: str) -> Path: """Create one topic directory of a year, with optional artifact files.""" topic_dir = root / ".goga" / "history" / year / name topic_dir.mkdir(parents=True, exist_ok=True) + for artifact in artifacts: path = topic_dir / artifact path.parent.mkdir(parents=True, exist_ok=True) path.write_text(artifact, encoding="utf-8") + return topic_dir @@ -100,6 +102,7 @@ def test_prune_topics_deletes_orphans_keeps_hosted(self, tmp_path: Path, monkeyp BranchRef(name="feat/a", remote=False), BranchRef(name="origin/feat/a", remote=True), ] + with _inventory(inventory): removed = prune_topics("2026") assert removed == ["done-c", "orphan-b"] @@ -111,6 +114,7 @@ def test_prune_remote_short_name_protects(self, tmp_path: Path, monkeypatch: pyt """The short name of a remote-tracking ref protects without a local branch.""" monkeypatch.chdir(tmp_path) topic_dir = _topic(tmp_path, "2026", "feat-a", "prd.md") + with _inventory([BranchRef(name="origin/feat/a", remote=True)]): assert prune_topics("2026") == [] assert topic_dir.is_dir() @@ -126,6 +130,7 @@ def build(root: Path) -> tuple[Path, Path]: explicit_year_root = tmp_path / "explicit" old_current, new_current = build(current_year_root) old_explicit, new_explicit = build(explicit_year_root) + with mock.patch.object(naming, "datetime", _FixedClock), _inventory(inventory): monkeypatch.chdir(current_year_root) assert prune_topics() == [] @@ -146,6 +151,7 @@ def build(root: Path) -> tuple[Path, Path]: dry_orphan, dry_done = build(dry_root) wet_orphan, wet_done = build(wet_root) + with _inventory([]): monkeypatch.chdir(dry_root) assert prune_topics("2026", dry_run=True) == ["done-c", "orphan-b"] @@ -161,6 +167,7 @@ def test_prune_topics_returns_sorted_unique_slugs(self, tmp_path: Path, monkeypa monkeypatch.chdir(tmp_path) _topic(tmp_path, "2026", "b-orphan", "prd.md") _topic(tmp_path, "2026", "a-orphan", "prd.md") + with _inventory([]): assert prune_topics("2026", dry_run=True) == ["a-orphan", "b-orphan"] @@ -174,6 +181,7 @@ def test_prune_oracle_matches_check_branch_occupancy(self, tmp_path: Path, monke BranchRef(name="origin/hot/b", remote=True), BranchRef(name="main", remote=False), ] + for entered in ["feat/a", "hot/b", "main", "other"]: slug = normalize_topic_slug(entered) # (1) the occupancy oracle on the empty tree — only the git oracles answer @@ -192,6 +200,7 @@ def test_prune_topics_absent_year_returns_empty_list(self, tmp_path: Path, monke """An absent year yields no topics — nothing is deleted, nothing is touched.""" monkeypatch.chdir(tmp_path) kept = _topic(tmp_path, "2026", "feat-a", "prd.md") + with ( _inventory([BranchRef(name="feat/a", remote=False)]), mock.patch("goga.history.prune.remove_topic_dir") as remover, @@ -205,6 +214,7 @@ def test_prune_topics_queries_inventory_even_for_empty_year( ) -> None: """A year without topics still reads the branch inventory — no short-circuit.""" monkeypatch.chdir(tmp_path) + with _inventory([]) as inventory: assert prune_topics("1999") == [] @@ -216,6 +226,7 @@ def test_prune_topics_propagates_inventory_git_failure( """A git failure of the ref listing propagates to the caller.""" monkeypatch.chdir(tmp_path) failure = subprocess.CalledProcessError(returncode=128, cmd=["git", "for-each-ref"]) + with ( mock.patch("goga.history.prune.list_branch_refs", side_effect=failure), pytest.raises(subprocess.CalledProcessError), @@ -228,6 +239,7 @@ def test_prune_topics_never_mutates_git(self, tmp_path: Path, monkeypatch: pytes orphan = _topic(tmp_path, "2026", "orphan-b", "prd.md") done = _topic(tmp_path, "2026", "done-c", "completed/plan.md") runner = _inventory_run(heads="main\n", remotes="") + with mock.patch("goga.history.git.refs.subprocess.run", runner): assert prune_topics("2026", dry_run=True) == ["done-c", "orphan-b"] assert orphan.is_dir() @@ -246,6 +258,7 @@ class TestPruneTopicsEdges: def test_prune_topics_empty_tree_returns_empty_list(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A missing history root is an empty result — not an error, and nothing is created.""" monkeypatch.chdir(tmp_path) + with _inventory([]): assert prune_topics() == [] assert not (tmp_path / ".goga").exists() @@ -255,6 +268,7 @@ def test_prune_topics_only_resolved_year_touched(self, tmp_path: Path, monkeypat monkeypatch.chdir(tmp_path) old = _topic(tmp_path, "2025", "orphan-old", "prd.md") new = _topic(tmp_path, "2026", "orphan-new", "prd.md") + with mock.patch.object(naming, "datetime", _FixedClock), _inventory([]): assert prune_topics("2025") == ["orphan-old"] assert not old.exists() @@ -268,6 +282,7 @@ def test_prune_topics_empty_string_year_means_current_year( monkeypatch.chdir(tmp_path) old = _topic(tmp_path, "2025", "orphan-old", "prd.md") new = _topic(tmp_path, "2026", "orphan-new", "prd.md") + with mock.patch.object(naming, "datetime", _FixedClock), _inventory([]): assert prune_topics("", dry_run=True) == ["orphan-new"] assert old.is_dir() @@ -280,6 +295,7 @@ def test_prune_topics_normalizes_tree_names_for_protection( monkeypatch.chdir(tmp_path) manual = _topic(tmp_path, "2026", "Feature_Foo", "prd.md") twin = _topic(tmp_path, "2026", "feature-foo", "prd.md") + with _inventory([BranchRef(name="feature/foo", remote=False)]): assert prune_topics("2026") == [] assert manual.is_dir() @@ -291,6 +307,7 @@ def test_prune_topics_unnormalized_orphan_dir_stays(self, tmp_path: Path, monkey wet_root = tmp_path / "wet" dry_manual = _topic(dry_root, "2026", "Feature_Foo", "prd.md") wet_manual = _topic(wet_root, "2026", "Feature_Foo", "prd.md") + with _inventory([]): monkeypatch.chdir(dry_root) assert prune_topics("2026", dry_run=True) == ["feature-foo"] diff --git a/tests/history/test_status.py b/tests/history/test_status.py index abfb0561..039433a2 100644 --- a/tests/history/test_status.py +++ b/tests/history/test_status.py @@ -234,6 +234,7 @@ def test_collect_topic_statuses_reuses_scale(self, tmp_path: Path, monkeypatch: (year_dir / "alpha" / "plan.md").write_text("plan", encoding="utf-8") (year_dir / "beta").mkdir(parents=True) assembly = mock.patch.object(status, "assemble_status_scale", wraps=status.assemble_status_scale) + with assembly as assemble: records = collect_topic_statuses("2026", _builtin_scale()) assert [record.topic for record in records] == ["alpha", "beta"] @@ -250,6 +251,7 @@ def test_collect_topic_statuses_assembles_scale_once_when_none( (year_dir / "alpha").mkdir(parents=True) (year_dir / "alpha" / "plan.md").write_text("plan", encoding="utf-8") (year_dir / "beta").mkdir(parents=True) + with mock.patch.object(status, "assemble_status_scale", return_value=_builtin_scale()) as assemble: records = collect_topic_statuses("2026") assert [record.topic for record in records] == ["alpha", "beta"] @@ -270,6 +272,7 @@ def test_collect_topic_statuses_empty_year_string_means_current( monkeypatch.chdir(tmp_path) (tmp_path / ".goga" / "history" / "2025" / "old-topic").mkdir(parents=True) (tmp_path / ".goga" / "history" / "2031" / "t").mkdir(parents=True) + with mock.patch.object(naming, "datetime", _FixedClock): records = collect_topic_statuses(year="", scale=_builtin_scale()) assert [record.topic for record in records] == ["t"] diff --git a/tests/integration/test_base_ref_end_to_end.py b/tests/integration/test_base_ref_end_to_end.py index e65d41e9..81c4a527 100644 --- a/tests/integration/test_base_ref_end_to_end.py +++ b/tests/integration/test_base_ref_end_to_end.py @@ -104,6 +104,7 @@ def _mock_vendored_sources(tmp_path: Path): (prompts_dir / "codex.txt").write_text("# codex review prompt\n") (prompts_dir / "review_first.txt").write_text(_REVIEW_FIRST_TEMPLATE) (prompts_dir / "review_second.txt").write_text(_REVIEW_SECOND_TEMPLATE) + for role in _ROLES: (agents_dir / f"{role}.txt").write_text(f"# {role} agent definition\n") @@ -131,6 +132,7 @@ def test_base_ref_survives_host_to_container(self, tmp_path: Path, monkeypatch) _write_goga_yml(tmp_path) runner = CliRunner() + with ( mock.patch.object(_build_cmd_mod, "_check_docker", return_value=True), mock.patch.object(_build_cmd_mod, "_write_env_file", return_value=Path("/tmp/env")), @@ -149,6 +151,7 @@ def test_base_ref_survives_host_to_container(self, tmp_path: Path, monkeypatch) # only the dispatch target is mocked to capture cli_options. monkeypatch.setenv("GOGA_DOCKER", "1") monkeypatch.setattr(sys, "argv", ["goga.build", "plan.md", *forwarded]) + with ( mock.patch("goga.build.__main__.build", return_value=0) as mock_build, mock.patch("goga.build.__main__.load_project_config"), @@ -162,6 +165,7 @@ def test_base_ref_unset_forwards_no_token(self, tmp_path: Path, monkeypatch) -> _write_goga_yml(tmp_path) runner = CliRunner() + with ( mock.patch.object(_build_cmd_mod, "_check_docker", return_value=True), mock.patch.object(_build_cmd_mod, "_write_env_file", return_value=Path("/tmp/env")), @@ -181,6 +185,7 @@ def test_base_ref_unset_forwards_no_token(self, tmp_path: Path, monkeypatch) -> # None, so the resolver falls through to build.review_executor.base_ref. monkeypatch.setenv("GOGA_DOCKER", "1") monkeypatch.setattr(sys, "argv", ["goga.build", "plan.md", *forwarded]) + with ( mock.patch("goga.build.__main__.build", return_value=0) as mock_build, mock.patch("goga.build.__main__.load_project_config"), diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index 534cda7f..381c0178 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -258,9 +258,11 @@ def _board_rows(output: str, columns: int = 3) -> list[tuple[str, ...]]: """ lines = [line for line in output.splitlines() if line.startswith("|")] rows = [] + for line in lines[2:]: cells = line.split("|") rows.append(tuple(cell.strip() for cell in cells[1 : columns + 1])) + return rows diff --git a/tests/pipeline/compiler/test_compile_flow_memory.py b/tests/pipeline/compiler/test_compile_flow_memory.py index 9431b51a..a33f4e67 100644 --- a/tests/pipeline/compiler/test_compile_flow_memory.py +++ b/tests/pipeline/compiler/test_compile_flow_memory.py @@ -298,6 +298,7 @@ def test_compile_flow_alignment_uniform_across_loop_copies(self, tmp_path: Path) assert len(copies) == 3 for stage in copies: assert stage.fields["memory_use"] is True + for stage in bystanders: assert stage.fields["memory_use"] is False assert "memory_use: false" in text diff --git a/tests/pipeline/workflow/test_parse_workflow_memory.py b/tests/pipeline/workflow/test_parse_workflow_memory.py index 6b2958e2..1f9d3e91 100644 --- a/tests/pipeline/workflow/test_parse_workflow_memory.py +++ b/tests/pipeline/workflow/test_parse_workflow_memory.py @@ -304,6 +304,7 @@ def test_parse_workflow_rejects_reflect_instruction_errors( ) -> None: """A malformed per-stage instruction raises WorkflowSyntaxError with the documented message.""" text = f"stages:\n brainstorm:\n{stage_yaml}\n" + if block_yaml: text = f"{block_yaml}{text}" diff --git a/tests/topics/git/test_publish.py b/tests/topics/git/test_publish.py index ba3bf44f..82e232c3 100644 --- a/tests/topics/git/test_publish.py +++ b/tests/topics/git/test_publish.py @@ -102,6 +102,7 @@ def test_parameters_are_positional_or_keyword_with_contract_hints(self) -> None: push_branch: {"branch_name": str, "return": type(None)}, origin_configured: {"return": bool}, } + for routine, declared in hints.items(): parameters = inspect.signature(routine).parameters assert all( @@ -113,6 +114,7 @@ def test_parameters_are_positional_or_keyword_with_contract_hints(self) -> None: def test_delete_remote_branch_callable_with_name(self) -> None: """The routine binds as ``delete_remote_branch("name")`` and returns None.""" run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): result = delete_remote_branch("name") @@ -126,6 +128,7 @@ class TestResolveRefCommit: def test_resolve_ref_commit_returns_peeled_commit(self) -> None: """``^{commit}`` peels annotated tags — the hash is a commit hash.""" run = mock.Mock(return_value=_git_answer("1a2b3c4d5e6f7890\n")) + with mock.patch("goga.topics.git.publish.subprocess.run", run): commit = resolve_ref_commit("origin/main") @@ -159,6 +162,7 @@ def answer_by_argv(command: list[str], **_kwargs: object) -> subprocess.Complete return subprocess.CompletedProcess(args=command, returncode=0, stdout=stdout, stderr="") run = mock.Mock(side_effect=answer_by_argv) + with mock.patch("goga.topics.git.publish.subprocess.run", run): commit = commit_file_on_base("<base>", _TODO_PATH, _TODO_CONTENT, _TODO_MESSAGE) @@ -173,6 +177,7 @@ def answer_by_argv(command: list[str], **_kwargs: object) -> subprocess.Complete ] quarantined = {"read-tree", "update-index", "write-tree"} + for call in run.call_args_list: env = call.kwargs["env"] assert env["GIT_TERMINAL_PROMPT"] == "0" @@ -202,6 +207,7 @@ def answer_by_argv(command: list[str], **_kwargs: object) -> subprocess.Complete return subprocess.CompletedProcess(args=command, returncode=0, stdout=stdout, stderr="") run = mock.Mock(side_effect=answer_by_argv) + with ( mock.patch("goga.topics.git.publish.subprocess.run", run), pytest.raises(subprocess.CalledProcessError), @@ -236,6 +242,7 @@ def answer_by_argv(command: list[str], **_kwargs: object) -> subprocess.Complete return subprocess.CompletedProcess(args=command, returncode=0, stdout=stdout, stderr="") run = mock.Mock(side_effect=answer_by_argv) + with mock.patch("goga.topics.git.publish.subprocess.run", run): commit = commit_file_on_base("<base>", _TODO_PATH, _TODO_CONTENT, "") @@ -255,6 +262,7 @@ class TestBranchAndPushMutations: def test_create_branch_at_commit_creates_ref_without_switch(self) -> None: """The plant pins ``refs/heads`` and leaves the working copy alone.""" run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): result = create_branch_at_commit("Feature/Foo_Bar", "<commit>") @@ -274,6 +282,7 @@ def test_create_branch_at_commit_stream_cannot_move_an_existing_ref(self) -> Non stream refuses an existing ref before anything is mutated. """ run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): create_branch_at_commit("--mirror", "<commit>") @@ -293,6 +302,7 @@ def test_create_branch_at_commit_stream_cannot_split_a_second_command(self) -> N validation owns it instead of the stream parser. """ run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): create_branch_at_commit("evil <oid>\nupdate refs/heads/main", "<commit>") @@ -307,6 +317,7 @@ def test_create_branch_at_commit_stream_cannot_split_a_second_command(self) -> N def test_delete_local_branch_deletes_ref(self) -> None: """The rollback addresses the same ``refs/heads`` ref the plant created.""" run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): delete_local_branch("Feature/Foo_Bar") @@ -315,6 +326,7 @@ def test_delete_local_branch_deletes_ref(self) -> None: def test_push_branch_pushes_with_upstream_binding(self) -> None: """Exactly the named branch, ``-u`` present, origin hardcoded.""" run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): push_branch("Feature/Foo_Bar") @@ -338,6 +350,7 @@ def test_push_branch_does_not_follow_tags(self) -> None: overrides the user's config. """ run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): push_branch("Feature/Foo_Bar") @@ -353,6 +366,7 @@ def test_push_branch_refspec_cannot_be_parsed_as_an_option(self) -> None: with ``r`` and can only ever name exactly the one branch. """ run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): push_branch("--mirror") @@ -371,6 +385,7 @@ def test_delete_remote_branch_pushes_full_refspec(self) -> None: with a dash, so exactly the named branch goes. """ run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): delete_remote_branch("feature-foo") @@ -393,6 +408,7 @@ def test_delete_remote_branch_refspec_cannot_be_parsed_as_an_option(self) -> Non all. The ``refs/heads/...`` refspec can never start with a dash. """ run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.publish.subprocess.run", run): delete_remote_branch("--mirror") @@ -423,6 +439,7 @@ class TestOriginConfigured: def test_origin_configured_true_when_configured(self) -> None: """A readable origin remote URL reads True.""" run = mock.Mock(return_value=_git_answer("git@github.com:o/r.git\n")) + with mock.patch("goga.topics.git.publish.subprocess.run", run): configured = origin_configured() diff --git a/tests/topics/git/test_refs.py b/tests/topics/git/test_refs.py index e41532ba..954b4ab4 100644 --- a/tests/topics/git/test_refs.py +++ b/tests/topics/git/test_refs.py @@ -77,6 +77,7 @@ def test_list_branch_refs_takes_no_arguments_and_returns_refs(self) -> None: def test_git_invocations_follow_the_git_practice(self) -> None: """Two ``for-each-ref`` calls — check/capture/text and a muted prompt.""" run = mock.Mock(side_effect=_answering_run()) + with mock.patch("goga.topics.git.refs.subprocess.run", run): list_branch_refs() @@ -105,6 +106,7 @@ def test_list_branch_refs_merges_and_sorts(self) -> None: remotes="origin/HEAD\norigin/feat/a\norigin/feat/b\n", ) ) + with mock.patch("goga.topics.git.refs.subprocess.run", run): refs = list_branch_refs() @@ -127,6 +129,7 @@ def test_list_branch_refs_merges_and_sorts(self) -> None: def test_list_branch_refs_empty_repository(self) -> None: """An empty inventory is the norm, answered by exactly two calls.""" run = mock.Mock(side_effect=_answering_run()) + with mock.patch("goga.topics.git.refs.subprocess.run", run): refs = list_branch_refs() diff --git a/tests/topics/git/test_switch.py b/tests/topics/git/test_switch.py index f3d59d20..63c394ec 100644 --- a/tests/topics/git/test_switch.py +++ b/tests/topics/git/test_switch.py @@ -69,6 +69,7 @@ def test_declared_signatures(self) -> None: def test_mutations_are_git_switch_invocations(self) -> None: """The three mutations are bounded host-side ``git switch`` actions.""" run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.switch.subprocess.run", run): checkout_local_branch("feat/a") create_branch_from_remote_tracking(BranchRef(name="origin/feat/b", remote=True)) @@ -83,6 +84,7 @@ def test_mutations_are_git_switch_invocations(self) -> None: def test_cleanliness_probe_is_a_porcelain_invocation(self) -> None: """The probe reads the working tree state — nothing else.""" run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.switch.subprocess.run", run): is_working_tree_clean() @@ -97,6 +99,7 @@ def test_git_invocations_follow_the_git_practice(self) -> None: (is_working_tree_clean, ()), ] run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.switch.subprocess.run", run): for routine, args in calls: routine(*args) @@ -124,6 +127,7 @@ def test_is_working_tree_clean_boolean(self) -> None: def test_create_branch_from_remote_tracking_takes_the_short_name(self) -> None: """The local branch is named after the part past the first slash.""" run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.switch.subprocess.run", run): create_branch_from_remote_tracking(BranchRef(name="origin/feat/b", remote=True)) @@ -132,6 +136,7 @@ def test_create_branch_from_remote_tracking_takes_the_short_name(self) -> None: def test_create_and_switch_branch_takes_the_name_verbatim(self) -> None: """No normalization, no suffixing — the name goes to git as entered.""" run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.switch.subprocess.run", run): create_and_switch_branch("Feature/Foo_Bar") @@ -140,6 +145,7 @@ def test_create_and_switch_branch_takes_the_name_verbatim(self) -> None: def test_checkout_local_branch_switches_without_creating(self) -> None: """A plain checkout — no ``-c``, the branch must already exist.""" run = mock.Mock(return_value=_git_answer()) + with mock.patch("goga.topics.git.switch.subprocess.run", run): checkout_local_branch("feat/a") diff --git a/tests/topics/git/test_trees.py b/tests/topics/git/test_trees.py index 07fd18c5..892f0ebf 100644 --- a/tests/topics/git/test_trees.py +++ b/tests/topics/git/test_trees.py @@ -55,6 +55,7 @@ def test_signature_takes_ref_and_prefix_and_returns_paths(self) -> None: def test_git_invocation_follows_the_git_practice(self) -> None: """One ``ls-tree -r --name-only`` call — check/capture/text, muted prompt.""" run = mock.Mock(return_value=_git_answer("")) + with mock.patch("goga.topics.git.trees.subprocess.run", run): read_ref_tree_paths("feat-a", ".goga/history/") @@ -85,6 +86,7 @@ def test_git_invocation_separates_a_dash_leading_ref(self) -> None: inventory read of the repository with ``unknown option``. """ run = mock.Mock(return_value=_git_answer("")) + with mock.patch("goga.topics.git.trees.subprocess.run", run): read_ref_tree_paths("--mirror", ".goga/history/") @@ -112,6 +114,7 @@ def test_git_invocation_anchors_the_read_at_the_repository_root(self) -> None: the caller's prefix unchanged. """ run = mock.Mock(return_value=_git_answer(".goga/history/2026/feat-a/plan.md\n")) + with mock.patch("goga.topics.git.trees.subprocess.run", run): paths = read_ref_tree_paths("feat-a", ".goga/history/2026/feat-a/") @@ -149,6 +152,7 @@ class TestReadRefTreePaths: def test_read_ref_tree_paths_filters_prefix(self) -> None: """Only paths under the prefix survive — one invocation per ref.""" run = mock.Mock(return_value=_git_answer(".goga/history/2026/feat-a/plan.md\nREADME.md\n")) + with mock.patch("goga.topics.git.trees.subprocess.run", run): result = read_ref_tree_paths("feat-a", ".goga/history/") @@ -163,6 +167,7 @@ def test_read_ref_tree_paths_no_matches_empty(self) -> None: def test_read_ref_tree_paths_keeps_git_order(self) -> None: """Paths return in the order git reports them.""" stdout = ".goga/history/2026/feat-a/plan.md\n.goga/history/2026/feat-a/prd.md\n" + with mock.patch("goga.topics.git.trees.subprocess.run", return_value=_git_answer(stdout)): result = read_ref_tree_paths("feat-a", ".goga/history/") @@ -173,6 +178,7 @@ class TestReadRefFile: def test_read_ref_file_returns_content_as_is(self) -> None: """The content returns as-is — one ``git show``, UTF-8, muted prompt.""" run = mock.Mock(return_value=_git_answer("Payment retry\n")) + with mock.patch("goga.topics.git.trees.subprocess.run", run): content = read_ref_file("feat/a", ".goga/history/2026/feat-a/todo.md") @@ -204,6 +210,7 @@ def test_read_ref_file_decodes_invalid_bytes_with_replacement(self) -> None: U+FFFD instead of raising through the board's clean-error boundary. """ run = mock.Mock(return_value=_git_answer("Pay�ment\n")) + with mock.patch("goga.topics.git.trees.subprocess.run", run): content = read_ref_file("feat/a", ".goga/history/2026/feat-a/todo.md") @@ -213,6 +220,7 @@ def test_read_ref_file_decodes_invalid_bytes_with_replacement(self) -> None: def test_read_ref_file_empty_file_returns_empty_string(self) -> None: """An empty file is present — ``""`` differs from absence (``None``).""" run = mock.Mock(return_value=_git_answer("")) + with mock.patch("goga.topics.git.trees.subprocess.run", run): content = read_ref_file("feat/a", ".goga/history/2026/feat-a/todo.md") diff --git a/tests/topics/test_deletion.py b/tests/topics/test_deletion.py index 7fd84eb6..f15f9e0c 100644 --- a/tests/topics/test_deletion.py +++ b/tests/topics/test_deletion.py @@ -111,6 +111,7 @@ def _wire_removal( remote = mock.Mock(side_effect=remote_error) restore = mock.Mock(side_effect=restore_error) directory = mock.Mock(return_value=False, side_effect=dir_side_effect) + for name, child in ( ("capture", capture), ("local", local), diff --git a/tests/topics/test_ensuring.py b/tests/topics/test_ensuring.py index d8fc24ca..967dd767 100644 --- a/tests/topics/test_ensuring.py +++ b/tests/topics/test_ensuring.py @@ -119,6 +119,7 @@ def _wire_fast_creation( monkeypatch.setattr(ensuring, "check_slug_occupancy", mock.Mock(return_value=None)) create_and_switch = mock.Mock() monkeypatch.setattr(ensuring, "create_and_switch_branch", create_and_switch) + if real_dir: return create_and_switch, None diff --git a/tests/topics/test_publishing.py b/tests/topics/test_publishing.py index f17d789e..89f30852 100644 --- a/tests/topics/test_publishing.py +++ b/tests/topics/test_publishing.py @@ -76,9 +76,11 @@ def _attach(self, name: str, **kwargs: object) -> mock.Mock: def _wire_cycle(monkeypatch: pytest.MonkeyPatch) -> _Cycle: """Patch publishing's import points with the recording doubles.""" cycle = _Cycle() + for name, double in vars(cycle).items(): if hasattr(publishing, name): monkeypatch.setattr(publishing, name, double) + return cycle From 9a83908730d4be4d19a948b9ef93d0f2e75c6f7f Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 21:29:54 +0000 Subject: [PATCH 217/229] docs: address contracts review findings in manifests and usages - connect memory-emission practice via goga/pipeline Imports and annotations - drop legacy/current-state change declarations from annotations and usages - state the extend strict-validation rule in parse_workflow requirements - translate memory and memory-emission practices to English - make refs-and-switching examples self-contained (no goga.history import) --- goga/build/.usages/build-usage.md | 4 +- goga/build/CODEMANIFEST | 7 +- goga/commands/install/.usages/install.md | 2 +- goga/config/.usages/project-configuration.md | 6 +- goga/config/project/CODEMANIFEST | 6 +- goga/history/.usages/topic-paths.md | 4 +- goga/history/CODEMANIFEST | 2 +- goga/pipeline/CODEMANIFEST | 4 +- .../compiler/.usages/memory-emission.md | 126 +++++++++--------- goga/pipeline/compiler/CODEMANIFEST | 4 +- goga/pipeline/workflow/.usages/memory.md | 109 +++++++-------- goga/pipeline/workflow/CODEMANIFEST | 7 +- goga/topics/git/.usages/refs-and-switching.md | 6 +- 13 files changed, 148 insertions(+), 139 deletions(-) diff --git a/goga/build/.usages/build-usage.md b/goga/build/.usages/build-usage.md index 6ea8da99..0c2b47e3 100644 --- a/goga/build/.usages/build-usage.md +++ b/goga/build/.usages/build-usage.md @@ -77,8 +77,8 @@ holds for the review_patience cli_options key / build.review_executor.patience → --review-patience. When neither source sets them, the keys stay absent and the assembled -ralphex command is unchanged. The legacy build.review_patience config key -is not parsed — declare build.review_executor.patience instead. +ralphex command carries no extra flags. The build.review_patience config +key is not parsed — declare build.review_executor.patience instead. ## Review-pass environment diff --git a/goga/build/CODEMANIFEST b/goga/build/CODEMANIFEST index 7723bd7c..2a66ff19 100644 --- a/goga/build/CODEMANIFEST +++ b/goga/build/CODEMANIFEST @@ -153,8 +153,8 @@ Annotations: | - Review-scoped options never appear in the options of a skip run or the tasks-only pass - When neither the CLI nor the config sets base_ref/patience, the keys stay - absent from the options and the assembled ralphex command is - byte-identical to the current behavior + absent from the options — the assembled ralphex command carries no + --base-ref / --review-patience flags Constraints: - Wrappers live in the image at /home/goga/bin/ and are referenced by absolute path @@ -235,8 +235,7 @@ Annotations: | - An absent review_executor section leaves base_ref and patience None (like the other review fields) - The code docstring of `resolve_review_options` lists the same three - cli_options keys — the current "only skip_review is read" wording is - updated with the two review-scoped keys + cli_options keys — skip_review, base_ref, and review_patience Constraints: - Pure — no side effects, no validation of values (separate routine) diff --git a/goga/commands/install/.usages/install.md b/goga/commands/install/.usages/install.md index 05b28733..a9f9848c 100644 --- a/goga/commands/install/.usages/install.md +++ b/goga/commands/install/.usages/install.md @@ -145,7 +145,7 @@ dot becomes an underscore and the result is lowercased (`mytool` → underscored top-level package pip lays out on disk: - No facade module or no callable `install` → quiet skip (the hook is - optional; existing tools without a hook install exactly as before). + optional; tools without a hook install unchanged). - The hook's signature declares a keyword-capable parameter `user` → it is called as `install(user=<initiating user>)`; otherwise it is called with no arguments. diff --git a/goga/config/.usages/project-configuration.md b/goga/config/.usages/project-configuration.md index e24a3253..8ceaa1ff 100644 --- a/goga/config/.usages/project-configuration.md +++ b/goga/config/.usages/project-configuration.md @@ -234,7 +234,7 @@ afm) that consume these fields. | `build.review_executor.roles` | list | None | Reviewer composition; empty list passes verbatim (full default set is consumer semantics) | | `build.review_executor.env` | mapping | `{}` | Review-pass env layer ({str: str}); empty when absent/YAML-null/`{}`; requires `agent` when non-empty (enforced by the consumer) | | `build.review_executor.base_ref` | str | None | Review diff base — branch name or commit hash; overrides ralphex's default-branch detection for review diffs. Verbatim, no validation at the config layer | -| `build.review_executor.patience` | int | None | Stop the external review after N consecutive unchanged rounds (moved from `build.review_patience`, which is no longer parsed) | +| `build.review_executor.patience` | int | None | Stop the external review after N consecutive unchanged rounds | | `codemanifest` | mapping | None | CODEMANIFEST usage and annotation config | | `codemanifest.usages` | mapping | `{}` | Usage name-to-path mapping (`{str: str}`) | | `codemanifest.annotations` | str | None | Freeform annotations for the AI agent | @@ -322,8 +322,8 @@ topics: The default template and the `{slug}` substitution belong to the consuming command (the create command). -The legacy `build.review_patience` key is no longer parsed — declare review -patience as `build.review_executor.patience`. +The `build.review_patience` key is not parsed — declare review patience as +`build.review_executor.patience`. ### `tools` accessor — no-validation contract diff --git a/goga/config/project/CODEMANIFEST b/goga/config/project/CODEMANIFEST index 0aa4f58e..720d33ae 100644 --- a/goga/config/project/CODEMANIFEST +++ b/goga/config/project/CODEMANIFEST @@ -218,9 +218,9 @@ Annotations: | topics.publish_commit template semantics at the loader level — structural typing only; the consumer applies the default template - The final `ProjectConfig` assembly MUST include topics - - Do NOT parse the legacy build.review_patience key — the field moved to - build.review_executor.patience; a config declaring the old key is silently - ignored (accepted breaking change: the field was never released) + - Do NOT parse the build.review_patience key — the review patience field + is build.review_executor.patience; a config declaring the unparsed key + is silently ignored - The final `ProjectConfig` assembly MUST include lint "ProjectConfig(lang: str, image: str | None, dockerfile: str | None, build: BuildConfig | None, pipeline: PipelineConfig | None, commands: dict, codemanifest: CodemanifestConfig | None, tools: dict[str, str] | None, usages: dict[str, dict[str, DepConfig]] | None = None, lint: LintConfig | None = None, topics: TopicsConfig | None = None)": diff --git a/goga/history/.usages/topic-paths.md b/goga/history/.usages/topic-paths.md index d80f3ff7..aa901ad4 100644 --- a/goga/history/.usages/topic-paths.md +++ b/goga/history/.usages/topic-paths.md @@ -60,10 +60,10 @@ if topic_exists("release-1-3-0"): from goga.history import ensure_topic_dir topic_dir = ensure_topic_dir("Feature/Foo_Bar") -# -> .goga/history/2026/feature-foo-bar (now existing) +# -> .goga/history/2026/feature-foo-bar (exists after the call) topic_dir = ensure_topic_dir("Feature/Foo_Bar", year="2025") -# -> .goga/history/2025/feature-foo-bar (now existing) +# -> .goga/history/2025/feature-foo-bar (exists after the call) ``` - The year defaults to the current year (four digits, local time); diff --git a/goga/history/CODEMANIFEST b/goga/history/CODEMANIFEST index d1523cf5..0db82a6c 100644 --- a/goga/history/CODEMANIFEST +++ b/goga/history/CODEMANIFEST @@ -210,7 +210,7 @@ Annotations: | `name`: topic input — a branch name or an already-normalized slug `year`: optional year as four digits; None and the empty string mean the current year - `topic_dir`: the topic directory path that now exists + `topic_dir`: the topic directory path that exists after the call Apply the `convention` practice for docstring style and intra-package imports. diff --git a/goga/pipeline/CODEMANIFEST b/goga/pipeline/CODEMANIFEST index 8738de55..0500b30a 100644 --- a/goga/pipeline/CODEMANIFEST +++ b/goga/pipeline/CODEMANIFEST @@ -8,6 +8,7 @@ Imports: - compile-flow - parse-dsl - serialize-flow + - memory-emission From: goga/pipeline/compiler - Types: - run_flow @@ -126,7 +127,8 @@ Annotations: | `compile-flow`) to transform the discovered pipeline-file into an afm flow-file at runtime inside the container; use `parse-dsl` and `serialize-flow` for the intermediate stages when a lower-level view is - required. + required. Use `memory-emission` for the compiled memory surface of the + flow-file — the top-level memory block and the per-stage memory keys. Prompt materialization step: after compilation, run coordination materializes the four default agent prompt files (per `default_prompts`) diff --git a/goga/pipeline/compiler/.usages/memory-emission.md b/goga/pipeline/compiler/.usages/memory-emission.md index ff9b64a6..65a25b40 100644 --- a/goga/pipeline/compiler/.usages/memory-emission.md +++ b/goga/pipeline/compiler/.usages/memory-emission.md @@ -1,81 +1,87 @@ -# memory-emission — компиляция памяти в afm flow-файл +# memory-emission — compiling memory into the afm flow-file -Документ описывает, как компилятор обрабатывает память workflow: когда -эмитится глобальный блок `memory`, какие ключи получают стадии, какие -умолчания материализуются. Адресат — потребители компилятора и авторы -workflow-файлов, сверяющие ожидаемый вывод. +The document describes how the compiler handles workflow memory: when the +global `memory` block is emitted, which keys the stages receive, and which +defaults are materialized. The audience is compiler consumers and +workflow-file authors checking the expected output. -## Условие эмиссии +## The emission condition -Глобальный блок `memory` эмитится **тогда и только тогда, когда хотя бы одна -стадия участвует в памяти**. Участие: инструкция `reflect` при reflect-методе; -`memory: true` при alignment-методе. Блок `memory:` в workflow — конфигурация, -а не выключатель. +The global `memory` block is emitted **if and only if at least one stage +participates in memory**. Participation: a `reflect` instruction under the +reflect method; `memory: true` under the alignment method. The `memory:` +block of a workflow is configuration, not a switch. -| # | Блок `memory:` | Инструкции на стадиях | Блок в выводе? | -|---|----------------|------------------------|----------------| -| 1 | нет | нет | нет | -| 2 | нет | есть `reflect` | да | -| 3 | есть (только конфигурация) | нет | нет — тихий no-op | -| 4 | есть, alignment | есть `memory: true` | да | -| 5 | есть, alignment | нет (в т.ч. все `false`) | нет — тихий no-op | -| 6 | есть, reflect | есть `reflect` | да | +| # | `memory:` block | Stage instructions | Block in the output? | +|---|----------------|--------------------|----------------------| +| 1 | absent | absent | no | +| 2 | absent | `reflect` present | yes | +| 3 | present (configuration only) | absent | no — a silent no-op | +| 4 | present, alignment | `memory: true` present | yes | +| 5 | present, alignment | absent (including all `false`) | no — a silent no-op | +| 6 | present, reflect | `reflect` present | yes | -Если блока нет — на стадиях не пишется **ничего**, включая отклоняющий ключ. +When the block is absent — **nothing** is written on the stages, including +the opting-out key. -## Состав блока (по методу) +## Block content (per method) -Блок стоит между `description` и `stages`; порядок ключей `path, mode, -memory_use, max_rules, commit`: +The block sits between `description` and `stages`; the key order is `path, +mode, memory_use, max_rules, commit`: -| Ключ | reflect | alignment | -|------|---------|-----------| -| `path` | склеенный корень памяти | склеенный корень памяти | -| `mode` | `r` (фиксированное) | материализованное авторское значение (`rw` по умолчанию) | +| Key | reflect | alignment | +|-----|---------|-----------| +| `path` | the joined memory root | the joined memory root | +| `mode` | `r` (fixed) | the materialized authored value (`rw` by default) | | `memory_use` | `false` | `false` | -| `max_rules` | из конфигурации | из конфигурации | -| `commit` | из конфигурации | из конфигурации | - -Глобальный `memory_use: false` — умолчание-отказ: участие в памяти строго -per-stage (afm вычисляет `UseFor(stage) = stage.memory_use ?? memory.memory_use`, -поэтому глобальный отказ не включает память у стадий без явного ключа). -При reflect стадия участвует через ключ `reflect` (`mode: r` даёт доступ -только на чтение проектной памяти); при alignment — через стадийный +| `max_rules` | from the configuration | from the configuration | +| `commit` | from the configuration | from the configuration | + +The global `memory_use: false` is an opting-out default: participation in +memory is strictly per-stage (afm computes `UseFor(stage) = +stage.memory_use ?? memory.memory_use`, so the global opt-out does not +enable memory on stages without an explicit key). Under reflect a stage +participates through the `reflect` key (`mode: r` gives read-only access to +the project memory); under alignment — through the stage-level `memory_use: true`. -`path` = `.goga/memory` (без суффикса) или `.goga/memory/<суффикс>`. +`path` = `.goga/memory` (no suffix) or `.goga/memory/<suffix>`. -Когда блок `memory:` в workflow не авторирован (случай 2 — есть только -инструкции `reflect`), значения берутся из материализованных умолчаний: -`path` — голый корень `.goga/memory`, `max_rules: 25`, `commit: false`. -Единственный источник умолчаний — полевые умолчания модели `WorkflowMemory`. +When the `memory:` block is not authored in the workflow (case 2 — only +`reflect` instructions present), the values come from the materialized +defaults: `path` — the bare root `.goga/memory`, `max_rules: 25`, +`commit: false`. The single source of the defaults is the field defaults of +the `WorkflowMemory` model. -## Ключи стадий +## Stage keys -Каноническая позиция — после `script_timeout` (хвост известных ключей): +The canonical position is after `script_timeout` (the tail of the known +keys): -- reflect-метод: стадия с инструкцией `reflect` получает ключ `reflect` — - `file` дословно, `mode` материализован (`rw`, если не авторирован) -- alignment-метод (при эмитированном блоке): помеченная стадия — - `memory_use: true`; **каждая** непомеченная — явный `memory_use: false` -- loop-копии несут те же ключи, что и оригинал; skipped-стадии не достигают - применения +- reflect method: a stage with a `reflect` instruction gets the `reflect` + key — `file` verbatim, `mode` materialized (`rw` when not authored) +- alignment method (with the block emitted): a marked stage — + `memory_use: true`; **every** unmarked one — an explicit `memory_use: false` +- loop copies carry the same keys as the original; skipped stages never + reach the application -Селектор метода goga в вывод не попадает никогда. +The goga method selector never reaches the output. -## Инварианты +## Invariants -- workflow без участия памяти компилируется байт-в-байт как без памяти — - ни блока, ни стадийных ключей -- `PipelineDocument` — точное зеркало исходного pipeline-файла: блок и - стадийные ключи памяти только output-side -- сигнатуры `compile_flow`/`serialize_flow` не меняются +- a workflow without memory participation compiles byte-identically — no + block, no stage keys +- `PipelineDocument` is the exact mirror of the source pipeline-file: the + memory block and the memory stage keys are output-side only +- the `compile_flow`/`serialize_flow` signatures are unaffected by memory + participation ## Anti-patterns -- Не авторить `reflect`/`memory_use` в теле стадии — структурная ошибка; - единственный источник — инструкции workflow -- Не рассчитывать, что незаданный стадийный ключ безопасен: наследование - глобального умолчания — причина явного `memory_use: false` на непомеченных -- Не проверять авторский словарь на стороне компилятора — его отвергает - парсер workflow до компиляции +- Do not author `reflect`/`memory_use` in a stage body — a structural + error; the single source is the workflow instructions +- Do not assume an unset stage key is safe: the inheritance of the global + default is the reason for the explicit `memory_use: false` on unmarked + stages +- Do not re-check the authoring vocabulary on the compiler side — the + workflow parser rejects it before compilation diff --git a/goga/pipeline/compiler/CODEMANIFEST b/goga/pipeline/compiler/CODEMANIFEST index e0bdd6cc..2a13c048 100644 --- a/goga/pipeline/compiler/CODEMANIFEST +++ b/goga/pipeline/compiler/CODEMANIFEST @@ -1680,8 +1680,8 @@ Annotations: | workflow.stages map — a skipped stage's instructions do not count - Every loop-expanded copy carries the same memory keys as its original - The method selector never appears in the output - - A workflow without memory participation compiles byte-identically to the - current output + - A workflow without memory participation compiles byte-identically — + no memory block, no stage keys Constraints: - Do not read AFM_DIR or any environment variable — `flow_path` is diff --git a/goga/pipeline/workflow/.usages/memory.md b/goga/pipeline/workflow/.usages/memory.md index fd946a21..6c0b1dfa 100644 --- a/goga/pipeline/workflow/.usages/memory.md +++ b/goga/pipeline/workflow/.usages/memory.md @@ -1,41 +1,42 @@ -# memory — авторинг памяти в workflow-файле +# memory — authoring memory in the workflow-file -`memory` включает участие workflow в памяти проекта: один top-level блок -конфигурации и две per-stage инструкции участия. Документ адресован авторам -workflow-файлов: всё описанное проверяется структурно при парсинге — опечатки, -несоответствия типов и значений отвергаются с читаемой ошибкой. +`memory` enables a workflow's participation in the project memory: one +top-level configuration block and two per-stage participation instructions. +The document addresses workflow-file authors: everything described here is +validated structurally at parse time — typos and type or value mismatches +are rejected with a readable error. -## Top-level блок `memory:` +## The top-level `memory:` block -| Ключ | Тип | Умолчание | Примечание | -|------|-----|-----------|------------| -| `method` | `reflect` \| `alignment` | `reflect` | селектор словаря инструкций; селектор — сторона goga, в скомпилированный вывод не попадает | -| `path` | str | без суффикса | суффикс внутри фиксированного корня памяти проекта | -| `max_rules` | int >= 1 | `25` | материализуется — опустить нельзя молча | -| `commit` | bool | `false` | материализуется | -| `mode` | `r` \| `w` \| `rw` | `rw` (материализуется) | только при `method: alignment`; при `method: reflect` — структурная ошибка | +| Key | Type | Default | Note | +|-----|------|---------|------| +| `method` | `reflect` \| `alignment` | `reflect` | selector of the instruction vocabulary; the selector is goga-side and never reaches the compiled output | +| `path` | str | no suffix | suffix inside the fixed root of the project memory | +| `max_rules` | int >= 1 | `25` | materialized — cannot be silently omitted | +| `commit` | bool | `false` | materialized | +| `mode` | `r` \| `w` \| `rw` | `rw` (materialized) | only with `method: alignment`; with `method: reflect` — a structural error | -Неизвестный ключ — структурная ошибка. Workflow из одного блока `memory:` -валиден (не считается пустым). +An unknown key is a structural error. A workflow consisting of the `memory:` +block alone is valid (not counted as empty). -## Инструкции блока `stages` +## Instructions of the `stages` block -| Инструкция | Допустимый метод | Значение | -|------------|------------------|----------| -| `reflect: {file, mode?}` | `reflect` | `file` обязателен — файл рефлексии стадии (форма пути внутри корня памяти, без ведущего `/`, не абсолютный, без `..`); `mode` опционален (`r`/`w`/`rw`), умолчание `rw` материализуется | -| `memory: <bool>` | `alignment` | `true` — стадия участвует; `false` эквивалентен отсутствию ключа | +| Instruction | Permitted method | Value | +|------------|------------------|-------| +| `reflect: {file, mode?}` | `reflect` | `file` is required — the stage's reflection file (a path shape inside the memory root: no leading `/`, not absolute, no `..`); `mode` is optional (`r`/`w`/`rw`), the `rw` default is materialized | +| `memory: <bool>` | `alignment` | `true` — the stage participates; `false` equals the key's absence | -Несоответствие метода и инструкции — структурная ошибка: `reflect` допустим -только при reflect-методе, `memory` — только при alignment-методе. Метод -по умолчанию — `reflect`, поэтому инструкция `memory` без блока `memory:` -с явным `method: alignment` — ошибка. +A method/instruction mismatch is a structural error: `reflect` is permitted +only under the reflect method, `memory` — only under the alignment method. +The default method is `reflect`, so a `memory` instruction without a +`memory:` block carrying an explicit `method: alignment` is an error. -В extend-записи обе инструкции запрещены: участие новой стадии авторится в -блоке `stages` по её имени. +Both instructions are forbidden in an extend-entry: the participation of a +new stage is authored in the `stages` block by its name. -## Минимальные примеры +## Minimal examples -Reflect-метод (умолчание) — стадии рефлексии в общий файл памяти: +Reflect method (the default) — stages reflecting into a shared memory file: ```yaml memory: @@ -50,7 +51,7 @@ stages: mode: r ``` -Alignment-метод — избирательное участие стадий: +Alignment method — selective stage participation: ```yaml memory: @@ -64,33 +65,35 @@ stages: memory: true ``` -Блок без инструкций — валидная конфигурация (тихий no-op при компиляции). +A block without instructions is a valid configuration (a silent no-op at +compilation). -## Структурные ошибки (полный перечень) +## Structural errors (complete list) -| Авторинг | Ошибка | +| Authoring | Error | |---|---| -| `memory:` не-отображение | non-mapping memory block in workflow | -| неизвестный ключ `memory:` | unknown key in workflow.memory: KEY; valid keys: method, path, max_rules, commit, mode | -| `method` вне {reflect, alignment} | структурная ошибка со списком допустимых | -| `max_rules` не int / < 1 | структурная ошибка | -| `commit`/`memory` не bool | структурная ошибка | -| `mode` вне {r, w, rw} | структурная ошибка со списком допустимых | -| `mode` при `method: reflect` | mode is forbidden in workflow.memory with method: reflect | -| `path`/`reflect.file` плохой формы | структурная ошибка (пустая строка, ведущий `/`, абсолютный путь, `..`) | -| `reflect` не-отображение | non-mapping reflect in workflow.stages.NAME | -| неизвестный ключ `reflect` | unknown key in workflow.stages.NAME.reflect: KEY; valid keys: file, mode | -| `reflect` без `file` | структурная ошибка | -| `reflect` при alignment | reflect is forbidden in workflow.stages.NAME with method: alignment | -| `memory` при reflect | memory is forbidden in workflow.stages.NAME with method: reflect | -| `reflect`/`memory` в extend-записи | reflect/memory is forbidden in workflow.extend.NAME | +| `memory:` non-mapping | non-mapping memory block in workflow | +| unknown `memory:` key | unknown key in workflow.memory: KEY; valid keys: method, path, max_rules, commit, mode | +| `method` outside {reflect, alignment} | a structural error listing the permitted values | +| `max_rules` not int / < 1 | a structural error | +| `commit`/`memory` not bool | a structural error | +| `mode` outside {r, w, rw} | a structural error listing the permitted values | +| `mode` with `method: reflect` | mode is forbidden in workflow.memory with method: reflect | +| `path`/`reflect.file` of bad shape | a structural error (empty string, leading `/`, absolute path, `..`) | +| `reflect` non-mapping | non-mapping reflect in workflow.stages.NAME | +| unknown `reflect` key | unknown key in workflow.stages.NAME.reflect: KEY; valid keys: file, mode | +| `reflect` without `file` | a structural error | +| `reflect` under alignment | reflect is forbidden in workflow.stages.NAME with method: alignment | +| `memory` under reflect | memory is forbidden in workflow.stages.NAME with method: reflect | +| `reflect`/`memory` in an extend-entry | reflect/memory is forbidden in workflow.extend.NAME | ## Anti-patterns -- Не авторить `reflect`/`memory` в теле стадии или в теле extend-записи — - единственная точка авторинга инструкций — блок `stages` workflow-файла - (ключи в телах стадий отвергаются при компиляции). -- Не рассчитывать на умолчания afm: материализация умолчаний (`mode`, - `max_rules`, `commit`) — обязательное поведение парсера, а не стилистика. -- Не указывать `mode` в блоке `memory:` при методе по умолчанию — это - структурная ошибка, а не молчаливое игнорирование. +- Do not author `reflect`/`memory` in a stage body or in an extend-entry + body — the single authoring point of the instructions is the `stages` + block of the workflow-file (keys in stage bodies are rejected at + compilation). +- Do not rely on afm defaults: the materialization of the defaults (`mode`, + `max_rules`, `commit`) is mandatory parser behavior, not a style choice. +- Do not set `mode` in the `memory:` block under the default method — that + is a structural error, not a silent ignore. diff --git a/goga/pipeline/workflow/CODEMANIFEST b/goga/pipeline/workflow/CODEMANIFEST index 2bd43927..526770fd 100644 --- a/goga/pipeline/workflow/CODEMANIFEST +++ b/goga/pipeline/workflow/CODEMANIFEST @@ -594,9 +594,10 @@ Annotations: | extend, and memory are accepted - extend-entry depends_on is a structural error; before/after (when present) must be list[str]; at least one of before/after is required - - extend-entry names are NOT validated against any pipeline — unknown - before/after names pass through; the compiler decides whether to apply - or ignore (silently with a warning) + - extend-entry names are NOT validated against any pipeline here — + unknown before/after names pass through; the compiler raises a + structural error on a name absent from the pipeline and the + extend-stages - Per-stage unknown keys are a structural error — only agent, prompt, loop, skills, skip, approve, manual, notes, reflect, memory are accepted diff --git a/goga/topics/git/.usages/refs-and-switching.md b/goga/topics/git/.usages/refs-and-switching.md index 295e7321..b97794ce 100644 --- a/goga/topics/git/.usages/refs-and-switching.md +++ b/goga/topics/git/.usages/refs-and-switching.md @@ -25,10 +25,9 @@ for ref in refs: ## Reading a ref tree ```python -from goga.history import resolve_history_root from goga.topics.git import read_ref_tree_paths -prefix = f"{resolve_history_root().as_posix()}/" +prefix = ".goga/history/" # the history tree root, repo-root-relative paths = read_ref_tree_paths("feature-foo", prefix) ``` @@ -40,10 +39,9 @@ paths = read_ref_tree_paths("feature-foo", prefix) ## Reading one file of a ref ```python -from goga.history import resolve_history_root from goga.topics.git import read_ref_file -path = f"{resolve_history_root().as_posix()}/2026/feature-foo/todo.md" +path = ".goga/history/2026/feature-foo/todo.md" # repo-root-relative content = read_ref_file("feature-foo", path) if content is not None: first = next( From 7728745d69abaf42eaa394928c77e6790cb92238 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 21:49:13 +0000 Subject: [PATCH 218/229] docs: add Returns section to current_year docstring --- goga/history/naming.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/goga/history/naming.py b/goga/history/naming.py index 27aa1078..432db12c 100644 --- a/goga/history/naming.py +++ b/goga/history/naming.py @@ -47,5 +47,8 @@ def current_year() -> str: The single time point for every history consumer: naive local time — the history tree is organized by the host's calendar year — with no timezone and no override. Pure and uncached: evaluated anew on each call. + + Returns: + The current local calendar year, four digits, as a string. """ return f"{datetime.now().year:04d}" # noqa: DTZ005 — bare now() is the mandated test mock target From 4b537999fb7fd5f2abade5d2024ec98ef1ccf70e Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 21:49:13 +0000 Subject: [PATCH 219/229] style: separate logical blocks in onboarding tests --- tests/onboarding/test_answers.py | 3 +++ tests/onboarding/test_generator.py | 9 +++++++++ tests/onboarding/test_questionnaire.py | 1 + 3 files changed, 13 insertions(+) diff --git a/tests/onboarding/test_answers.py b/tests/onboarding/test_answers.py index 71e95ccd..629fac83 100644 --- a/tests/onboarding/test_answers.py +++ b/tests/onboarding/test_answers.py @@ -88,6 +88,7 @@ def test_goga_config_answers_is_frozen(self) -> None: image="qarium/goga-python-3.12:0.1", pipeline_agent="claude", ) + with pytest.raises(dataclasses.FrozenInstanceError): cfg.language = "go" # type: ignore[misc] @@ -99,6 +100,7 @@ def test_init_answers_is_frozen(self) -> None: pipeline_agent="claude", ) answers = InitAnswers(goga_config=cfg) + with pytest.raises(dataclasses.FrozenInstanceError): answers.goga_config = cfg # type: ignore[misc] @@ -149,6 +151,7 @@ def test_init_answers_kw_only(self) -> None: image="qarium/goga-python-3.12:0.1", pipeline_agent="claude", ) + with pytest.raises(TypeError): InitAnswers(cfg) # type: ignore[call-arg] diff --git a/tests/onboarding/test_generator.py b/tests/onboarding/test_generator.py index bd462dd5..d623f719 100644 --- a/tests/onboarding/test_generator.py +++ b/tests/onboarding/test_generator.py @@ -102,6 +102,7 @@ def test_generate_goga_config_yaml_compatible_with_load_config(self, tmp_path: P mock_response.raise_for_status = MagicMock() gen = self._make_gen(tmp_path) + with patch("goga.onboarding.generator.requests.get", return_value=mock_response): gen.generate_goga_config(config) @@ -167,6 +168,7 @@ def test_generate_golang_language_url(self, tmp_path: Path) -> None: mock_response.raise_for_status = MagicMock() gen = self._make_gen(tmp_path) + with patch("goga.onboarding.generator.requests.get", return_value=mock_response) as mock_get: gen.generate(answers) @@ -179,6 +181,7 @@ def test_generate_skips_convention_when_no_usages(self, tmp_path: Path) -> None: answers = InitAnswers(goga_config=config) gen = self._make_gen(tmp_path) + with patch("goga.onboarding.generator.requests.get") as mock_get: gen.generate(answers) @@ -198,6 +201,7 @@ def test_generate_skips_convention_when_usages_without_conventions_key(self, tmp answers = InitAnswers(goga_config=config) gen = self._make_gen(tmp_path) + with patch("goga.onboarding.generator.requests.get") as mock_get: gen.generate(answers) @@ -224,6 +228,7 @@ def test_generate_creates_usages_directory_for_convention(self, tmp_path: Path) mock_response.raise_for_status = MagicMock() gen = self._make_gen(tmp_path) + with patch("goga.onboarding.generator.requests.get", return_value=mock_response): gen.generate(answers) @@ -241,6 +246,7 @@ def test_generate_convention_download_fails_propagates(self, tmp_path: Path) -> answers = InitAnswers(goga_config=config) gen = self._make_gen(tmp_path) + with ( patch( "goga.onboarding.generator.requests.get", @@ -320,6 +326,7 @@ def test_generator_no_dockerfile_when_none(self, tmp_path: Path) -> None: answers = InitAnswers(goga_config=config) gen = self._make_gen(tmp_path) + with patch("goga.onboarding.generator.requests.get"): gen.generate(answers) @@ -439,6 +446,7 @@ def test_generate_goga_config_emits_yaml_in_correct_order(self, tmp_path: Path) gen = FileGenerator() gen._base_dir = tmp_path + with patch("goga.onboarding.generator.requests.get", return_value=mock_response): gen.generate_goga_config(config) @@ -465,6 +473,7 @@ def test_generate_goga_config_emits_dockerfile_in_full_canonical_order(self, tmp gen = FileGenerator() gen._base_dir = tmp_path + with patch("goga.onboarding.generator.requests.get", return_value=mock_response): gen.generate_goga_config(config) diff --git a/tests/onboarding/test_questionnaire.py b/tests/onboarding/test_questionnaire.py index e7ea88be..3622f902 100644 --- a/tests/onboarding/test_questionnaire.py +++ b/tests/onboarding/test_questionnaire.py @@ -827,6 +827,7 @@ def test_agent_choice_accepts_all_supported_agents(self) -> None: from goga.onboarding.questionnaire import _AGENTS choice = Choice(_AGENTS) + for agent in ("claude", "codex", "cursor", "opencode", "qwen"): assert choice.convert(agent, None, None) == agent From 2aa550271b0d4fb60e2582d7bb6733ed533a644b Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Thu, 3 Sep 2026 22:03:49 +0000 Subject: [PATCH 220/229] docs: add Returns section to resolve_review_options docstring --- goga/build/review_options.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/goga/build/review_options.py b/goga/build/review_options.py index 62a52fe0..940ceb17 100644 --- a/goga/build/review_options.py +++ b/goga/build/review_options.py @@ -65,6 +65,10 @@ def resolve_review_options(config: BuildConfig, cli_options: dict) -> ReviewOpti `skip_review` (bool | None — None = flag not given), `base_ref` (str | None — an empty or whitespace-only value counts as unset), and `review_patience` (int | None). + + Returns: + The resolved ReviewOptions: the skip decision, review agent, roles, + env, and two_pass flag, plus the review-scoped base_ref and patience. """ review_executor = config.review_executor From bb4f79dfd21326cd91ad0c9ed36c63d81b79e841 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 4 Sep 2026 09:03:18 +0000 Subject: [PATCH 221/229] docs: apply documentation review updates --- .goga/memory/architecture.md | 27 ++++++----- .goga/memory/code-design.md | 14 +++--- README.md | 45 +++++++++++-------- docs/cli/build.md | 4 +- docs/cli/history.md | 10 ++--- docs/cli/hooks.md | 1 - docs/cli/install.md | 10 ++--- docs/cli/pipeline.md | 6 ++- docs/cli/topics.md | 15 ++++--- docs/configuration/agents.md | 1 + docs/configuration/project.md | 2 +- docs/getting-started.md | 4 +- docs/pipelines/shipped.md | 17 +++---- docs/pipelines/workflows.md | 11 +++-- docs/tools.md | 2 +- docs/workflow/apply.md | 29 ++++++------ docs/workflow/brainstorm.md | 10 +++-- docs/workflow/build.md | 4 +- docs/workflow/define.md | 2 +- docs/workflow/design.md | 14 ++---- docs/workflow/discover.md | 4 +- docs/workflow/index.md | 6 +-- docs/workflow/plan.md | 15 +++---- docs/workflow/review.md | 4 +- goga/assets/skills/goga-apply/SKILL.md | 17 ++++--- .../goga-brainstorm-plan-assembly/SKILL.md | 8 ++-- .../goga-brainstorm-primary-analysis/SKILL.md | 6 +-- goga/assets/skills/goga-brainstorm/SKILL.md | 6 +-- .../skills/goga-cells-by-brainstorm/SKILL.md | 2 +- goga/assets/skills/goga-define-prd/SKILL.md | 14 +++--- goga/assets/skills/goga-define/SKILL.md | 2 +- .../skills/goga-design-by-changes/SKILL.md | 1 - .../design-doc-template.md | 2 + goga/assets/skills/goga-discover/SKILL.md | 2 +- .../skills/goga-plan-by-design/SKILL.md | 2 +- .../goga-plan-by-design/output-template.md | 2 + goga/assets/skills/goga-plan/SKILL.md | 4 +- goga/assets/skills/goga-review-arch/SKILL.md | 4 +- goga/assets/skills/goga-review/SKILL.md | 2 - .../skills/goga-task-by-proposing/SKILL.md | 2 +- 40 files changed, 171 insertions(+), 162 deletions(-) diff --git a/.goga/memory/architecture.md b/.goga/memory/architecture.md index 6f455e6f..2b97c85b 100644 --- a/.goga/memory/architecture.md +++ b/.goga/memory/architecture.md @@ -1,21 +1,23 @@ -# Project rules +# Project rules — architecture -## Dependency edges target the owner's facade and respect fixed direction +## Dependency edges target the owner's facade and respect the fixed direction All interaction with a subsystem's capabilities — code dependencies and documentation alike — targets the owning unit's public surface. Internal sub-units are never direct dependency targets; nested capabilities publish their contracts at -the owner's level, and reuse happens through the owner's re-export, never by linking into the depths. When a unit -accumulates several functional zones (data, registry, dispatch, access to an external system), it is split into leaf -sub-units by zone, with the main API re-exported on the parent facade; consumers import only the facade. On top of -target choice, direction is part of the same law: dependency direction between domains is fixed and one-way, and a -reverse edge is never introduced, whatever reuse it would buy — it creates a cycle that surfaces too late. When the -fixed direction puts a capability out of reach, the fallback is a consumer-side variant, never an edge shortcut. +the owner's level, and reuse happens through the owner's re-export, never by linking into the depths. + +When a unit accumulates several functional zones (data, registry, dispatch, access to an external system), it is split +into leaf sub-units by zone, with the main API re-exported on the parent facade; consumers import only the facade. + +Direction is part of the same law: dependency direction between domains is fixed and one-way, and a reverse edge is +never introduced, whatever reuse it would buy — it creates a cycle that surfaces too late. When the fixed direction +puts a capability out of reach, the fallback is a consumer-side variant, never an edge shortcut. ## Single access zone per external system All operations that reach one external system inside a domain belong to exactly one dedicated leaf unit that owns the access, mirrors the structure of the existing access leaves, exposes a minimal public surface, and is consumed only -through the domain facade; when the access happens and with what content remains the responsibility of consumer +through the domain facade. When the access happens and with what content remains the responsibility of consumer orchestrations. New capabilities extend that unit's zone instead of spawning a parallel sibling — even when the extension forces an exception to the zone's established invariants. Extending a zone never rewrites already published contract fragments: their invariants stay verbatim, and every new allowance is recorded only in the fragments of the @@ -34,10 +36,11 @@ contract. Environment coupling lives at the boundary layer, never in the domain core. The boundary layer resolves external inputs — source precedence of explicit argument over configuration over built-in default — and passes primitive values -inward; interactive prompting and terminal-capability handling belong to the outer command layer, keeping the domain -core usable from non-interactive callers and inner layers independently testable. Command callbacks stay thin in the +inward; interactive prompting that resolves a missing input belongs to the outer command layer, and a domain routine +that must interact detects the non-interactive terminal and fails with a clean error — keeping the domain core usable +from non-interactive callers and inner layers independently testable. Command callbacks stay thin in the same spirit: they only resolve inputs, delegate to domain routines, and render results, passing values through as -opaque data without validating or re-interpreting them — grammar, normalization, and filtering rules for a value +opaque data without validating or re-interpreting them — grammar and normalization rules for a value belong exclusively to the domain module. The domain core exposes all-or-nothing read-only resolution with clean errors and mutation routines that run unconditionally once the caller has confirmed. The value provider performs structural validation only (type and shape), stores values verbatim, embeds no defaults, and checks no semantics — semantic diff --git a/.goga/memory/code-design.md b/.goga/memory/code-design.md index 7deae6d8..53e0bd8c 100644 --- a/.goga/memory/code-design.md +++ b/.goga/memory/code-design.md @@ -1,23 +1,23 @@ -# Project rules +# Project rules — code design -## Independent Root-Cause Isolation +## Independent root-cause isolation -When multiple failure classes arrive together, reproduce and prove each cause separately against authoritative sources -such as official documentation and actual runtime sources before fixing anything; never assume a single shared cause or +When multiple failure classes arrive together, reproduce and prove each cause separately against authoritative material +— official documentation and the actual runtime sources — before fixing anything; never assume a single shared cause or diagnose solely from an aggregated CI report. -## Specification-Implementation Co-Evolution +## Specification-implementation co-evolution When a defect surfaces at a contract boundary, fix the implementation to satisfy the existing specification and amend the specification only to state the boundary explicitly, then confirm consistency through the project's specification validation gates; never rewrite the specification to legitimize buggy behavior. -## Explicit Version-Stable Validation +## Explicit version-stable validation Encode edge-case semantics explicitly in production validation logic so behavior is identical on every supported runtime version, rather than relying on standard-library behavior that silently changes between versions. -## Honest Verification Scope +## Honest verification scope Validate on the environments where the defects actually reproduce, rely on existing parameterized coverage and the CI matrix for environments unavailable locally, disclose local-coverage gaps explicitly in every report, and never diff --git a/README.md b/README.md index f1ddfab5..048ec7a6 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ goga topics board # the board: every topic of the year across bran goga topics board --remote # same board over remote-tracking refs goga topics board --info # the board with the todo column (the todo summary of todo.md) goga topics create feat/x --from-current # fresh work off the current HEAD: the branch verbatim + its topic directory -goga topics create feat/x -t "Payment retry" # same, and writes todo.md (status: todo) +goga topics create feat/x -t "Payment retry" # same (--from-current implied), and writes todo.md (status: todo) goga topics create feat/x # on a terminal, same and the todo entry opens in your $EDITOR goga topics create feat/x -p -t "Payment retry" # same, committed + pushed to origin, no switch goga topics switch feat-x # onto the branch hosting that work (branch, slug, or prefix) @@ -154,13 +154,13 @@ goga topics delete feat-x # delete the branch, its origin twin, and the di goga topics --year 2025 board # the board of an explicit year ``` -Every `create` needs a base: `--base-ref`, or `topics.base_ref` in `.goga/config.yml`, or the current HEAD under `--from-current`. With no `-t` given a terminal opens the external editor for the todo (empty or unchanged cancels), and on a terminal the command asks once whether to publish. `--publish`/`-p` is the fast mode: it builds the branch off the resolved base with a single `todo.md` commit and pushes it to `origin` without switching — your working copy, index, and HEAD stay untouched, and a failed push rolls the branch back. See [`goga topics`](https://qarium.github.io/goga/cli/topics/). +Every `create` needs a base: `--base-ref`, or `topics.base_ref` in `.goga/config.yml`, or the current HEAD under `--from-current`. With no `-t` given a terminal opens the external editor for the todo (an empty or unchanged file cancels the todo — the work is created without one); once a todo is resolved, the command asks on a terminal whether to publish. `--publish`/`-p` is the fast mode: it builds the branch off the resolved base with a single `todo.md` commit and pushes it to `origin` without switching — your working copy, index, and HEAD stay untouched, and a failed push rolls the branch back. See [`goga topics`](https://qarium.github.io/goga/cli/topics/). The board is a three-column table — topic, branch, statuses, plus a todo column under `--info` — with `*` marking the current branch and a local branch absorbing its remote twin. Each topic carries its **maximal statuses** in scale order: `empty → todo → defined → discovered → backlog → designed → specified → planned → done`, deepening as `todo.md`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. A topic can carry several statuses at once (`goga history status` prints them; `-s` filters by any of them). -Topics no branch hosts anymore are orphans — `goga history prune --dry-run` lists the orphans of a year, and `goga history -y <year> prune` deletes them (the year is the group's `-y`/`--year` option, given once before the subcommand; irreversibly: the history tree is not in git). +Topics no branch hosts anymore are orphans — [`goga history`](https://qarium.github.io/goga/cli/history/) `prune --dry-run` lists the orphans of a year, and `goga history -y <year> prune` deletes them (the year is the group's `-y`/`--year` option, given once before the subcommand; irreversibly: the history tree is not in git). -To resume work inside a pipeline, pass the identifier to the run — `goga pipeline development -t feat/x` switches to the hosting branch first (creating a local branch from its remote-tracking ref when needed) and is an idempotent no-op when you are already on it; adding `--todo` opens the topic's `todo.md` in your editor after the switch. Fresh work is started with `goga topics create`, not `-t`. +To resume work inside a pipeline, pass the identifier to the run — `goga pipeline development -t feat/x` switches to the hosting branch first (creating a local branch from its remote-tracking ref when needed) and is an idempotent no-op when you are already on it; adding `--todo` opens the topic's `todo.md` in your editor after the switch. Fresh work is better started with `goga topics create` — it takes an explicit base, todo, and publication; a pipeline `-t` creates from the current HEAD when nothing hosts the identifier. ## Pipelines @@ -235,8 +235,6 @@ A **workflow-file** (`.goga/workflows/<name>.yml`) configures and extends a comp ```yaml stages: - propose: - agent: codex brainstorm: agent: codex architecture-review: @@ -265,8 +263,8 @@ stages: ```yaml stages: - deploy: - manual: true # the pipeline pauses before deploy until launched manually + accept-result: + manual: true # the pipeline pauses before accept-result until launched manually ``` **`skills` — add skills to a stage.** Merged with the pipeline stage's own skills (pipeline-first, deduplicated by value): @@ -297,7 +295,7 @@ stages: ```yaml stages: - deploy: + plan-review: notes: fix: Fix the failure and continue ``` @@ -314,7 +312,7 @@ stages: file: shared.md ``` -Additionally: `skip: true` removes a stage with transparent reconnection of dependents, and `extend:` adds brand-new stages with `before`/`after` positioning (a new stage's own launch mode is authored in its body via `trigger: manual`). The full model is in the [Workflows](https://qarium.github.io/goga/pipelines/workflows/) documentation. Workflow memory requires afm 0.5.60+ (the shipped image carries it). +Additionally: `skip: true` removes a stage with transparent reconnection of dependents, and `extend:` adds brand-new stages with `before`/`after` positioning (a new stage's own launch mode is authored in its body via `trigger: manual`). Names under `stages:` must name stages of the target pipeline — `propose` exists only in `refinement`, `brainstorm` only in `development`; brand-new stages come via `extend:`. The full model is in the [Workflows](https://qarium.github.io/goga/pipelines/workflows/) documentation. Workflow memory requires afm 0.5.60+ (the shipped image carries it). Run with a workflow: @@ -351,7 +349,7 @@ goga install --local <path> goga install --local <path>:<tool-name> ``` -After a successful pip, `goga install` runs each freshly installed tool's optional post-install hook (a callable `install` in its facade — skipped quietly when absent), then re-syncs every already-connected agent: +After a successful pip, `goga install` runs each freshly installed tool's optional post-install hook (a callable `install` in its facade — skipped quietly when absent), then re-syncs every already-connected agent automatically. Connect a new agent at any time: ```bash goga connect <agent> @@ -408,12 +406,14 @@ The following tools ship with goga out of the box — no separate install requir ### Packaging your own tool -Minimal layout, illustrated by a tool named `acme` that ships four subcommands — `explore`, `propose`, `apply`, `archive` — and one pipeline-file, with no top-level dispatcher skill: +Minimal layout, illustrated by a tool named `acme` that ships four subcommands — `explore`, `propose`, `apply`, `archive` — and one pipeline-file: ``` goga_tool_acme/ ├── __init__.py # main(argv: list[str]) — CLI entry; optional install()/register_hooks() ├── skills/ +│ ├── acme/ +│ │ └── SKILL.md # goga-tool-acme — the entry point │ ├── acme-explore/ │ │ └── SKILL.md # goga-tool-acme-explore │ ├── acme-propose/ @@ -429,8 +429,8 @@ goga_tool_acme/ A valid tool **must**: - Be named with the `goga_tool_` prefix (PyPI publication under `goga-tool-`) -- Contain a `skills/` directory with at least one skill (each skill directory has a `SKILL.md`) -- Expose a `main(argv: list[str])` function for CLI execution (optionally declaring a keyword-capable `ast` parameter to receive the project AST) +- Contain the entry-point skill `skills/<tool-name>/SKILL.md` — `goga connect` skips the whole package with a warning when it is missing (every skill directory carries a `SKILL.md`) +- Expose a `main(argv: list[str])` function for CLI execution - A `pipelines/` directory is **optional**; when present, its flat `*.yml` files are copied into `~/.goga/pipelines/` at `goga connect` time, namespaced as `<tool>:<name>.yml` A tool **may** additionally expose an `install(user: str | None = None)` callable in its facade package: `goga install` calls it after a successful pip, passing the initiating user (`SUDO_USER` when goga itself runs under sudo, else the current OS user) only when the parameter is declared keyword-capable. A missing or non-callable `install` is skipped quietly. @@ -446,7 +446,7 @@ def register_published(context): context.register("published", "mkdocs/published.md", after="planned") ``` -The hook receives the delivered status registry through `context` — read and call freely, attribute assignment is blocked. The name is shown qualified as `<tool>.<name>` (here `mkdocs.published`; the tool identity is the package name with the `goga_tool_` prefix dropped and underscores turned into hyphens, so `goga_tool_hello_world` registers `hello-world.*`), the filepath is relative to the topic directory (nested paths allowed), and `before=`/`after=` anchor the entry to an existing scale entry (at least one anchor is required; both define a range). Built-in entries are immutable. A bad registration — an unknown anchor, an invalid range, or a crashed hook — is skipped with a warning on stderr and never aborts the command; only a package that fails to import is fatal. Run `goga hooks` to inspect what is registered. The removed `register_topic_statuses(statuses)` callback is no longer called — a package still carrying it loses its statuses silently after the update. +The hook receives the delivered status registry through `context` — read and call freely, attribute assignment is blocked. The name is shown qualified as `<tool>.<name>` (here `mkdocs.published`); the tool identity is the package name with the `goga_tool_` prefix dropped and underscores turned into hyphens, so `goga_tool_hello_world` registers `hello-world.*`. The filepath is relative to the topic directory (nested paths allowed), and `before=`/`after=` anchor the entry to an existing scale entry — at least one anchor is required, both define a range. Built-in entries are immutable. A bad registration — an unknown anchor, an invalid range, or a crashed hook — is skipped with a warning on stderr and never aborts the command; only a package that fails to import is fatal. Run [`goga hooks`](https://qarium.github.io/goga/cli/hooks/) to inspect what is registered. The removed `register_topic_statuses(statuses)` callback is no longer called — a package still carrying it loses its statuses silently after the update. After publication, install into any project: @@ -472,7 +472,7 @@ When `goga connect` installs a tool, the prefix `goga-tool-<tool-name>-` is adde Rules: - Use lowercase with hyphens as separators -- When a top-level dispatcher skill is wanted, name its directory exactly `<tool-name>` — it becomes the entry point invoked by `/goga:tool <name>` (or `goga-tool` / `$goga-tool` in agents without slash-command support). A tool that exposes only subcommands (like `acme` above) skips this directory. +- The entry-point skill directory is named exactly `<tool-name>` — it becomes the skill invoked by `/goga:tool <name>` (or `goga-tool` / `$goga-tool` in agents without slash-command support). It is required: a package without `skills/<tool-name>/SKILL.md` is skipped by `goga connect` with a warning. - Name sub-skills descriptively using the `<tool-name>-<purpose>` pattern (e.g., `mkdocs-discovery`, `mkdocs-validator`) ### Pipeline namespacing @@ -630,7 +630,7 @@ stages: approve: auto ``` -These are not special "SDD extension points" — they are exactly the same workflow mechanisms from the Pipelines section, applied to the SDD cycle. Combining tools and workflows, SDD can be compressed to `propose → accept` for prototypes or expanded with threat-modelling, security review, and compliance gates for production. Read the full functional model in the [Workflow](https://qarium.github.io/goga/pipelines/workflows/) section of the docs. +These are not special "SDD extension points" — they are exactly the same workflow mechanisms from the Pipelines section, applied to the SDD cycle. Combining tools and workflows, SDD can be compressed to `propose → accept` for prototypes or expanded with threat-modelling, security review, and compliance gates for production. Read the full functional model in the [Workflows](https://qarium.github.io/goga/pipelines/workflows/) section of the docs. ## Build @@ -651,7 +651,16 @@ goga build plan.md -e ENV_VAR=value # forward an extra env var into the co goga build plan.md --skip-review # run tasks only, skip the review phase ``` -The review phase is configurable beyond the on/off flag: a `build.review_executor` section in `.goga/config.yml` can hand review to a different agent (`agent: codex` runs a second, review-only pass on the codex wrapper), skip it by default (`skip: true` — `--no-skip-review` forces the full cycle), select the reviewer composition (`roles: [quality, testing]`), layer environment variables onto the review pass alone (`env: {ANTHROPIC_MODEL: reviewer}` — the variables overlay the container environment for the review subprocess only; the tasks pass never sees them, the values never reach logs or dry-run output, and like a differing agent a non-empty `env` forces a two-pass run, so it cannot be combined with a worktree), bound the review diff to an explicit base (`base_ref: origin/main` — a branch name or commit hash that overrides ralphex's default-branch detection; `--base-ref` on the command line wins), and stop the external review after N unchanged rounds (`patience: 3`, or `--review-patience` — the setting moved from the top-level `build.review_patience` key, which is no longer parsed). Both review bounds apply to review-carrying passes only: the single full-cycle pass, or the review pass of a two-pass run. After a successful run the plan file itself moves to `completed/` inside its own topic directory (`.goga/history/<year>/<topic>/completed/`). +The review phase is configurable beyond the on/off flag through a `build.review_executor` section in `.goga/config.yml`: + +- hand review to a different agent (`agent: codex` runs a second, review-only pass on the codex wrapper); +- skip it by default (`skip: true` — `--no-skip-review` forces the full cycle); +- select the reviewer composition (`roles: [quality, testing]`); +- layer environment variables onto the review pass alone (`env: {ANTHROPIC_MODEL: reviewer}` — the variables overlay the container environment for the review subprocess only; the tasks pass never sees them, the values never reach logs or dry-run output, and like a differing agent a non-empty `env` forces a two-pass run, so it cannot be combined with a worktree); +- bound the review diff to an explicit base (`base_ref: origin/main` — a branch name or commit hash that overrides ralphex's default-branch detection; `--base-ref` on the command line wins); +- stop the external review after N unchanged rounds (`patience: 3`, or `--review-patience` — the setting moved from the top-level `build.review_patience` key, which is no longer parsed). + +Both review bounds apply to review-carrying passes only: the single full-cycle pass, or the review pass of a two-pass run. After a successful run the plan file itself moves to `completed/` inside its own topic directory (`.goga/history/<year>/<topic>/completed/`). A running build executes inside a Docker container, where its run-state and logs are written to a persistent host directory and survive across runs of the same project on the same branch — so an interrupted build can be resumed. Pass `--clean` (or `-c`) to wipe that state before launch for a fresh run. After the build, test the implementation manually. diff --git a/docs/cli/build.md b/docs/cli/build.md index 58b1ff41..4025525b 100644 --- a/docs/cli/build.md +++ b/docs/cli/build.md @@ -17,7 +17,7 @@ The build pipeline performs these steps: 1. **Docker check** -- Verifies Docker is installed and accessible. 2. **Config loading** -- Reads `.goga/config.yml` for build settings. 3. **Uncommitted manifest check** -- Scans `git status` for uncommitted `CODEMANIFEST` files (can be skipped). -4. **Agent preconditions** -- Sets up agent-specific files (e.g., `.claude/settings.json`, `.ralphex/claude-wrapper.sh` for Claude). A review executor whose `agent` differs from the task executor, or that declares a non-empty `env`, combined with an active worktree (`--worktree` or `build.worktree: true`) is rejected here with exit 1, before any container launch — the ralph-loop review mode cannot follow a worktree branch. The guard is config-level and skip-independent: `--skip-review` does not bypass it. +4. **Agent preconditions** -- Resolves the agent wrapper path into `.ralphex/config` (e.g., `claude_command = /home/goga/bin/claude-as-claude.sh`; the wrappers ship inside the image). A review executor with a set `agent` that differs from the task executor, or that declares a non-empty `env`, combined with an active worktree (`--worktree` or `build.worktree: true`) is rejected host-side with exit 1, before any container launch — the ralph-loop review mode cannot follow a worktree branch. The guard is config-level and skip-independent: `--skip-review` does not bypass it. 5. **Defaults copy** -- Fully rewrites `.ralphex/prompts/` and `.ralphex/agents/` from the configured `build.prompts_dir`/`build.agents_dir`, or from the vendored ralph-loop defaults shipped with goga (`goga/assets/ralphex/`). When `build.review_executor.roles` is set, the review prompts are filtered to the selected roles. 6. **Image refresh (optional)** -- When `--update`/`-u` is set, the image is refreshed: if a top-level `dockerfile` is declared in `.goga/config.yml`, `docker build` runs against it (build failure is fatal — exit 1); otherwise `docker pull` runs (a pull failure is logged as a warning and the build proceeds with the locally available image). By default no refresh happens and the local image is used as-is. 7. **Docker execution** -- Launches the ralph-loop command inside the configured Docker image, after a pre-launch host–image version check (see [Pre-launch version check](#pre-launch-version-check)). Credential files for claude, codex, and opencode are detected on the host and bind-mounted read-only into the container automatically (no flag). @@ -43,7 +43,7 @@ The build pipeline performs these steps: | `--max-iterations` | int | config | Maximum number of build iterations | | `--review-patience` | int | config | Review patience count | | `--base-ref` | string | config | Review diff base (branch name or commit hash); overrides `build.review_executor.base_ref` | -| `-e`, `--env` | string | -- | Additional environment variable (`KEY=VALUE`, repeatable) | +| `-e`, `--env` | string (repeatable) | -- | Additional environment variable (`KEY=VALUE`, repeatable) | | `--proxy` | string | config | HTTP/HTTPS proxy URL; overrides `build.proxy`. Adds `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` to the container env-file | | `--add-host` | string (repeatable) | -- | Add a `docker run --add-host HOST:IP` entry; merges on top of `build.hosts` (CLI wins on key conflict) | | `--update`, `-u` | flag | off | Refresh the image before launch (build if a project Dockerfile is declared, else pull). Default skips the refresh | diff --git a/docs/cli/history.md b/docs/cli/history.md index 1bda57d6..e40aaee7 100644 --- a/docs/cli/history.md +++ b/docs/cli/history.md @@ -43,16 +43,16 @@ An empty tree prints nothing. Read-only — statuses and artifact names never ap Prints the topics of one year, one `topic [status] [status] …` line each: ``` -feat-x [defined] [planned] +feat-x [planned] release-1-3-0 [done] [mkdocs.published] ``` A topic carries its **maximal present statuses** in scale order — one bracketed segment per status: -| Status | Artifact | | +| Status | Artifact | Notes | |---|---|---| | `empty` | — | no artifact yet | -| `todo` | `todo.md` | written by `goga topics create --todo` | +| `todo` | `todo.md` | written by the `--todo` editor entries (`goga topics create/switch --todo`, `goga pipeline … --todo`) | | `defined` | `prd.md` | | | `discovered` | `adr.md` | | | `backlog` | `task.md` | | @@ -63,7 +63,7 @@ A topic carries its **maximal present statuses** in scale order — one brackete A topic can carry several statuses at once: every artifact present that is outranked by no other present artifact stays visible (tool statuses included, shown qualified such as `mkdocs.published` — see [Tools](../tools.md) for how a tool package registers its own statuses). The year comes from the group's `-y`/`--year` (default: the current year) and is never printed; topics come out alphabetically. -The status segments print colored (`cyan`) unless `NO_COLOR` is set in the environment. +The status segments print colored (`cyan`) unless a non-empty `NO_COLOR` is set in the environment. ### Filters @@ -103,7 +103,7 @@ goga history -y 2025 prune --dry-run goga history -y 2025 prune # one explicit year ``` -- A topic is protected when a local branch, or a remote-tracking ref whose short name (the part after the first `/`) normalizes to the topic slug, hosts it — in every year, not just the scoped one. +- A topic is protected when any local branch name, or the short name of any remote-tracking ref (the part after the first `/`), normalizes to the topic slug — in every year, not just the scoped one. A branch carrying the topic as merged work does not protect it. - Deletion is unconditional — no status protects a topic, a `done` orphan goes too — and irreversible: the history tree is not in git, so a deleted topic directory cannot be recovered. Run with `--dry-run` first. - Filesystem-only: no branch, ref, or index of git is touched — the only git call is the read-only ref listing. diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md index 9311a9a6..643a370c 100644 --- a/docs/cli/hooks.md +++ b/docs/cli/hooks.md @@ -30,7 +30,6 @@ scriba - Under a tool, one domain line per distinct domain of its subscriptions, ordered alphabetically. - Under a domain, one line per subscription — the action name and the hook name. - Every refused registration prints with its reason. -- A tool with no subscriptions and no refusals prints its line alone. - An empty registry prints nothing and exits `0`. ## The slice diff --git a/docs/cli/install.md b/docs/cli/install.md index fab09f82..c44fb15f 100644 --- a/docs/cli/install.md +++ b/docs/cli/install.md @@ -2,14 +2,14 @@ `goga install` adds goga-tool packages into the **current runtime interpreter** — the exact Python that runs goga. It targets the running interpreter's pip directly, so the install lands in the correct environment regardless of how goga was deployed (pipx venv, system Python, or any other). -After a successful pip in single, local, or bulk mode, the command runs each freshly installed tool's optional **post-install hook** (see [Post-install hooks](#post-install-hooks)), then **activates** every agent already recorded in `~/.goga/connect.yml` (re-syncing each with its persisted `force_overwrite`) so the freshly installed tool's skills and pipelines appear in `~/.goga/` and in each connected agent's symlink tree. Pass `--no-connect` to skip activation and perform the install only (useful in CI/Docker where a transient activation failure must not fail the install); the post-install hooks still run. To execute an installed tool without going through an agent, run the dedicated tool-runner command. +After a successful pip in single, local, or bulk mode, the command runs each freshly installed tool's optional **post-install hook** (see [Post-install hooks](#post-install-hooks)), then **activates** every agent already recorded in `~/.goga/connect.yml` (re-syncing each with its persisted `force_overwrite`) so the freshly installed tool's skills and pipelines appear in `~/.goga/` and in each connected agent's symlink tree. Pass `--no-connect` to skip activation and perform the install only (useful in CI/Docker where a transient activation failure must not fail the install); the post-install hooks still run. To execute an installed tool without going through an agent, run [`goga tool`](tool.md). ## Modes `goga install` branches on whether a tool name or `--local` path is given: - **Single mode** (`goga install <name>`): install one tool, run its post-install hook, then activate. `--version` resolves through the four-form grammar; the project config is ignored. -- **Local mode** (`goga install --local <path>[:<tool-name>]` / `-l <path>[:<tool-name>]`): pip-install a local directory (no PyPI lookup). Mutually exclusive with `name`; `--version` is rejected. The optional `:<tool-name>` suffix names the tool whose post-install hook runs; without it no hook runs (a warning names the suffix as the way to enable it). Activation follows the single/bulk rules. +- **Local mode** (`goga install --local <path>[:<tool-name>]` / `-l <path>[:<tool-name>]`): pip-install a local directory (no PyPI lookup). Mutually exclusive with `name`; `--version` is rejected. The optional `:<tool-name>` suffix names the tool whose post-install hook runs; without it no hook runs (a warning is logged). Activation follows the single/bulk rules. - **Bulk mode** (`goga install`): install every tool declared in the `tools` section of `.goga/config.yml`, in a single pip invocation in YAML insertion order, then one activation pass. - **Empty mode** (`goga install` with no `tools` section): no-op — prints `Nothing to install` and exits 0. pip is not invoked, and neither is activation. @@ -74,7 +74,7 @@ Install a pip-installable local directory instead of resolving a package from Py ```bash # Install a tool from a local source checkout. -# No hook runs — a warning names the way to enable it +# No hook runs — a warning is logged goga install --local ./my-tool # Same path with the :<tool-name> suffix — the post-install hook of @@ -105,7 +105,7 @@ Local mode issues a single `pip install <path> -U` (never `-e`/editable) so the ## Post-install hooks -After a successful pip in single mode, local mode with a `:<tool-name>` suffix, and bulk mode, the command imports each freshly installed tool's facade module `goga_tool_<tool>` and calls its `install` callable when one exists: +After a successful pip in single mode, local mode with a `:<tool-name>` suffix, and bulk mode, the command imports each freshly installed tool's facade module `goga_tool_<tool>` (hyphens and dots become underscores, lowercased) and calls its `install` callable when one exists: - No facade module or no callable `install` → quiet skip (the hook is optional; tools without one install exactly as before). - The hook's signature declares a keyword-capable `user` parameter → called as `install(user=<initiating user>)`; otherwise called with no arguments. @@ -125,7 +125,7 @@ def install(user: str | None = None) -> None: ## Post-install activation -When pip succeeds in single or bulk mode and `--no-connect` is not set, the command activates every agent listed in `~/.goga/connect.yml`, each with its own recorded `force_overwrite`. Activation is a local operation on `$HOME` and **never** runs under `--sudo` — only pip honors `--sudo`. A missing or empty registry is a no-op that returns 0: the tool is installed on the interpreter but not yet linked to any agent. Connect an agent later with `goga connect <agent>` and the tool will be picked up on the next install/upgrade. +When pip succeeds in single, local, or bulk mode and `--no-connect` is not set, the command activates every agent listed in `~/.goga/connect.yml`, each with its own recorded `force_overwrite`. Activation is a local operation on `$HOME` and **never** runs under `--sudo` — only pip honors `--sudo`. A missing or empty registry is a no-op that returns 0: the tool is installed on the interpreter but not yet linked to any agent. Connect an agent later with `goga connect <agent>` and the tool will be picked up on the next install/upgrade. ## Version form grammar diff --git a/docs/cli/pipeline.md b/docs/cli/pipeline.md index ff39a888..fc8e0612 100644 --- a/docs/cli/pipeline.md +++ b/docs/cli/pipeline.md @@ -115,7 +115,7 @@ The outcome: A switch that would mutate checks the working tree first: a dirty tree exits 1 with `working tree is dirty — commit or stash before switching` before anything is touched. Every git action happens on the host, after every form check and before any docker activity. The single result line (`Switched to branch <name>`, `Created branch <name> from <remote>/<name>`, `Already on branch <name>`, or `Created branch <name> and topic <year>/<slug>`) is echoed to stdout once, before the launch. The branch name is never forwarded into the container — the container sees the branch through the mounted project, and goga does not switch back after the launch. -With `--todo`, the topic procedure opens the external editor with the ensured work's `todo.md` after the switch or the fresh creation — saving overwrites the file (no commit), cancelling leaves it untouched, exactly like `goga topics switch --todo`. The flag needs an interactive terminal and acts only together with `--topic`: `--todo` without `--topic` in the run form is a clean error (`--todo acts only together with --topic`, exit 1) fired before any git or docker activity. +With `--todo`, the topic procedure opens the external editor with the ensured work's `todo.md` after the switch or the fresh creation — saving overwrites the file (no commit), cancelling leaves it untouched, as in `goga topics switch --todo` — but unlike switch, a hosting branch without a topic gets its topic directory created and the todo entered. The flag needs an interactive terminal and acts only together with `--topic`: `--todo` without `--topic` in the run form is a clean error (`--todo acts only together with --topic`, exit 1) fired before any git or docker activity. The flat list, overview, and card forms silently ignore `-t` and `--todo` — passing them there is not an error and has no effect. @@ -165,7 +165,7 @@ Three invocation modes (mutually exclusive in the explicit cases), honored by bo For a run, the decision reaches the container via the env-file (`GOGA_WORKFLOW_NAME=<name>` for `--workflow`; `GOGA_WORKFLOW_DISABLED=1` for `--no-workflow`; neither for auto-match). For a card (`<name> --info`), the same flags travel in the `docker run` argv — the composition the card prints is exactly the composition a run with the same flags executes. -When a workflow will actually be applied to a run (explicit `--workflow`, or an auto-match file that exists), the launcher prints `Pipeline running with workflow "<name>"` to stdout. When no workflow applies, the launcher prints no workflow line. The launcher surfaces only the workflow log line, the `docker` output stream, and any pre-launch version-check warning or refusal on stderr (see [Pre-launch version check](#pre-launch-version-check)). +When a workflow will actually be applied to a run (explicit `--workflow`, or an auto-match file that exists), the launcher prints `Pipeline running with workflow "<name>"` to stdout. When no workflow applies, the launcher prints no workflow line. The launcher surfaces only the workflow log line, the `docker` output stream, any pre-launch version-check warning or refusal on stderr (see [Pre-launch version check](#pre-launch-version-check)), and, in the run form with `-t`, the single topic result line. Inside the container the goga in-container process resolves and parses the workflow-file, then forwards it to the compiler, which reconstructs the parsed body: `extend` entries inject new stages positioned via `before`/`after`, per-stage `agent` overrides compose the in-container wrapper path into the stage's `command` slot, per-stage `prompt` overrides fill its `description` slot, `skip: true` removes the stage and reconnects its dependents' `depends_on`, a `loop: N` (N ≥ 2) expands the stage into `NAME-1`..`NAME-N` copies with chained internal `depends_on` (external references are rewritten to the LAST expanded id), `manual: true|false` forces or cancels the stage's manual launch mode (compiling to the afm `auto_run` key), and a `memory` block with per-stage `reflect` / `memory` instructions emits the afm top-level `memory` block and the per-stage `reflect` / `memory_use` keys (only when at least one stage participates — see [Workflows — Project memory](../pipelines/workflows.md#project-memory-memory-reflect)). @@ -211,6 +211,8 @@ Run mode mounts a host directory at `/home/goga/pipeline` inside the container a ~/.goga/runtime/pipelines/<normalized-project-path>/<git-branch>/<name>/ ``` +A `:` in the pipeline name becomes `-` in the path segment (`acme:deploy` → `acme-deploy`). + It is created before launch and is **not** deleted on exit. Use `--clean` to wipe it before launch when you want a fresh run. Note that the `prompts/` subdirectory inside it is regenerated on every run (wiped and rebuilt from the shipped defaults plus any `roles` overrides) — it does not persist user-placed content even though the parent directory survives across runs. diff --git a/docs/cli/topics.md b/docs/cli/topics.md index 8cc04670..003284f0 100644 --- a/docs/cli/topics.md +++ b/docs/cli/topics.md @@ -2,7 +2,7 @@ Work with the topics of one year — the cross-branch inventory, fresh-work creation, switching, and deletion. -`goga topics` is a Click group with four subcommands (`board`, `create`, `switch`, `delete`) over the topics domain. It is host-side and git-driven: the board and the deletion resolution read branch trees without checkout, and creation and switching perform bounded local git mutations. Two subcommands touch the network, each exactly once: `create --publish` pushes the new branch to `origin`, and `delete` pushes the branch deletion to `origin` (no fetch ever happens); every other mutation is local. +`goga topics` is a Click group with four subcommands (`board`, `create`, `switch`, `delete`) over the topics domain. It is host-side and git-driven: the board and the deletion resolution read branch trees without checkout, and creation and switching perform bounded local git mutations. The only network operations are the `--publish` push and the delete push (one per target that has an origin twin); no fetch ever happens; every other mutation is local. ## Synopsis @@ -20,11 +20,11 @@ goga topics [--year YYYY] delete IDENTIFIER... [--yes] Prints the board — the cross-branch topic inventory of the scoped year — as a three-column table: topic, branch, statuses. ``` -| Topic | Branch | Statuses | -|----------------|----------|-------------------| -| feat-b | feat-b | [defined] | -| * feat-a | feat-a | [planned] | -| feat-a | feat-b | [planned] | +| Topic | Branch | Statuses +|----------------|----------------|------------------- +| feat-b | feat-b | [defined] +| * feat-a | feat-a | [planned] +| feat-a | feat-b | [planned] ``` - One row per topic hosted by a branch; `*` marks the row hosting the current branch. @@ -48,7 +48,8 @@ goga topics create Feature/Foo_Bar --from-current goga topics create Feature/Foo_Bar --base-ref origin/main --todo "Payment retry" # Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar -# (.goga/history/2026/feature-foo-bar/todo.md now carries "Payment retry") +# (.goga/history/2026/feature-foo-bar/todo.md now carries "Payment retry"; +# on a terminal the publication ask appears first — see below) ``` - The branch name is taken verbatim; git itself rejects invalid names. The branch is planted at the resolved base commit (`git update-ref --stdin`), then checked out (`git switch`) — a failed checkout rolls the planted branch back so the name never strands. diff --git a/docs/configuration/agents.md b/docs/configuration/agents.md index fa1ec906..05522f5a 100644 --- a/docs/configuration/agents.md +++ b/docs/configuration/agents.md @@ -51,6 +51,7 @@ Env variables are forwarded into the container through the standard env layering | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | no | Claude default | Override for the Haiku-class model slot. `goga init` suggests this when claude is the agent. | | `ANTHROPIC_DEFAULT_SONNET_MODEL` | no | Claude default | Override for the Sonnet-class model slot. Suggested by `goga init`. | | `ANTHROPIC_DEFAULT_OPUS_MODEL` | no | Claude default | Override for the Opus-class model slot. Suggested by `goga init`. | +| `ANTHROPIC_MODEL` | no | Claude default | Override for the main model slot. Suggested by `goga init`. | | `ANTHROPIC_BASE_URL` | no | Claude default | Base URL for an Anthropic-compatible gateway or proxy. Suggested by `goga init`. | ### codex diff --git a/docs/configuration/project.md b/docs/configuration/project.md index bdf84a97..8bd05f70 100644 --- a/docs/configuration/project.md +++ b/docs/configuration/project.md @@ -186,7 +186,7 @@ When `lint` is absent, `config.lint` is `None` and `goga lint` lints every direc ### topics -Optional section consumed by [`goga topics create`](../cli/topics.md). Read lazily — only when a value no CLI flag provided has to come from it. +Optional section consumed by [`goga topics create`](../cli/topics.md). Read lazily — only when a value that no CLI flag supplied has to come from it. | Field | Type | Required | Description | |-------|------|----------|-------------| diff --git a/docs/getting-started.md b/docs/getting-started.md index a1d93103..499b0c91 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -43,13 +43,13 @@ The wizard will prompt you for: 2. **Convention** -- Optionally download language-specific conventions from the goga-lang-conventions repository 3. **Codemanifest usages** -- Optional named practices (key-value pairs) for your project 4. **Codemanifest annotations** -- Optional free-text instructions for AI agents -5. **Agent** -- Confirm-gated (defaults to No). Decline to skip the build agent, or accept and choose `claude` or `codex` +5. **Agent** -- Confirm-gated (defaults to No). Decline to skip the build agent, or accept and choose an agent — `claude`, `codex`, `cursor`, `opencode`, or `qwen` 6. **Custom Dockerfile** -- Optionally create a custom Dockerfile (suggested path `.goga/Dockerfile`). This decision drives the next step: image semantics differ between the two branches. 7. **Docker image** (depends on step 6): - **If you create a Dockerfile**, the image is **built from it**, so you provide two values: the **base image** for the `FROM` line (chosen from the language-specific list), and a **built image name/tag** (what `goga build` tags with `docker build -t`). The built image name defaults to `<project-name>:latest`, where `<project-name>` is derived from your git `origin` remote URL; when no git remote is available, no default is offered and the name is required. - **If you skip the Dockerfile**, you pick a **pre-built image to pull** from the language-specific list (or enter a custom one). 8. **Environment variables** -- Set agent-specific env vars (e.g., `ANTHROPIC_API_KEY`) -9. **Pipeline agent** -- Confirm-gated (defaults to No). Decline to skip the pipeline agent, or accept and choose `claude` or `codex`. Does not inherit the build agent from step 5 — the two are collected independently +9. **Pipeline agent** -- Confirm-gated (defaults to No). Decline to skip the pipeline agent, or accept and choose an agent — `claude`, `codex`, `cursor`, `opencode`, or `qwen`. Does not inherit the build agent from step 5 — the two are collected independently 10. **Pipeline environment variables** -- Set env vars for the pipeline container (e.g., `ANTHROPIC_API_KEY`) ### What `goga init` creates diff --git a/docs/pipelines/shipped.md b/docs/pipelines/shipped.md index 5c6fac25..c1ae2885 100644 --- a/docs/pipelines/shipped.md +++ b/docs/pipelines/shipped.md @@ -79,7 +79,7 @@ Once installed, shipped pipelines behave like any other user pipeline. They are discoverable by `goga pipeline`, can be applied as-is, layered on with a [workflow](workflows.md), or shadowed by a same-named project pipeline (project source wins on name conflicts — see -[Discovery](pipeline-file.md#document-shape)). +[Discovery](index.md#discovery)). ## `refinement` @@ -95,8 +95,9 @@ records the settled technical decisions as a short ADR; `propose` formulates the structured task; `task-review` verifies it. The `define`, `discover`, `propose`, and `task-review` stages emit and consume documents under the current branch's history topic -(`.goga/history/<year>/<topic>/` — `prd.md`, `adr.md`, `task.md`, with -`<topic>` the kebab-case slug of the current git branch), and each later +(`.goga/history/<year>/<topic>/` — consuming `todo.md` and emitting +`prd.md`, `adr.md`, `task.md`, with `<topic>` the kebab-case slug of +the current git branch), and each later stage falls back to the earlier artifacts when they exist. ## `development` @@ -177,12 +178,12 @@ the user to specify the verification procedure. `commit-changes` commits the accumulated fixes. -All four review stages (`code-review`, `contracts-review`, -`documentation-review`, `testing`) carry shared constraints: do not -fabricate a finding priority when it is not obvious (set it as -`unknown`); do not run lint/format/tests outside the dedicated `testing` +The three finding stages (`code-review`, `contracts-review`, +`documentation-review`) carry shared constraints: do not fabricate a +finding priority when it is not obvious (set it as `unknown`); do not +run the project's lint/format/tests outside the dedicated `testing` stage; fix findings only after user confirmation. The `testing` stage -must not ignore any error. +instead fixes every error — it must not ignore any. ## `sync` diff --git a/docs/pipelines/workflows.md b/docs/pipelines/workflows.md index 5d2061ec..332a3837 100644 --- a/docs/pipelines/workflows.md +++ b/docs/pipelines/workflows.md @@ -43,9 +43,9 @@ stages: approve: auto # optional auto-approval directive: auto | plan | dialog notes: # optional note buttons compiled to the afm `buttons` field fix: Fix the failure and continue - reflect: # optional memory-reflection instruction (reflect method) + reflect: # optional memory-reflection instruction (reflect method only) file: shared.md - memory: true # optional memory participation (alignment method) + memory: true # optional memory participation (alignment method only; never together with reflect) memory: # optional workflow-memory configuration block method: reflect # reflect | alignment (the instruction vocabulary selector) @@ -384,10 +384,13 @@ stages: reflect: # reflect method: which file the stage reflects into file: shared.md mode: rw # r | w | rw, default rw - build: - memory: true # alignment method: the stage participates + accept: + reflect: + file: shared.md ``` +Under `method: alignment` the participating stages carry `memory: true` instead — see the key table below. + The top-level block accepts five keys: | Key | Type | Default | Description | diff --git a/docs/tools.md b/docs/tools.md index 1708a985..37208aa4 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -138,7 +138,7 @@ def register_published(context): `hooks.subscribe(domain, action, name, hook)` registers one hook: - `domain` + `action` — the action address: the semantic owner domain and the action name within it (`"statuses"` / `"register_statuses"` is the topic-status action). -- `name` — the hook name, unique per tool per address; registrations are shown as `<tool>.<name>`. +- `name` — the hook name, unique per tool per address; registrations appear in the [`goga hooks`](cli/hooks.md) tree under their tool line. - `hook` — the callable executed when the action fires. The tool identity is assigned by goga from the package name — a package never names itself, and identical hook names of different tools never collide. Enumeration is deterministic: packages in alphabetical order of top-level module name, subscriptions delivered in enumeration order. diff --git a/docs/workflow/apply.md b/docs/workflow/apply.md index cc2fa162..4cb7617c 100644 --- a/docs/workflow/apply.md +++ b/docs/workflow/apply.md @@ -8,6 +8,8 @@ Materialize an architecture plan into the cells file structure. Reads `.goga/his /goga:apply <topic> ``` +The architecture plan comes from the current git branch; the argument is an optional path to the plan file. + Examples use the slash-command form `/goga:<command>`, which works in agents that consume the goga command bundle (`claude`, `opencode`, `qwen`). Codex and cursor do not register commands — invoke the skill directly: `goga-apply` (Codex: `$goga-apply`). See [Workflow](index.md). ## Output artifacts @@ -19,6 +21,14 @@ For each cell in the plan: The skill does **not** write implementation code — only the contract skeleton. +## Pre-flight check + +```bash +goga --help +``` + +If `goga` is unavailable, the skill halts. + ## Algorithm ### Phase 1. Load DSL specification and principles @@ -33,7 +43,7 @@ The skill does **not** write implementation code — only the contract skeleton. | Step | Action | |---|---| -| 1. Locate the plan file | Use argument as path or `.goga/history/<year>/<topic>/arch.md`; if no argument, scan `.goga/history/*/` topic directories for `arch.md` (4-digit year) and list them via `AskUserQuestion`; halt if file missing. | +| 1. Locate the architecture file | See [Resolving the architecture file](#resolving-the-architecture-file); an argument containing a path wins. | | 2. Parse the plan structure | Extract implementation order, artifacts per cell, dependency map, verification checklist. | | 3. Classify cells | Mark each as **new** (directory does not exist) or **modification** (directory exists; read current CODEMANIFEST to compute diff). | @@ -74,22 +84,9 @@ Process cells **strictly in plan order** (leaves → root). For each cell: 2. **Dependency map** — confirmed inter-cell connections. 3. **Validation status** — linter and schema results. -## Resolving the topic +## Resolving the architecture file -If `<topic>` is omitted: - -1. Scan `.goga/history/*/` topic directories for `arch.md` (4-digit year). -2. **Single file** — use automatically. -3. **Multiple files** — ask the user via `AskUserQuestion`. -4. **Empty or missing** — halt and report. - -## Pre-flight check - -```bash -goga --help -``` - -If `goga` is unavailable, the skill halts. +The architecture plan is read from the path printed by `goga history path -f arch.md` (the topic of the current git branch). An argument containing a path wins over the printed path. If the file does not exist — halt and ask the user to run `brainstorm` first. ## Inputs and outputs diff --git a/docs/workflow/brainstorm.md b/docs/workflow/brainstorm.md index 07f200cc..2069923c 100644 --- a/docs/workflow/brainstorm.md +++ b/docs/workflow/brainstorm.md @@ -8,6 +8,8 @@ Design the cells architecture for a task through a structured, interactive pipel /goga:brainstorm <topic> ``` +The topic follows the current git branch; the argument is an optional free-form description or a path to `task.md`. + Examples use the slash-command form `/goga:<command>`, which works in agents that consume the goga command bundle (`claude`, `opencode`, `qwen`). Codex and cursor do not register commands — invoke the skill directly: `goga-brainstorm` (Codex: `$goga-brainstorm`). See [Workflow](index.md). ## Output artifact @@ -154,10 +156,10 @@ Applies to every interactive phase: ## Inputs and outputs -| | | -|------------|--------------------------------------------------| -| **Input** | `.goga/history/<year>/<topic>/task.md` (approved task) | -| **Output** | `.goga/history/<year>/<topic>/arch.md` — cells architecture plan | +| | | +|------------|----------------------------------------------------------------------------------| +| **Input** | `.goga/history/<year>/<topic>/task.md` (approved task) or a free-form description| +| **Output** | `.goga/history/<year>/<topic>/arch.md` — cells architecture plan | ## What happens next diff --git a/docs/workflow/build.md b/docs/workflow/build.md index 753d7c5f..987e95d6 100644 --- a/docs/workflow/build.md +++ b/docs/workflow/build.md @@ -19,7 +19,7 @@ Implemented code in the project tree, produced by the ralph-loop executing each | Step | Stage | Action | |---|---|---| | 1. Docker check | host | Verify Docker is installed and accessible. Halt on failure. | -| 2. Config loading | host | Read `.goga/config.yml` for `image`, `dockerfile`, `build.task_executor.agent`, env, and timeouts. Refuse to run when the `build` section is absent or the top-level `image` is unset. | +| 2. Config loading | host | Read `.goga/config.yml` for `image`, `dockerfile`, `build.task_executor.agent`, env, and timeouts. Refuse to run when the `build` section is absent, the top-level `image` is unset, or `build.task_executor.agent` is unset. | | 3. Home config + git identity layering | host | Load the optional machine-wide home config (`~/.goga/config.yml`). `home.env` is the BASE (lowest-priority) layer of the container env-file; `home.docker.run` is appended to every `docker run`; `home.docker.build` is forwarded to image build. Layer in git identity env (`GIT_AUTHOR_NAME/EMAIL`, `GIT_COMMITTER_NAME/EMAIL`) — tolerate absent git config. | | 4. Project preconditions | host → in-container | Resolve proxy (CLI `--proxy` wins over `config.build.proxy`); resolve hosts (CLI `--add-host` merges on top of `config.build.hosts`, CLI wins on conflict); when `--skip-manifest-check` is not set, scan `git status` for uncommitted `CODEMANIFEST` files and reject with exit 1 if any are found. | | 5. Agent preconditions | host → in-container | The in-container entrypoint resolves the agent wrapper via `resolve_wrapper_path(config.build.task_executor.agent)` and writes `.ralphex/config` per pass with `claude_command` set to the pass's wrapper path (`/home/goga/bin/<agent>-as-claude.sh`), `claude_args` defaults when missing, `codex_enabled` from `BuildConfig`, `preserve_anthropic_api_key: true`, and `move_plan_on_completion: false` (goga owns the plan relocation itself). A review executor whose agent differs from the task executor, or that declares a non-empty `env`, combined with an active worktree is rejected on the host with exit 1 before any container launch (skip-independent — `--skip-review` does not bypass it). | @@ -100,7 +100,7 @@ goga build .goga/history/<year>/json-export/plan.md # second run reuses .ralphe | Code | Meaning | |---|---| | `0` | Build completed successfully | -| `1` | Build failed (Docker not found, config error, `build` section missing, top-level `image` unset, uncommitted `CODEMANIFEST` files, invalid review configuration, two-pass review combined with worktree, a missing `ralphex` binary or a rejected ralphex launch, a fatal `docker build` under `--update`, a ralphex error, or a refused pre-launch version check — a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version; see [`goga build`](../cli/build.md)) | +| `1` | Build failed (Docker not found, config error, `build` section missing, top-level `image` unset, a missing `build.task_executor.agent`, uncommitted `CODEMANIFEST` files, invalid review configuration, two-pass review combined with worktree, a missing `ralphex` binary or a rejected ralphex launch, a fatal `docker build` under `--update`, a ralphex error, or a refused pre-launch version check — a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version; see [`goga build`](../cli/build.md)) | ## What happens next diff --git a/docs/workflow/define.md b/docs/workflow/define.md index 64a2d626..4c1f17bc 100644 --- a/docs/workflow/define.md +++ b/docs/workflow/define.md @@ -42,7 +42,7 @@ Each stage declares what it consumes and produces: ## Conflicts -Any stage may report a conflict with an earlier decision. Conflicts are resolved before the pipeline continues: a resolver reconsiders the decisions across the entire context, and if an earlier decision changes, the earliest affected stage is re-run from that point. A PRD is never generated while unresolved conflicts remain. +Any stage may report a conflict with an earlier decision. Conflicts are resolved before the pipeline continues: a resolver reconsiders the decisions across the entire context, and if an earlier decision changes, the earliest affected stage is re-run from that point. A PRD is never generated while unresolved conflicts remain. A subskill that fails to produce its declared output is never fabricated or silently skipped — it is retried only when safe, otherwise the workflow stops and reports the failed stage. ## When to use diff --git a/docs/workflow/design.md b/docs/workflow/design.md index 1cfbbbc0..3f105e9b 100644 --- a/docs/workflow/design.md +++ b/docs/workflow/design.md @@ -5,9 +5,11 @@ Produce a detailed design document based on CODEMANIFEST changes introduced by ` ## Synopsis ```text -/goga:design <function-name> +/goga:design <topic> ``` +The topic follows the current git branch; the argument is an optional free-form description. + Examples use the slash-command form `/goga:<command>`, which works in agents that consume the goga command bundle (`claude`, `opencode`, `qwen`). Codex and cursor do not register commands — invoke the skill directly: `goga-design` (Codex: `$goga-design`). See [Workflow](index.md). ## Output artifact @@ -18,6 +20,7 @@ Examples use the slash-command form `/goga:<command>`, which works in agents tha - Applied CODEMANIFEST fixes - Entity interactions and data flows (diagrams) - Code Stack Trace (verified logical chains per entry point) +- Algorithm design (per entity: responsibility, algorithm, errors, edge cases) - Cross-cutting concerns (error handling, validation, logging, caching, concurrency) - Usages analysis (per practice: what/where/why/how) - `.usages/` updates (per cell) @@ -74,15 +77,6 @@ The document does **not** contain implementation code and does **not** produce a | 1. Write from template | Use the design-doc template. | | 2. Save | Path: `.goga/history/<year>/<topic>/design.md`. Create directory if missing; overwrite if exists. | -## Resolving the function name - -If `<function-name>` is omitted: - -1. Scan `.goga/history/*/` topic directories for `design.md` (4-digit year). -2. **Single file** — use automatically. -3. **Multiple files** — ask the user. -4. **Empty or missing** — halt and ask the user to run `design` first. - ## Inputs and outputs | | | diff --git a/docs/workflow/discover.md b/docs/workflow/discover.md index f563dfff..8eeced9a 100644 --- a/docs/workflow/discover.md +++ b/docs/workflow/discover.md @@ -12,7 +12,7 @@ Examples use the slash-command form `/goga:<command>`, which works in agents tha ## Output artifact -`.goga/history/<year>/<topic>/adr.md` — a short ADR (slug name, lowercase kebab-case; the directory is created lazily if needed). An ADR is 1–3 sentences: the context, the decision, and why. Optional sections (`Status`, `Considered Options`, `Consequences`) are included only when they add genuine value. +`.goga/history/<year>/<topic>/adr.md` — a short ADR (the topic comes from the current git branch; the directory is created lazily if needed). An ADR is 1–3 sentences: the context, the decision, and why. Optional sections (`Status`, `Considered Options`, `Consequences`) are included only when they add genuine value. ## Algorithm @@ -49,6 +49,8 @@ The context only grows — entries are appended, never overwritten. After each r The ADR's "why" only makes sense if the vocabulary it uses is sharp — this is why term discipline runs throughout the interview, not as a final pass. +Questions drifting into contracts — signatures, cell boundaries, wiring — are not answered: they are recorded in the ADR as unresolved and the interview moves on. + ### Completion The interview is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. The ADR is written only after the user confirms shared understanding. diff --git a/docs/workflow/index.md b/docs/workflow/index.md index 96d26aa9..bf82e373 100644 --- a/docs/workflow/index.md +++ b/docs/workflow/index.md @@ -93,8 +93,8 @@ define → discover → propose → review(task) | [`define`](define.md) | Refinement | Product idea | `.goga/history/<year>/<topic>/prd.md` (PRD) | | [`discover`](discover.md) | Refinement | A decision worth recording | `.goga/history/<year>/<topic>/adr.md` (short ADR) | | [`propose`](propose.md) | Refinement | User request text | `.goga/history/<year>/<topic>/task.md` | -| [`review`](review.md) | both | Any artifact in `.goga/history/` (or a cell) | Review report | -| [`brainstorm`](brainstorm.md) | Development | `.goga/history/<year>/<topic>/task.md` | `.goga/history/<year>/<topic>/arch.md` | +| [`review`](review.md) | both | Any reviewable artifact in `.goga/history/` — task, arch, design, plan — (or a cell) | Review report | +| [`brainstorm`](brainstorm.md) | Development | `.goga/history/<year>/<topic>/task.md` (or a raw description) | `.goga/history/<year>/<topic>/arch.md` | | [`apply`](apply.md) | Development | `.goga/history/<year>/<topic>/arch.md` | Cell file structure (CODEMANIFEST, `.usages/`) | | [`design`](design.md) | Development | Modified CODEMANIFEST | `.goga/history/<year>/<topic>/design.md` | | [`plan`](plan.md) | Development | `.goga/history/<year>/<topic>/design.md` | `.goga/history/<year>/<topic>/plan.md` | @@ -102,7 +102,7 @@ define → discover → propose → review(task) | [`change`](change.md) | Development | Change description | Modified code + reconciled contracts and usages | | [`accept`](accept.md) | Development | Completed implementation | Final acceptance report | -Workflow artifacts live at `.goga/history/<year>/<topic>/<kind>.md` (`<kind>` ∈ `prd | adr | task | arch | design | plan`): `<year>` is the current year as `YYYY`, and `<topic>` is a lowercase kebab-case slug — non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed (branch `release/1.3.0` → `release-1-3-0`). The topic directory is created lazily by the stage that first writes into it, and the whole `.goga/history/` tree is git-ignored by default. `goga pipeline <name> -t <topic>` switches onto the branch hosting an existing topic before a run, or creates both a fresh branch and its fresh topic when nothing hosts it; `goga topics create <branch>` prepares both directly. +Workflow artifacts live at `.goga/history/<year>/<topic>/<kind>.md` (`<kind>` ∈ `todo | prd | adr | task | arch | design | plan`): `<year>` is the current year as `YYYY`, and `<topic>` is a lowercase kebab-case slug — non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed (branch `release/1.3.0` → `release-1-3-0`). The topic directory is created lazily by the stage that first writes into it, and the whole `.goga/history/` tree is meant to stay out of git — add it to your `.gitignore`. `goga pipeline <name> -t <topic>` switches onto the branch hosting an existing topic before a run, or creates both a fresh branch and its fresh topic when nothing hosts it; `goga topics create <branch>` prepares both directly. ## Next steps diff --git a/docs/workflow/plan.md b/docs/workflow/plan.md index 714ac83a..4a2ac4ff 100644 --- a/docs/workflow/plan.md +++ b/docs/workflow/plan.md @@ -5,9 +5,11 @@ Compile a design document into a ralph-loop-compatible execution plan. The plan ## Synopsis ```text -/goga:plan <function-name> +/goga:plan <topic> ``` +The topic follows the current git branch; the argument is an optional free-form description. + Examples use the slash-command form `/goga:<command>`, which works in agents that consume the goga command bundle (`claude`, `opencode`, `qwen`). Codex and cursor do not register commands — invoke the skill directly: `goga-plan` (Codex: `$goga-plan`). See [Workflow](index.md). ## Output artifact @@ -38,7 +40,7 @@ Only ONE task is executed per ralph-loop iteration. | Step | Action | |---|---| -| 1. Extract data from design | Contract changes → task scope; applied fixes → context; entity interactions → diagrams; code stack traces → verified chains; algorithm design → checkboxes; cross-cutting concerns → distributed across tasks; usages analysis → task context; `.usages/` updates → tasks; test stack traces → test instructions. Transfer traces and diagrams **verbatim**, do not summarize. | +| 1. Extract data from design | Contract changes → task scope; applied fixes → context; entity interactions → diagrams; code stack traces → verified chains; algorithm design → checkboxes; cross-cutting concerns → distributed across tasks; usages analysis → task context; `.usages/` updates → tasks; test stack traces → test instructions; additional instructions → task context. Transfer traces and diagrams **verbatim**, do not summarize. | | 2. Compile into ralph-loop tasks | For each entity: create tasks following DSL compilation rules, cell boundaries, ralphex plan-format requirements, task ordering, TDD workflow, task formation rules, templates, and project conventions. | | 3. Save the plan | Write to `.goga/history/<year>/<topic>/plan.md`. Create directory if missing. | @@ -115,14 +117,9 @@ After completion: `→ REVIEW → APPROVAL → NEXT_TASK`. **Prohibited:** creating new cells, defining new cell-level interfaces outside the current one, replacing contract entities with internal-only abstractions, ignoring `location`, modifying `CODEMANIFEST` files (read-only). -## Resolving the function name - -If `<function-name>` is omitted: +## Resolving the design document -1. Scan `.goga/history/*/` topic directories for `design.md` (4-digit year). -2. **Single file** — use automatically. -3. **Multiple files** — ask the user. -4. **Empty or missing** — halt and ask the user to run `design` first. +The design document is read from the path printed by `goga history path -f design.md` (the topic of the current git branch). If the file does not exist — halt and ask the user to run `design` first. ## Inputs and outputs diff --git a/docs/workflow/review.md b/docs/workflow/review.md index 8caa615c..f7c101de 100644 --- a/docs/workflow/review.md +++ b/docs/workflow/review.md @@ -122,8 +122,8 @@ Reviews a cell across three dimensions and proposes remediation per dimension. |---|---| | 1. Load context | Load `goga-lang-disp`, `goga-cell`, `goga-cookbook`; read CODEMANIFEST and source files; enumerate cell files; read `.usages/*.md`; verify facade. | | 2. Run tools | `goga lint` (fix syntax before analysis), `goga schema` (for context). | -| 3. Analysis 1 — Code vs Requirements | Compare signatures, methods, properties, location validity, behavioral conformance, import utilization. Proposed action: `goga-design` (`/goga:design`) in **brainstorm** mode. | -| 4. Analysis 2 — Requirements vs Code/Usages | Find undocumented entities, inaccurate descriptions, poor annotation authoring (purpose statement, parameter descriptions, `Algorithm:`/`Requirements:` sections). Proposed action: edit CODEMANIFEST, then `goga-design` (`/goga:design`) in **changes** mode. | +| 3. Analysis 1 — Code vs Requirements | Compare signatures, methods, properties, location validity, behavioral conformance, import utilization. Proposed action: run `goga-design` (`/goga:design`). | +| 4. Analysis 2 — Requirements vs Code/Usages | Find undocumented entities, inaccurate descriptions, poor annotation authoring (purpose statement, parameter descriptions, `Algorithm:`/`Requirements:` sections). Proposed action: edit CODEMANIFEST, then run `goga-design` (`/goga:design`). | | 5. Analysis 3 — Usages | Verify existence, annotation references, adequacy, categorization. Proposed action: create or update `.usages/*.md` directly. | | 6. Execute approved actions | For each approved action, execute the proposed remediation; run `goga lint` after all actions and fix any errors. | diff --git a/goga/assets/skills/goga-apply/SKILL.md b/goga/assets/skills/goga-apply/SKILL.md index bb8a92ed..5010d162 100644 --- a/goga/assets/skills/goga-apply/SKILL.md +++ b/goga/assets/skills/goga-apply/SKILL.md @@ -12,14 +12,6 @@ The command invokes the skill: Arguments: $ARGUMENTS -Retain the original arguments for the duration of the session. - -### Resolving the architecture file - -Check if the path printed by `goga history path -f arch.md`: - -- **Does not exist** — stop and ask the user to run `/goga:brainstorm` first. - ## Pre-flight check: goga availability Before proceeding, verify tool availability: @@ -30,11 +22,18 @@ goga --help If the command is unavailable — halt and notify the user. +### Resolving the architecture file + +Check if the path printed by `goga history path -f arch.md` exists: + +- **Does not exist** — stop and ask the user to run `/goga:brainstorm` first. +- **Exists** — proceed to Materialization. + --- ## Materialization -Use the **Skill tool** to invoke `goga-cells-by-brainstorm` with `<topic>` as the argument. +Use the **Skill tool** to invoke `goga-cells-by-brainstorm` with the printed path as the argument. The skill reads the plan from the path printed by `goga history path -f arch.md` and materializes it into a cells file structure (CODEMANIFEST, `.usages/`). diff --git a/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md b/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md index 984397c8..1883500b 100644 --- a/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md +++ b/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md @@ -14,9 +14,9 @@ files for each cell — and writing it to disk. Use these reports for its specific purpose: -- **`[CELL_ASSEMBLY_REPORT]`** — use it for the assembled per-cell **CODEMANIFESTs and `.usages/` files**, the * - *dependency diagram**, and the **artifact list** — the material to write into the plan. -- **`[PRIMARY_ANALYSIS_REPORT]`** — use its **Topic** for the plan file name, its **Existing Cells & Schema** for the +- **`[CELL_ASSEMBLY_REPORT]`** — use it for the assembled per-cell **CODEMANIFESTs and `.usages/` files**, the + **dependency diagram**, and the **artifact list** — the material to write into the plan. +- **`[PRIMARY_ANALYSIS_REPORT]`** — use its **Topic** as the plan's short name, its **Existing Cells & Schema** for the project structure (file names and paths), and its **Artifact Resolution** to mark each cell as modified vs created anew. @@ -24,7 +24,7 @@ Use these reports for its specific purpose: ### Phase 1. Determine the topic -Resolve the topic directory — the path printed by `goga history path` (the topic comes from the current git branch). +Resolve the topic directory — the path printed by `goga history path` (the current topic in the history tree). Keep the **Topic** section of the `[PRIMARY_ANALYSIS_REPORT]` as the plan's short name. ### Phase 2. Assemble the plan structure diff --git a/goga/assets/skills/goga-brainstorm-primary-analysis/SKILL.md b/goga/assets/skills/goga-brainstorm-primary-analysis/SKILL.md index edbf7733..4633a31a 100644 --- a/goga/assets/skills/goga-brainstorm-primary-analysis/SKILL.md +++ b/goga/assets/skills/goga-brainstorm-primary-analysis/SKILL.md @@ -33,8 +33,8 @@ From the description and gathered facts, determine. The Description Type from th Task file) sets the expected depth — Brief input yields more dark zones; Detailed/Task-file input yields more constraints and acceptance criteria. -- **Topic** — a short topic name derived from the `[INTAKE_REPORT]` task summary (used by plan-assembly for - the `arch.md` path printed by `goga history path -f arch.md`) +- **Topic** — a short topic name derived from the `[INTAKE_REPORT]` task summary (used by plan-assembly as the plan's + short name) - **Acceptance criteria** — if task-file input, folded verbatim/condensed from the `[INTAKE_REPORT]` "Acceptance Criteria" section; otherwise N/A - **Stack & external dependencies** — if task-file input, folded from the `[INTAKE_REPORT]` "Stack and Dependencies" @@ -75,7 +75,7 @@ Fill every section. No empty sections. # [PRIMARY_ANALYSIS_REPORT] ## Topic -[Short topic name derived from the [INTAKE_REPORT] task summary. +[Short topic name derived from the [INTAKE_REPORT] task summary.] ## Acceptance Criteria [If task-file input: verbatim/condensed list from the [INTAKE_REPORT] "Acceptance Criteria" section. Otherwise: N/A. diff --git a/goga/assets/skills/goga-brainstorm/SKILL.md b/goga/assets/skills/goga-brainstorm/SKILL.md index 5d9d8738..ce241bb3 100644 --- a/goga/assets/skills/goga-brainstorm/SKILL.md +++ b/goga/assets/skills/goga-brainstorm/SKILL.md @@ -21,7 +21,7 @@ Arguments: $ARGUMENTS Retain the original arguments for the duration of the session. -## Pre-check: goga Availability +## Pre-check: goga availability Before starting work, execute: @@ -42,7 +42,7 @@ Load these skills via the **Skill tool** before starting the pipeline. Actively use these skills during design and analysis. Proceed to Pipeline Phase 1. -## Requirements: +## Requirements - [DESIGN PRINCIPLE]: First design types and their interactions **without cell boundaries**, then group types into cells. @@ -74,7 +74,7 @@ Actively use these skills during design and analysis. Proceed to Pipeline Phase ## Dialogue Protocol -Applies to every interactive sub-skill (Phases 3-8). Enforce throughout: +Applies to every interactive sub-skill (Phases 3-10). Enforce throughout: 1. **Do not read implementation source code.** Design is conducted at the level of CODEMANIFEST, project schema, and practices. diff --git a/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md b/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md index 92f4ab7b..8c80f749 100644 --- a/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md +++ b/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md @@ -6,7 +6,7 @@ description: Creation and modification of cells by architecture plan ## Purpose -Creates new cells and modifies existing cells based on the architecture plan defined in the path printed by `goga history path -f arch.md`. Materializes the plan into the cell file structure: +Creates new cells and modifies existing cells based on the architecture plan (the file at the path printed by `goga history path -f arch.md`). Materializes the plan into the cell file structure: CODEMANIFEST, `.usages/`. --- diff --git a/goga/assets/skills/goga-define-prd/SKILL.md b/goga/assets/skills/goga-define-prd/SKILL.md index bd7eb60a..00b0b04f 100644 --- a/goga/assets/skills/goga-define-prd/SKILL.md +++ b/goga/assets/skills/goga-define-prd/SKILL.md @@ -1,6 +1,6 @@ --- name: goga-define-prd -description: +description: Generate the final PRD from the validated product definition --- # goga-define-prd @@ -170,12 +170,12 @@ Include, where relevant: - primary flow; - meaningful alternative flows; - important states; -- failure behaviour; +- failure behavior; - recovery; - user-visible feedback; - consequences of important actions. -The section should be detailed enough that engineering can understand the intended experience without inventing product behaviour. +The section should be detailed enough that engineering can understand the intended experience without inventing product behavior. --- @@ -185,12 +185,12 @@ Present the product requirements in a clear and structured form. Requirements should describe: -- required product behaviour; +- required product behavior; - conditions; - business rules; - permissions; - important states; -- failure behaviour; +- failure behavior; - required user-visible information. Do not add implementation details. @@ -290,7 +290,7 @@ The document should be precise enough to support engineering design while remain ## Technical Details -Technical details are allowed only when they are necessary to describe product behaviour. +Technical details are allowed only when they are necessary to describe product behavior. For example: @@ -319,7 +319,7 @@ Perform a final internal check before producing the document. Verify: - Every goal is supported by the user experience. -- Every important user experience behaviour is represented in requirements. +- Every important user experience behavior is represented in requirements. - Requirements respect constraints. - Scope contains everything necessary to solve the problem. - Success criteria demonstrate the intended outcome. diff --git a/goga/assets/skills/goga-define/SKILL.md b/goga/assets/skills/goga-define/SKILL.md index 0d168ab3..765ac746 100644 --- a/goga/assets/skills/goga-define/SKILL.md +++ b/goga/assets/skills/goga-define/SKILL.md @@ -1,6 +1,6 @@ --- name: goga-define -description: +description: Orchestrate the product definition pipeline and generate the PRD --- # goga-define diff --git a/goga/assets/skills/goga-design-by-changes/SKILL.md b/goga/assets/skills/goga-design-by-changes/SKILL.md index 4656eec1..b739d1e5 100644 --- a/goga/assets/skills/goga-design-by-changes/SKILL.md +++ b/goga/assets/skills/goga-design-by-changes/SKILL.md @@ -328,7 +328,6 @@ Write results to a file using the template from `design-doc-template.md`. Path: the path printed by `goga history path -f design.md`. -- Prompt for the feature name if not obvious - Run `goga history ensure` first if the topic directory does not exist - Overwrite if the file already exists diff --git a/goga/assets/skills/goga-design-by-changes/design-doc-template.md b/goga/assets/skills/goga-design-by-changes/design-doc-template.md index 7485cd1e..f4b9f1f8 100644 --- a/goga/assets/skills/goga-design-by-changes/design-doc-template.md +++ b/goga/assets/skills/goga-design-by-changes/design-doc-template.md @@ -8,6 +8,8 @@ This is a **complete architectural specification** — every detail fully elabor # Design Document: `<topic>` +<!-- `<topic>` — the topic name (the topic directory under `.goga/history/<year>/<topic>/`) --> + ## Contract Changes ### Changed CODEMANIFEST Files diff --git a/goga/assets/skills/goga-discover/SKILL.md b/goga/assets/skills/goga-discover/SKILL.md index 8575d3c1..6e7890c2 100644 --- a/goga/assets/skills/goga-discover/SKILL.md +++ b/goga/assets/skills/goga-discover/SKILL.md @@ -43,7 +43,7 @@ To understand the architectural diagram of the project, use: goga schema ``` -To understand the json of diagram, use `goga schema --help`. +For the JSON structure of the diagram, see `goga schema --help`. ## Context structure diff --git a/goga/assets/skills/goga-plan-by-design/SKILL.md b/goga/assets/skills/goga-plan-by-design/SKILL.md index 62a11657..e98e2b8f 100644 --- a/goga/assets/skills/goga-plan-by-design/SKILL.md +++ b/goga/assets/skills/goga-plan-by-design/SKILL.md @@ -103,7 +103,7 @@ Use the `goga-cell` skill for correct interpretation of DSL elements during comp Write the plan to the path printed by `goga history path -f plan.md`, using the template from `output-template.md`. -The topic (branch) name should reflect the plan's scope, not the Cell name. +The topic is the current one in the history tree; name it to reflect the plan's scope, not the Cell name. Run `goga history ensure` first if the topic directory does not exist. --- diff --git a/goga/assets/skills/goga-plan-by-design/output-template.md b/goga/assets/skills/goga-plan-by-design/output-template.md index 0c1950ed..71db6d40 100644 --- a/goga/assets/skills/goga-plan-by-design/output-template.md +++ b/goga/assets/skills/goga-plan-by-design/output-template.md @@ -8,6 +8,8 @@ This format is compatible with ralphex execution. # Plan: `<topic>` +<!-- `<topic>` — the topic name (the topic directory under `.goga/history/<year>/<topic>/`) --> + ## Purpose A brief statement of what will be implemented or changed. diff --git a/goga/assets/skills/goga-plan/SKILL.md b/goga/assets/skills/goga-plan/SKILL.md index 06010090..cb390198 100644 --- a/goga/assets/skills/goga-plan/SKILL.md +++ b/goga/assets/skills/goga-plan/SKILL.md @@ -8,10 +8,8 @@ You are a technical planner specializing in contract-oriented implementation. Yo Arguments: $ARGUMENTS -Retain the original arguments for the entire session. - ### Design document identification -Check if the path printed by `goga history path -f design.md`: +Check if the path printed by `goga history path -f design.md` exists: - **Does not exist** — stop and ask the user to run `/goga:design` first. - **Exists** — use the **Skill tool** to invoke `goga-plan-by-design` with the printed path as the argument. diff --git a/goga/assets/skills/goga-review-arch/SKILL.md b/goga/assets/skills/goga-review-arch/SKILL.md index 709299b7..2508d055 100644 --- a/goga/assets/skills/goga-review-arch/SKILL.md +++ b/goga/assets/skills/goga-review-arch/SKILL.md @@ -49,7 +49,7 @@ all types, domains, and requirements as a unified whole — not each CODEMANIFES - Classify plan cells: newly created vs. modified 6. Read the existing CODEMANIFESTs of cells the plan marks for modification 7. Read the existing `.usages/` files of cells marked for modification -8. If the task file at the path printed by `goga history path -f task.md` — read it for subsequent requirements coverage verification +8. If the task file at the path printed by `goga history path -f task.md` exists — read it for subsequent requirements coverage verification --- @@ -262,7 +262,7 @@ Modification breaks existing contracts — log as **Critical**. #### Step 2. Impact on Dependent Cells - Determine whether changes affect cells not mentioned in the plan but dependent on modified cells -- Run `--depends-on <cell_path>` to locate dependent cells +- Run `goga schema --depends-on <cell_path>` to locate dependent cells Unacknowledged affected cells — log as **High**. diff --git a/goga/assets/skills/goga-review/SKILL.md b/goga/assets/skills/goga-review/SKILL.md index 11ac73ce..02487ddb 100644 --- a/goga/assets/skills/goga-review/SKILL.md +++ b/goga/assets/skills/goga-review/SKILL.md @@ -38,8 +38,6 @@ Arguments: $ARGUMENTS ### Type-Based Routing -**prd** and **adr** have no dedicated review skills — there is nothing to route them to yet. - #### prd / adr There is no review skill for this artifact kind yet. 1. Stop execution and report to the user that PRD/ADR review is not supported. diff --git a/goga/assets/skills/goga-task-by-proposing/SKILL.md b/goga/assets/skills/goga-task-by-proposing/SKILL.md index fdbfa2ce..f28b6cd9 100644 --- a/goga/assets/skills/goga-task-by-proposing/SKILL.md +++ b/goga/assets/skills/goga-task-by-proposing/SKILL.md @@ -167,7 +167,7 @@ If all external dependencies are covered by current usage files, skip this phase **Objective:** Save the formulated task to the path printed by `goga history path -f task.md`, using the template (run `goga history ensure` first if the topic directory does not exist). -The topic directory is resolved by `goga history path` from the current git branch. +The topic directory is the current one in the history tree, resolved by `goga history path`. 1. Read the `task-template.md` template from the current skill directory and apply its structure. From 8209ff4b1ca0b7132788c40e644bf5b3c0612b3e Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 4 Sep 2026 10:24:21 +0000 Subject: [PATCH 222/229] feat(topics)!: plant created branches without switching by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit goga topics create now leaves the caller on their branch by default: the fresh branch is planted at one quarantined commit carrying todo.md on top of the resolved base, while the working copy, the index, and HEAD stay untouched. The todo is required on this path — the work exists only through its committed todo.md. The previous behavior — checking out the fresh branch with its topic directory and an uncommitted todo — moves behind the new --switch/-s flag, where the todo is optional. --switch together with --publish is a clean error; the publication path and the publication ask are unchanged. The quarantine plant lives in publishing (_plant_topic_branch) and is shared with publish_topic. CODEMANIFESTs, usages, docs, and README are updated to the new semantics; tests cover both paths, the guards, and real-git branch behavior. BREAKING CHANGE: goga topics create no longer checks out the created branch and no longer creates the topic directory in the working copy by default; pass --switch/-s for the previous behavior, and the todo is now required without --switch or --publish. --- README.md | 10 +- docs/cli/pipeline.md | 2 +- docs/cli/topics.md | 28 ++- .../commands/topics/.usages/topics-command.md | 20 +- goga/commands/topics/CODEMANIFEST | 32 ++- goga/commands/topics/topics.py | 56 +++-- goga/topics/.usages/creating.md | 34 +-- goga/topics/CODEMANIFEST | 74 ++++--- goga/topics/creation.py | 168 +++++++++----- goga/topics/publishing.py | 73 ++++-- tests/commands/topics/test_topics_command.py | 84 +++++-- tests/integration/test_topic_workflows.py | 68 ++++-- tests/topics/test_creation.py | 208 +++++++++++++++--- 13 files changed, 625 insertions(+), 232 deletions(-) diff --git a/README.md b/README.md index 048ec7a6..cb9ee4a5 100644 --- a/README.md +++ b/README.md @@ -144,17 +144,17 @@ Work is organized as **topics** — one directory per piece of work under `.goga goga topics board # the board: every topic of the year across branches goga topics board --remote # same board over remote-tracking refs goga topics board --info # the board with the todo column (the todo summary of todo.md) -goga topics create feat/x --from-current # fresh work off the current HEAD: the branch verbatim + its topic directory -goga topics create feat/x -t "Payment retry" # same (--from-current implied), and writes todo.md (status: todo) -goga topics create feat/x # on a terminal, same and the todo entry opens in your $EDITOR -goga topics create feat/x -p -t "Payment retry" # same, committed + pushed to origin, no switch +goga topics create feat/x --from-current # fresh work off the current HEAD: the branch verbatim + its topic committed, you stay on your branch +goga topics create feat/x -t "Payment retry" # same (--from-current implied); the todo becomes the branch's todo.md commit (status: todo) +goga topics create feat/x -s # same, but switch to the fresh branch; on a terminal the todo entry opens in your $EDITOR +goga topics create feat/x -p -t "Payment retry" # same as the default, plus pushed to origin goga topics switch feat-x # onto the branch hosting that work (branch, slug, or prefix) goga topics switch feat-x --todo # same, then edit the topic's todo.md in your $EDITOR goga topics delete feat-x # delete the branch, its origin twin, and the directory goga topics --year 2025 board # the board of an explicit year ``` -Every `create` needs a base: `--base-ref`, or `topics.base_ref` in `.goga/config.yml`, or the current HEAD under `--from-current`. With no `-t` given a terminal opens the external editor for the todo (an empty or unchanged file cancels the todo — the work is created without one); once a todo is resolved, the command asks on a terminal whether to publish. `--publish`/`-p` is the fast mode: it builds the branch off the resolved base with a single `todo.md` commit and pushes it to `origin` without switching — your working copy, index, and HEAD stay untouched, and a failed push rolls the branch back. See [`goga topics`](https://qarium.github.io/goga/cli/topics/). +Every `create` needs a base: `--base-ref`, or `topics.base_ref` in `.goga/config.yml`, or the current HEAD under `--from-current`. The default creation quarantines the topic into the branch — one commit carrying the topic's `todo.md` on top of the base — while you stay on your branch; the todo is required there, so with no `-t` given a terminal opens the external editor for the todo, and once a todo is resolved the command asks on a terminal whether to publish. `-s`/`--switch` checks out the fresh branch instead — the topic directory and `todo.md` land in the working copy uncommitted, and the todo is optional. `--publish`/`-p` is the fast mode: it builds the branch off the resolved base with a single `todo.md` commit and pushes it to `origin` without switching — your working copy, index, and HEAD stay untouched, and a failed push rolls the branch back. See [`goga topics`](https://qarium.github.io/goga/cli/topics/). The board is a three-column table — topic, branch, statuses, plus a todo column under `--info` — with `*` marking the current branch and a local branch absorbing its remote twin. Each topic carries its **maximal statuses** in scale order: `empty → todo → defined → discovered → backlog → designed → specified → planned → done`, deepening as `todo.md`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. A topic can carry several statuses at once (`goga history status` prints them; `-s` filters by any of them). diff --git a/docs/cli/pipeline.md b/docs/cli/pipeline.md index fc8e0612..24acd9fe 100644 --- a/docs/cli/pipeline.md +++ b/docs/cli/pipeline.md @@ -111,7 +111,7 @@ The outcome: - already on the hosting branch → idempotent success, nothing is touched and the working tree is not even probed; - a local host → `git switch <branch>`; - a remote-only host → the local branch is created from the remote-tracking ref (`git switch -c <branch> <remote>/<branch>`); -- nothing hosts the identifier → the branch is created as entered from the current HEAD and the topic directory of the year appears (uncommitted changes carry onto the fresh branch; `goga topics create` instead plants the branch at an explicit or configured base). +- nothing hosts the identifier → the branch is created as entered from the current HEAD and the topic directory of the year appears (uncommitted changes carry onto the fresh branch; `goga topics create` instead plants the branch at an explicit or configured base and, by default, leaves you on your branch). A switch that would mutate checks the working tree first: a dirty tree exits 1 with `working tree is dirty — commit or stash before switching` before anything is touched. Every git action happens on the host, after every form check and before any docker activity. The single result line (`Switched to branch <name>`, `Created branch <name> from <remote>/<name>`, `Already on branch <name>`, or `Created branch <name> and topic <year>/<slug>`) is echoed to stdout once, before the launch. The branch name is never forwarded into the container — the container sees the branch through the mounted project, and goga does not switch back after the launch. diff --git a/docs/cli/topics.md b/docs/cli/topics.md index 003284f0..517fac6f 100644 --- a/docs/cli/topics.md +++ b/docs/cli/topics.md @@ -8,7 +8,7 @@ Work with the topics of one year — the cross-branch inventory, fresh-work crea ```bash goga topics [--year YYYY] board [--remote] [--info] -goga topics [--year YYYY] create BRANCH_NAME [--todo TEXT] [--publish] [--base-ref REF] [--from-current] [--commit TEMPLATE] +goga topics [--year YYYY] create BRANCH_NAME [--todo TEXT] [--switch] [--publish] [--base-ref REF] [--from-current] [--commit TEMPLATE] goga topics [--year YYYY] switch IDENTIFIER [--todo] goga topics [--year YYYY] delete IDENTIFIER... [--yes] ``` @@ -40,22 +40,26 @@ The statuses are the topic's **maximal present statuses** in scale order — `em ## `goga topics create` -Creates fresh work — a branch named exactly as entered, planted at a base commit and checked out, plus the topic directory of the scoped year: +Creates fresh work — a branch named exactly as entered, planted at a base commit with the topic of the scoped year committed on it, while you stay on your branch: ```bash -goga topics create Feature/Foo_Bar --from-current +goga topics create Feature/Foo_Bar --from-current --todo "Payment retry" # Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar +# (one commit on the branch carries .goga/history/2026/feature-foo-bar/todo.md; +# the working copy, the index, and HEAD stay untouched — no switch) -goga topics create Feature/Foo_Bar --base-ref origin/main --todo "Payment retry" +goga topics create Feature/Foo_Bar --base-ref origin/main --switch # Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar -# (.goga/history/2026/feature-foo-bar/todo.md now carries "Payment retry"; -# on a terminal the publication ask appears first — see below) +# (the branch is checked out; .goga/history/2026/feature-foo-bar/ now exists +# in the working copy; on a terminal the publication ask appears first) ``` -- The branch name is taken verbatim; git itself rejects invalid names. The branch is planted at the resolved base commit (`git update-ref --stdin`), then checked out (`git switch`) — a failed checkout rolls the planted branch back so the name never strands. +- The branch name is taken verbatim; git itself rejects invalid names. The default path builds one quarantined commit carrying the topic's `todo.md` on top of the resolved base commit — git plumbing that never touches the working copy, so a dirty tree and a detached HEAD do not interfere — and plants the branch at it (`git update-ref --stdin`); no switch happens, and `goga topics switch <name>` brings you onto the work later. `-s`/`--switch` plants the branch at the base and checks it out instead (`git switch`) — a failed checkout rolls the planted branch back so the name never strands — with the topic directory created in the working copy, uncommitted. - The base resolves as `--base-ref` > `topics.base_ref` in `.goga/config.yml` > the current HEAD under `--from-current` > clean error. With nothing set, exit 1 with a message naming the flag, the flag alternative, and the configuration line, including a two-line YAML example (see [Project Configuration](../configuration/project.md#topics)). -- The topic directory is `.goga/history/<YYYY>/<slug>/`, where the slug is the normalized name (lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed: `Feature/Foo_Bar` → `feature-foo-bar`). No artifact file is written unless a todo resolves. +- The topic directory is `.goga/history/<YYYY>/<slug>/`, where the slug is the normalized name (lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed: `Feature/Foo_Bar` → `feature-foo-bar`). The default path carries the directory as the committed `todo.md`; `--switch` creates it on disk. No artifact file is written unless a todo resolves. - `-t`/`--todo` takes the todo value on the command line — the multi-line text as entered plus one trailing newline, UTF-8 — which marks the topic `todo` on the status scale and feeds the `--info` column of the board. An empty value — `--todo ""`, `--todo=`, `-t ""` — counts as absent: no `todo.md` is ever created empty. +- The todo is **required** on the default path — git keeps no empty directories, so the work exists only through its committed `todo.md`. A cancelled editor entry or a missing todo exits 1 with `the local creation needs a todo — the board reads the topic through todo.md; pass --todo/-t or --switch/-s to create on the spot without one`. Under `--switch` the todo is optional. +- `--switch` acts only without `--publish` — the publication never switches, so the two together are a clean error (exit 1). - The current branch already hosting the same slug is a clean error (exit 1) — `branch <name> already hosts topic <YYYY>/<slug> — switch to it instead of re-creating it`. There is no idempotent path. - Occupancy is probed against three oracles in order: a local branch with the entered name, a remote-tracking branch with the entered name (local refs only — no network), and an existing `.goga/history/<YYYY>/<slug>/` directory (only a directory occupies a topic). A fourth oracle applies to every creation: any branch tree of the inventory — local and remote-tracking refs — hosting the topic directory of the slug (`topic '<slug>' of <YYYY> is already hosted by branch '<branch>'`). - An occupied name, an unresolvable base, or a name that normalizes to an empty slug (a fully non-ASCII name) is one clean error (exit 1) with the reason and a hint to `goga topics board` for occupied names — there is no re-ask. Every read-only decision (the preflight) runs before the first input, so a failing base never wastes an entered todo. @@ -65,19 +69,19 @@ goga topics create Feature/Foo_Bar --base-ref origin/main --todo "Payment retry" Running the creation with no `--todo` given on an interactive terminal opens the external editor instead of taking the text from the command line. The option takes a value only — a value-less `--todo`/`-t` is click's own usage error (exit 2), not the entry form: ``` -$ goga topics create feat/x --from-current +$ goga topics create feat/x --from-current --switch Enter the text. An empty or unchanged file cancels the entry. # (the editor opens; saving writes todo.md, cancelling leaves nothing) # Created branch feat/x and topic 2026/feat-x ``` - The editor resolves through `$VISUAL` → `$EDITOR` → the system default (`vi`); the session edits a temporary file outside the project. -- Saving a blank file — or a file unchanged from its prefill — cancels the entry: the command continues with no `todo.md` written. A failed editor run is a clean error with nothing mutated. +- Saving a blank file — or a file unchanged from its prefill — cancels the entry: under `--switch` the command continues with no `todo.md` written; on the default path the creation is a clean error asking for the todo (see above). A failed editor run is a clean error with nothing mutated. - Without an interactive terminal a creation with no `--todo` value is a clean error before any mutation: `the todo needs a value — pass --todo/-t or run the creation on an interactive terminal` (exit 1). ### The publication ask -On an interactive terminal, without `--publish`, once a todo is resolved, the command asks once: `Publish the branch to origin?`. An empty answer reads the default no — the normal local path runs; answering yes takes the publication path below; Ctrl-C or EOF aborts with nothing created. Without a terminal, or with a cancelled todo entry, no ask happens and the normal path runs. +On an interactive terminal, without `--publish`, once a todo is resolved, the command asks once: `Publish the branch to origin?`. An empty answer reads the default no — the local path runs (the quarantined branch, or the checked-out branch under `--switch`); answering yes takes the publication path below; Ctrl-C or EOF aborts with nothing created. Without a terminal, or with a cancelled todo entry, no ask happens and the local path runs. ### `--publish` — create and publish in one step @@ -158,7 +162,7 @@ Every IDENTIFIER resolves first — a branch name, a topic slug, or their prefix | Code | Meaning | |------|---------| | `0` | Success — the board printed, the work created or published, the switch performed, the deletion done (including the idempotent switch and a declined deletion) | -| `1` | A clean domain error: an unresolvable or ambiguous identifier, no base for a creation, an occupied name, a missing todo under `--publish`, a dirty working tree, merged work or the current branch hosting a deletion target, a failed publication or remote deletion, a git infrastructure failure, or a broken `goga_tool_*` package failing to import during status-scale assembly | +| `1` | A clean domain error: an unresolvable or ambiguous identifier, no base for a creation, an occupied name, a missing todo under `--publish` or the no-switch creation, `--switch` together with `--publish`, a dirty working tree, merged work or the current branch hosting a deletion target, a failed publication or remote deletion, a git infrastructure failure, or a broken `goga_tool_*` package failing to import during status-scale assembly | | `2` | A usage error (unknown option, missing argument) | ## Notes diff --git a/goga/commands/topics/.usages/topics-command.md b/goga/commands/topics/.usages/topics-command.md index c3cb8bb2..81d8f7a0 100644 --- a/goga/commands/topics/.usages/topics-command.md +++ b/goga/commands/topics/.usages/topics-command.md @@ -7,7 +7,8 @@ facade that registers the group. The group scopes every subcommand to one year (--year/-y, default the current year); the board subcommand reads remote-tracking refs with --remote/-r and adds the todo column with --info/-i; the create subcommand -publishes fresh work without switching under --publish/-p. +creates fresh work without switching by default, switches under +--switch/-s, and publishes under --publish/-p. ## Boarding all work @@ -34,6 +35,7 @@ lines. An empty board prints nothing and exits 0. goga topics create Feature/Foo_Bar --from-current goga topics create Feature/Foo_Bar --base-ref origin/main goga topics create Feature/Foo_Bar -t "Payment retry" + goga topics create Feature/Foo_Bar -s goga topics --year 2025 create Feature/Foo_Bar --base-ref origin/main Creates the branch off the base — --base-ref, topics.base_ref of @@ -46,14 +48,18 @@ board — the todo of an existing topic is `goga topics switch ID --todo`. An explicit --todo/-t value (only the value form exists; an empty value counts as absent) is the todo; without a value a terminal opens the external editor ($VISUAL/$EDITOR/vi) — an empty or unchanged -file cancels the entry and the command continues without a todo; -without a terminal and without a value the command is a clean error -naming --todo "...". The saved text becomes todo.md — the last action -of the normal path: the branch off the base, the switch, the topic -directory, then todo.md. On a terminal without --publish the +file cancels the entry; without a terminal and without a value the +command is a clean error naming --todo "...". By default the saved +text becomes one quarantined commit — todo.md on top of the base — and +the branch is planted at it: you stay on your branch, nothing lands +in the working copy, and the todo is required (a cancelled entry is a +clean error naming --todo and --switch). --switch/-s checks out the +fresh branch instead — the topic directory and todo.md appear in the +working copy uncommitted, and the todo is optional; --switch acts only +without --publish. On a terminal without --publish the "Publish? [y/N]" ask appears only when a todo was obtained; confirming publishes with a full rollback on failure, declining takes -the normal path. +the local path. ## Creating and publishing fresh work diff --git a/goga/commands/topics/CODEMANIFEST b/goga/commands/topics/CODEMANIFEST index cf76a6de..5980bfee 100644 --- a/goga/commands/topics/CODEMANIFEST +++ b/goga/commands/topics/CODEMANIFEST @@ -53,8 +53,9 @@ Annotations: | current HEAD requested explicitly; no base at all is a clean error naming the flag and the configuration line. The commit message template — a flag beats the topics section; the built-in default - lives in the domain. The configuration is read only for values no - flag provided. + lives in the domain. The switch flag passes through to the domain + like the todo value — no resolution happens here. The configuration + is read only for values no flag provided. This cell is the CLI surface of the topics domain: a thin wrapper that resolves inputs, delegates every computation to the domain @@ -84,7 +85,7 @@ Annotations: | - board — a --remote/-r flag, an --info/-i flag - create — a NAME positional, a --todo/-t option, a --publish/-p flag, a --base-ref option, a --from-current flag, a --commit/-c - option + option, a --switch/-s flag - switch — an IDENTIFIER positional, a --todo flag - delete — IDENTIFIER positionals, a --yes/-y flag @@ -120,10 +121,13 @@ Annotations: | - Do not print the year or the artifacts, and no heading line outside the table — the table carries topic, branch, the todo column under `info`, and statuses only - "create(branch_name: str, todo: str | None = None, publish: bool = False, base_ref: str | None = None, from_current: bool = False, commit_message: str | None = None) -> exit_code: int": | + "create(branch_name: str, todo: str | None = None, publish: bool = False, base_ref: str | None = None, from_current: bool = False, commit_message: str | None = None, switch: bool = False) -> exit_code: int": | Subcommand goga topics create: create fresh work — a branch off - the resolved base with the name as entered, its topic directory - of the scoped year, and an optional multi-line todo; under + the resolved base with the name as entered, its topic of the + scoped year, and an optional multi-line todo; by default the + branch is planted at one commit carrying the topic's todo.md and + the caller stays on their branch; under --switch the branch is + checked out with its topic directory in the working copy; under --publish the work is created off the base and published to origin without switching. @@ -138,6 +142,8 @@ Annotations: | the base `commit_message`: the --commit/-c value — the message template; publication-only + `switch`: the --switch/-s flag — the checked-out creation; acts + only without `publish` `exit_code`: 0 on success, 1 on error Apply the `creating` practice for the creation contract of the @@ -152,18 +158,20 @@ Annotations: | Algorithm: 1. `commit_message` without `publish` -> clean error: the option is publication-only - 2. Resolve the base — `base_ref`, otherwise the topics section of + 2. `switch` together with `publish` -> clean error: the + publication never switches + 3. Resolve the base — `base_ref`, otherwise the topics section of the configuration loaded via `load_project_config`, otherwise the current HEAD under `from_current`; no base at all -> clean error naming the flag and the configuration line, before anything else - 3. Resolve the template — `commit_message`, otherwise the topics + 4. Resolve the template — `commit_message`, otherwise the topics section, otherwise None (the built-in default lives in the domain) - 4. Delegate to `create_topic` with the name, the base, the todo, - `publish`, the template, and the scoped year - 5. Echo the single result line - 6. Propagate the exit code + 5. Delegate to `create_topic` with the name, the base, the todo, + `publish`, the template, the scoped year, and `switch` + 6. Echo the single result line + 7. Propagate the exit code Requirements: - The configuration is read only for values no flag provided; a diff --git a/goga/commands/topics/topics.py b/goga/commands/topics/topics.py index 67afd23a..9b103905 100644 --- a/goga/commands/topics/topics.py +++ b/goga/commands/topics/topics.py @@ -13,8 +13,9 @@ lazily, only for values no flag provided. The deletion is confirmed at this layer — one confirmation for the whole resolved list. No inventory walking, no switch resolution, no git access, and no editor session -live here — the todo value passes through and the entry belongs to the -domain. Domain errors surface as clean CLI errors. +live here — the todo value and the ``--switch/-s`` flag pass through and +the entry belongs to the domain. Domain errors surface as clean CLI +errors. """ from __future__ import annotations @@ -141,6 +142,13 @@ def board(scope: _TopicsScope, remote: bool = False, info: bool = False) -> None default=None, help="Commit message template, publication-only; beats topics.publish_commit — {slug} takes the topic slug.", ) +@click.option( + "--switch", + "-s", + is_flag=True, + default=False, + help="Switch to the created branch after the creation; without the flag you stay on your branch.", +) @click.pass_obj def create( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared CLI surface scope: _TopicsScope, @@ -150,29 +158,35 @@ def create( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared CLI surface base_ref: str | None = None, from_current: bool = False, commit_message: str | None = None, + switch: bool = False, ) -> None: - """Create fresh work — a branch off the resolved base with its topic directory. - - The branch name is taken verbatim; the topic directory of the scoped - year is created from its slug. The base resolves as --base-ref, then - topics.base_ref of .goga/config.yml, then the current HEAD under - --from-current; no base at all is a clean error naming the flag and - the configuration line. An explicit --todo/-t value is the todo — the - value form only, a value-less --todo is click's own usage error; an - empty value counts as absent; with no todo given a terminal opens - the external editor and without a terminal the command is a clean - error naming the option. On a terminal without --publish the publication - ask appears once a todo is resolved; declining takes the normal path - — the branch off the base, the switch, the topic directory, then - todo.md. --publish/-p publishes to origin without switching and - without the ask; a failed publication rolls back fully. --commit/-c - — the message template; topics.publish_commit; the built-in default - lives in the domain — is publication-only. One result line on - stdout. + """Create fresh work — a branch off the resolved base with its topic. + + The branch name is taken verbatim; the topic of the scoped year takes + its slug. The base resolves as --base-ref, then topics.base_ref of + .goga/config.yml, then the current HEAD under --from-current; no base + at all is a clean error naming the flag and the configuration line. + By default the branch is planted at one commit carrying the topic's + todo.md and you stay on your branch — the todo is required on this + path. An explicit --todo/-t value is the todo — the value form only, + a value-less --todo is click's own usage error; an empty value counts + as absent; with no todo given a terminal opens the external editor + and without a terminal the command is a clean error naming the + option. --switch/-s checks out the fresh branch instead — the topic + directory and todo.md land in the working copy uncommitted and the + todo is optional. On a terminal without --publish the publication ask + appears once a todo is resolved; declining takes the local path. + --publish/-p publishes to origin without switching and without the + ask; a failed publication rolls back fully. --commit/-c — the + message template; topics.publish_commit; the built-in default lives + in the domain — is publication-only. One result line on stdout. """ if commit_message is not None and not publish: raise click.ClickException("--commit is publication-only — it acts only together with --publish") + if switch and publish: + raise click.ClickException("--switch acts only without --publish — the publication never switches") + # The empty --todo value counts as an absent option; the entry and # the write belong to the domain. if todo == "": @@ -197,7 +211,7 @@ def create( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared CLI surface if template is None and section is not None: template = section.publish_commit - line = create_topic(branch_name, base, todo, publish, template, scope.year) + line = create_topic(branch_name, base, todo, publish, template, scope.year, switch) click.echo(line) click.get_current_context().exit(0) diff --git a/goga/topics/.usages/creating.md b/goga/topics/.usages/creating.md index dcc575db..66e286c8 100644 --- a/goga/topics/.usages/creating.md +++ b/goga/topics/.usages/creating.md @@ -1,8 +1,7 @@ # topics — creating fresh work -How to create a new branch off an explicit base with its topic -directory using the `goga.topics` facade. For consumers that start new -work. +How to create a new branch off an explicit base with its topic using +the `goga.topics` facade. For consumers that start new work. `create_topic` takes the branch name as entered and the base revision. The branch keeps the name verbatim; the topic directory takes the @@ -14,28 +13,35 @@ normalized slug of the year — the two may deliberately differ ```python from goga.topics import create_topic -result = create_topic("Feature/Foo_Bar", "origin/main") # current year -result = create_topic("Feature/Foo_Bar", "origin/main", year="2025") +result = create_topic("Feature/Foo_Bar", "origin/main", todo="Fix.") # current year +result = create_topic("Feature/Foo_Bar", "origin/main", todo="Fix.", year="2025") +result = create_topic("Feature/Foo_Bar", "origin/main", todo="Fix.", switch=True) print(result) # one line describing what was created ``` - The base is explicit — any revision git resolves; the branch starts - at it and the repository switches to it. + at it and, by default, the repository stays on the caller's branch. - The preflight runs before any input: an empty slug, an occupied branch name or slug, or the current branch hosting the same slug is a clean error with a hint to the board — creating the existing is an error, not an update. - The todo: passed by value it is the todo; without a value an - interactive terminal opens the external editor — a cancelled entry - creates without a todo; a non-interactive terminal without a value - is a clean error naming the value option. + interactive terminal opens the external editor; without a terminal + and without a value the call is a clean error naming the value + option. +- The default path quarantines the topic into the branch: one commit + carrying `todo.md` — the text as entered plus a trailing newline, + UTF-8 — on top of the base, the branch planted at it, the working + copy untouched. The todo is required on this path — git keeps no + empty directories, so a cancelled entry is a clean error naming the + value option and the switch form; the built-in message applies. +- `switch=True` checks out the fresh branch instead: the topic + directory appears in the working copy and the resolved todo is + written as `todo.md` — uncommitted, the last action of the path; the + todo is optional on this path. - On an interactive terminal without an explicit publish decision, the publication ask runs when a todo was obtained — the answer chooses - between the normal path and the publication path. -- The normal path: branch off the base, switch, the topic directory, - and todo.md as the last action — the text as entered plus a trailing - newline, UTF-8. -- No todo resolved — no todo.md is written. + between the local path and the publication path. ## Occupancy diff --git a/goga/topics/CODEMANIFEST b/goga/topics/CODEMANIFEST index 3a8e719a..2edc9d82 100644 --- a/goga/topics/CODEMANIFEST +++ b/goga/topics/CODEMANIFEST @@ -79,15 +79,18 @@ Annotations: | per-topic statuses and todo summaries; the switch-identifier resolution and switching orchestration with the optional todo entry; the creation procedure off an explicit base with its preflight, the - interactive todo entry, and the publication ask; the fast - creation-and-publication procedure — a committed branch off an - explicit base without switching, pushed to origin, rolled back fully - on a failed publication; the combined ensure orchestration of the - fast process — always from the current HEAD, the topic directory - ensured on any hosting branch, the todo entry after the switch or - the creation; the todo entry of an existing topic; and the - identified-topic deletion — the local branch, the origin twin, and - the topic directory removed symmetrically with restore on failure. + interactive todo entry, and the publication ask — by default one + quarantined todo commit planted as the branch without a switch, + under the switch flag the checked-out branch with its working-copy + topic directory; the fast creation-and-publication procedure — a + committed branch off an explicit base without switching, pushed to + origin, rolled back fully on a failed publication; the combined + ensure orchestration of the fast process — always from the current + HEAD, the topic directory ensured on any hosting branch, the todo + entry after the switch or the creation; the todo entry of an + existing topic; and the identified-topic deletion — the local + branch, the origin twin, and the topic directory removed + symmetrically with restore on failure. Topic identity, addressing, and statuses belong to the history facade; git access to the topics git cell; the editor session to the editor cell. Git infrastructure failures and the fatal @@ -372,12 +375,11 @@ Annotations: | - Do not manage the stages of the hosting pipeline — continuation belongs to the pipeline itself -"create_topic(branch_name: str, base_ref: str, todo: str | None = None, publish: bool = False, commit_message: str | None = None, year: str | None = None) -> result: str": +"create_topic(branch_name: str, base_ref: str, todo: str | None = None, publish: bool = False, commit_message: str | None = None, year: str | None = None, switch: bool = False) -> result: str": location: creation.py annotations: | Create fresh work — a branch off an explicit base with the name as - entered, its topic directory of the year, and an optional - multi-line todo. + entered, its topic of the year, and an optional multi-line todo. `branch_name`: the branch name as entered by the user `base_ref`: the base revision the branch starts from — any revision @@ -387,6 +389,11 @@ Annotations: | `commit_message`: the message template; None applies the built-in default `year`: optional year as four digits; None means the current year + `switch`: True checks out the fresh branch after the creation — + the topic directory and the todo land in the working + copy, uncommitted, and the todo is optional; the default + path quarantines the topic into the branch instead and + needs the todo `result`: one line describing the outcome Apply the `click` practice for the publication ask and the @@ -411,35 +418,47 @@ Annotations: | clean error naming the value option — before any mutation 3. `publish` without a resolved todo -> clean error asking for the todo, before any mutation - 4. The publication ask — interactive terminal, `publish` not set, + 4. Neither `publish` nor `switch` without a resolved todo -> clean + error — git keeps no empty directories, so the no-switch work + exists only through its committed todo file + 5. The publication ask — interactive terminal, `publish` not set, and a todo resolved: the answer chooses the path; no ask otherwise - 5. The normal path: create the branch at the base commit via - `create_branch_at_commit` and switch to it via + 6. The normal path without `switch`: build one quarantined commit + carrying the todo file todo.md — the path resolved via + `resolve_topic_file` — on the base commit, and plant the branch + named as entered at it; the working copy, the index, and HEAD + stay untouched — the caller stays on their branch + 7. The normal path under `switch`: create the branch at the base + commit via `create_branch_at_commit` and switch to it via `checkout_local_branch` — a failed checkout rolls the planted branch back via `delete_local_branch` (the occupancy oracle would otherwise block the retry) —, create the topic directory of the year via `ensure_topic_dir`, and write the todo file - todo.md — the path resolved via `resolve_topic_file` — when a - todo resolved; the write is the last action of the path - 6. The publication path: delegate to `publish_topic` with the + todo.md when a todo resolved; the write is the last action of + the path + 8. The publication path: delegate to `publish_topic` with the name, the todo, the base, the template, and the year - 7. Return the single result line + 9. Return the single result line Requirements: - Every decision — preflight, todo, ask — precedes the first mutation - - A failed checkout of the normal path rolls the planted branch + - A failed checkout of the switch path rolls the planted branch back — nothing of the path stays behind - - The todo.md file carries the todo as entered with exactly one + - The todo.md content carries the todo as entered with exactly one trailing newline — a todo already ending in one keeps it, a bare todo gains it — encoded UTF-8; empty lines inside the text stay as entered - - The todo.md file is written only when a todo resolved - - The topic directory exists before the todo.md file is written + - The no-switch path needs a resolved todo and builds its commit + with the built-in domain message — `commit_message` stays + publication-only + - The topic directory of the switch path exists before the + todo.md file is written; the todo.md file is written only when + a todo resolved - The branch keeps the name as entered; the topic directory takes the slug - - The caller stays on the new branch on the normal path + - The caller stays on their branch unless `switch` is set Constraints: - Do not validate branch-name characters — git owns name validity @@ -758,6 +777,7 @@ CreatedAt: 29/08/26 Description: | The topics domain — the cross-branch topic inventory with todo summaries, switching with the optional todo entry, creation off an - explicit base with preflight and publication ask, fast creation with - publication, the ensure orchestration of the fast process, the todo - entry of a topic, and identified-topic deletion. + explicit base with preflight and publication ask — quarantined + without a switch by default, checked out under the switch flag — + fast creation with publication, the ensure orchestration of the fast + process, the todo entry of a topic, and identified-topic deletion. diff --git a/goga/topics/creation.py b/goga/topics/creation.py index a8cd4215..a60b6baa 100644 --- a/goga/topics/creation.py +++ b/goga/topics/creation.py @@ -6,20 +6,22 @@ across every branch tree of the inventory — without checkout, so a topic hosted only on a branch (or only on ``origin``) is visible — the orchestrator that creates fresh work off an explicit base — the branch -named exactly as entered, planted at the base commit and checked out, -together with its topic directory of the year and its topic todo file: -a given value or the editor session of the nested editor cell, every -decision read-only before the first input and the first mutation, every -conflict one clean error, and an optional publication ask that delegates -to the fast cycle of the publishing module — and the todo entry of a -topic — the editor session over the topic's todo.md and the write of -the saved text, without a commit. Topic identity and addressing belong -to the history facade; the bounded git mutation belongs to the nested -git cell; the editor session belongs to the nested editor cell. Git -infrastructure failures surface as ``click.ClickException`` — the -clean-error boundary of the domain; the interactive moments follow the -``click`` practice. The status scale is never assembled here — creation -is not a status consumer. +named exactly as entered with its topic of the year: by default planted +at one quarantined commit carrying the topic todo file while the caller +stays on their branch, or — under the switch flag — planted at the base +commit and checked out together with its topic directory of the year and +its topic todo file in the working copy: a given value or the editor +session of the nested editor cell, every decision read-only before the +first input and the first mutation, every conflict one clean error, and +an optional publication ask that delegates to the fast cycle of the +publishing module — and the todo entry of a topic — the editor session +over the topic's todo.md and the write of the saved text, without a +commit. Topic identity and addressing belong to the history facade; the +bounded git mutation belongs to the nested git cell; the editor session +belongs to the nested editor cell. Git infrastructure failures surface +as ``click.ClickException`` — the clean-error boundary of the domain; +the interactive moments follow the ``click`` practice. The status scale +is never assembled here — creation is not a status consumer. """ from __future__ import annotations @@ -145,12 +147,17 @@ def create_topic( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared signat publish: bool = False, commit_message: str | None = None, year: str | None = None, + switch: bool = False, ) -> str: """Create fresh work — a branch off an explicit base with the name as entered. - The branch is checked out, with its topic directory of the year and an - optional todo; the publication ask may hand the work to the fast - publication cycle instead. + The default path plants the branch at one quarantined commit carrying + the topic todo file — the working copy, the index, and HEAD stay + untouched and the caller stays on their branch; ``switch`` moves the + repository onto the fresh branch instead, with the topic directory + created in the working copy and the todo written uncommitted. The + publication ask may hand the work to the fast publication cycle + instead. Args: branch_name: Branch name as entered by the user. @@ -166,6 +173,11 @@ def create_topic( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared signat commit_message: Commit message template of the publication; ``None`` applies the publication's own built-in default. year: Optional year as four digits; ``None`` means the current year. + switch: ``True`` checks out the fresh branch after the creation — + the topic directory and the todo land in the working copy, + uncommitted, and the todo is optional; the default path plants + the branch at a commit carrying ``todo.md`` and needs the + todo. Returns: One line describing the outcome — the created work of the normal @@ -184,21 +196,27 @@ def create_topic( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared signat clean error naming the value option 3. ``publish`` without a resolved todo -> clean error asking for the todo - 4. The ask — an interactive terminal, ``publish`` not set, a todo + 4. Neither ``publish`` nor ``switch`` without a resolved todo -> + clean error — the no-switch work exists only through its + committed ``todo.md`` + 5. The ask — an interactive terminal, ``publish`` not set, a todo resolved: ``click.confirm`` offers the publication (an empty answer reads the default no; Ctrl-C or EOF aborts); no ask otherwise - 5. The normal path — ``create_branch_at_commit`` plants the - branch at the base commit, ``checkout_local_branch`` switches - to it (a failed checkout rolls the plant back — the + 6. The normal path — ``switch`` set: ``create_branch_at_commit`` + plants the branch at the base commit, ``checkout_local_branch`` + switches to it (a failed checkout rolls the plant back — the ``publish_topic`` precedent), ``ensure_topic_dir`` creates the topic directory of the year, and a resolved todo writes the todo file ``todo.md`` — the write is the last action of the - path - 6. The publication path — the fast cycle of ``publishing`` via a + path; ``switch`` unset: the quarantined plant of + ``publishing`` — one commit carrying ``todo.md`` on the base + commit, the branch planted at it, the caller stays on their + branch + 7. The publication path — the fast cycle of ``publishing`` via a call-time import; the cycle re-runs its own preflight — the delegation is deliberately whole - 7. Return the single result line + 8. Return the single result line Requirements: Every decision — the preflight, the todo, the ask — precedes the @@ -209,9 +227,12 @@ def create_topic( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared signat The todo.md file carries the todo as entered plus a single trailing newline, encoded UTF-8 — empty lines inside the text stay as entered. - The todo.md file is written only when a todo resolved. - The topic directory exists before the todo.md file is written. - The caller stays on the new branch on the normal path. + The no-switch normal path builds its commit with the built-in + domain message — ``commit_message`` stays publication-only. + On the switch path the todo.md file is written only when a todo + resolved, and the topic directory exists before the file is + written. + The caller stays on their branch unless ``switch`` is set. Constraints: Do not validate branch-name characters — git owns name validity. @@ -223,13 +244,13 @@ def create_topic( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared signat Raises: click.ClickException: an empty slug, the current branch hosting the slug, an occupancy conflict, an unresolvable base, no todo - without a terminal, ``publish`` without a todo, or a git - infrastructure failure (its stderr when git reports one, or a - missing git binary). + without a terminal, ``publish`` or the no-switch creation + without a todo, or a git infrastructure failure (its stderr + when git reports one, or a missing git binary). click.Abort: Ctrl-C or EOF at the publication ask. """ try: - return _create_topic(branch_name, base_ref, todo, publish, commit_message, year) + return _create_topic(branch_name, base_ref, todo, publish, commit_message, year, switch) except subprocess.CalledProcessError as exc: detail = (exc.stderr or "").strip() or str(exc) raise click.ClickException(f"git failed: {detail}") from exc @@ -239,7 +260,9 @@ def create_topic( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared signat # ``ensure_topic_dir`` propagates the mkdir failures — a stray file # named like the slug occupies no topic for the oracle, so the # failure can only surface here, after the branch was created. The - # todo write shares the boundary: one clean error for both. + # todo write shares the boundary: one clean error for both. The + # quarantined plant of the no-switch path raises its OS-level + # failures here too — one clean error covers the whole path. raise click.ClickException(f"cannot create the topic directory or write the todo file: {exc}") from exc @@ -340,6 +363,7 @@ def _create_topic( # noqa: PLR0913, PLR0917 — the unwrapped mirror of the dec publish: bool, commit_message: str | None, year: str | None, + switch: bool, ) -> str: """Run the traced creation procedure — the unwrapped orchestration. @@ -351,6 +375,7 @@ def _create_topic( # noqa: PLR0913, PLR0917 — the unwrapped mirror of the dec commit_message: Commit message template of the publication; ``None`` applies the publication's own default. year: Optional year as four digits; ``None`` means the current year. + switch: ``True`` checks out the fresh branch after the creation. Returns: The single result line of the outcome. @@ -382,24 +407,29 @@ def _create_topic( # noqa: PLR0913, PLR0917 — the unwrapped mirror of the dec if publish and resolved_todo is None: raise click.ClickException("the publication needs a todo — the board reads the topic through todo.md") + if not publish and not switch and resolved_todo is None: + # Git keeps no empty directories: without a committed todo.md the + # no-switch work exists in no tree — the board and the slug oracle + # cannot see it. The publication enforces the same for its own + # path. + raise click.ClickException( + "the local creation needs a todo — the board reads the topic through todo.md; " + "pass --todo/-t or --switch/-s to create on the spot without one" + ) + if not _publication_asked(publish, resolved_todo): - create_branch_at_commit(branch_name, base_commit) - try: - checkout_local_branch(branch_name) - except (subprocess.CalledProcessError, OSError): - # A failed checkout would strand the planted branch: the - # occupancy oracle blocks the retry ("already exists") and the - # deletion flow cannot remove it (a bare branch hosts no - # topic), so only a raw ``git branch -D`` recovers. Roll the - # plant back — the ``publish_topic`` precedent; a failure of - # the rollback itself is suppressed so the checkout reason - # surfaces. - with contextlib.suppress(subprocess.CalledProcessError, OSError): - delete_local_branch(branch_name) - raise - ensure_topic_dir(branch_name, year) - if resolved_todo is not None: - _write_todo(branch_name, resolved_year, resolved_todo) + if not switch: + # The no-switch plant goes through the same quarantined + # mechanics as the publication — the call-time import breaks + # the creation ↔ publishing import cycle exactly like the + # publication delegation below; the built-in message applies, + # ``commit_message`` stays publication-only. + from .publishing import _plant_topic_branch # noqa: PLC0415 — breaks the creation ↔ publishing import cycle + + _plant_topic_branch(branch_name, resolved_todo, base_commit, slug, resolved_year, None) + return f"Created branch {branch_name} and topic {resolved_year}/{slug}" + + _enter_fresh_branch(branch_name, base_commit, resolved_todo, year, resolved_year) return f"Created branch {branch_name} and topic {resolved_year}/{slug}" # The publication delegates to the fast cycle through a call-time @@ -412,6 +442,46 @@ def _create_topic( # noqa: PLR0913, PLR0917 — the unwrapped mirror of the dec return publish_topic(branch_name, resolved_todo, base_ref, commit_message, year) +def _enter_fresh_branch( + branch_name: str, + base_commit: str, + resolved_todo: str | None, + year: str | None, + resolved_year: str, +) -> None: + """Plant the branch at the base commit and switch to it — the traced + switch path of the creation, with the topic directory and the todo + written into the working copy. + + Args: + branch_name: Branch name as entered by the user. + base_commit: The base commit hash the preflight resolved. + resolved_todo: The resolved todo text, or ``None`` for no todo. + year: The year argument as passed — ``None`` means the current + year for the directory creation. + resolved_year: Year as four digits — the directory and the todo + file segment. + """ + create_branch_at_commit(branch_name, base_commit) + try: + checkout_local_branch(branch_name) + except (subprocess.CalledProcessError, OSError): + # A failed checkout would strand the planted branch: the + # occupancy oracle blocks the retry ("already exists") and the + # deletion flow cannot remove it (a bare branch hosts no + # topic), so only a raw ``git branch -D`` recovers. Roll the + # plant back — the ``publish_topic`` precedent; a failure of + # the rollback itself is suppressed so the checkout reason + # surfaces. + with contextlib.suppress(subprocess.CalledProcessError, OSError): + delete_local_branch(branch_name) + raise + + ensure_topic_dir(branch_name, year) + if resolved_todo is not None: + _write_todo(branch_name, resolved_year, resolved_todo) + + def _resolve_todo(todo: str | None) -> str | None: """Resolve the todo of the fresh work — the value, the editor, or an error. diff --git a/goga/topics/publishing.py b/goga/topics/publishing.py index ed48073f..be85f814 100644 --- a/goga/topics/publishing.py +++ b/goga/topics/publishing.py @@ -9,7 +9,9 @@ the mutation sequence is the quarantined commit build, the branch plant, and the push, and a failed publication rolls back fully — the planted branch is deleted and nothing else was ever mutated. The commit message -default lives here as the built-in domain template. The occupancy oracles +default lives here as the built-in domain template. The quarantined +commit build and the branch plant also serve the no-switch creation of +``creation`` through the shared plant helper. The occupancy oracles belong to ``creation``; the bounded git mutations to the nested git cell. Git infrastructure failures surface as ``click.ClickException`` — the clean-error boundary of the domain. @@ -143,20 +145,7 @@ def _publish_topic( base_commit = resolve_ref_commit(base_ref) - # The single-trailing-newline rule: an editor-sourced todo already ends - # with a newline (click's read-back), so an unconditional append would - # publish a blank trailing line; a bare value gains exactly one. - content = todo if todo.endswith("\n") else todo + "\n" - message = commit_message if commit_message is not None else _DEFAULT_COMMIT_MESSAGE - path = resolve_topic_file(slug, "todo.md", resolved_year).as_posix() - commit = commit_file_on_base( - base_commit, - path, - content, - message.replace("{slug}", slug), - ) - - create_branch_at_commit(branch_name, commit) + _plant_topic_branch(branch_name, todo, base_commit, slug, resolved_year, commit_message) try: push_branch(branch_name) @@ -171,3 +160,57 @@ def _publish_topic( raise return f"Created branch {branch_name} and published topic {resolved_year}/{slug}" + + +def _plant_topic_branch( # noqa: PLR0913, PLR0917 — the shared plant step of the two commit-building paths + branch_name: str, + todo: str, + base_commit: str, + slug: str, + resolved_year: str, + commit_message: str | None, +) -> str: + """Plant the branch at one quarantined commit carrying the topic todo file. + + The shared step of the two commit-building paths — the fast + publication and the no-switch creation of ``creation``: the commit is + built through quarantined git plumbing over ``base_commit`` and the + branch is planted at it, without touching the working copy, the index, + or HEAD. + + Args: + branch_name: Branch name as entered by the user. + todo: The todo text as entered by the user. + base_commit: The parent commit hash the commit is built on. + slug: The normalized topic slug — the topic directory of the todo + file and the ``{slug}`` placeholder value. + resolved_year: Year as four digits — the topic directory segment. + commit_message: Commit message template — the ``{slug}`` + placeholder is replaced with the topic slug; a template + without the placeholder is used as is; ``None`` applies the + built-in default ``goga: create topic {slug}``. + + Returns: + The hash of the built commit the branch was planted at. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + chain itself (propagated raw — the caller wraps it). + OSError: unexpected OS-level failures of the chain (e.g. a missing + git binary or a quarantined-index failure under ``.git``). + """ + # The single-trailing-newline rule: an editor-sourced todo already ends + # with a newline (click's read-back), so an unconditional append would + # publish a blank trailing line; a bare value gains exactly one. + content = todo if todo.endswith("\n") else todo + "\n" + message = commit_message if commit_message is not None else _DEFAULT_COMMIT_MESSAGE + path = resolve_topic_file(slug, "todo.md", resolved_year).as_posix() + commit = commit_file_on_base( + base_commit, + path, + content, + message.replace("{slug}", slug), + ) + + create_branch_at_commit(branch_name, commit) + return commit diff --git a/tests/commands/topics/test_topics_command.py b/tests/commands/topics/test_topics_command.py index 853d17af..d77370c4 100644 --- a/tests/commands/topics/test_topics_command.py +++ b/tests/commands/topics/test_topics_command.py @@ -181,8 +181,17 @@ def test_create_carries_the_commit_option_with_the_explicit_param_name(self) -> assert commit_option.is_flag is False assert commit_option.default is None + def test_create_carries_the_switch_flag(self) -> None: + """create: --switch/-s flag, defaulting to False.""" + command = topics.commands["create"] + switch_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "switch") + assert "-s" in switch_option.opts + assert "--switch" in switch_option.opts + assert switch_option.is_flag is True + assert switch_option.default is False + def test_create_callback_signature(self) -> None: - """``create(scope, branch_name, todo, publish, base_ref, from_current, commit_message)``.""" + """``create(scope, branch_name, todo, publish, base_ref, from_current, commit_message, switch)``.""" callback = topics.commands["create"].callback signature = inspect.signature(callback) assert list(signature.parameters) == [ @@ -193,12 +202,14 @@ def test_create_callback_signature(self) -> None: "base_ref", "from_current", "commit_message", + "switch", ] assert signature.parameters["todo"].default is None assert signature.parameters["publish"].default is False assert signature.parameters["base_ref"].default is None assert signature.parameters["from_current"].default is False assert signature.parameters["commit_message"].default is None + assert signature.parameters["switch"].default is False def test_switch_carries_the_identifier_positional(self) -> None: """switch: the required identifier positional.""" @@ -271,7 +282,7 @@ def test_topics_group_help_and_year_scope(self, tmp_path: Path, monkeypatch: pyt mock_create.return_value = "Created branch X and topic 2025/x" scoped = runner.invoke(topics, ["--year", "2025", "create", "X", "--from-current"]) assert scoped.exit_code == 0 - mock_create.assert_called_once_with("X", "HEAD", None, False, None, "2025") + mock_create.assert_called_once_with("X", "HEAD", None, False, None, "2025", False) @pytest.mark.parametrize("subcommand", ["board", "create", "switch", "delete"]) def test_subcommand_help_follows_the_cli_docstring_rule(self, subcommand: str) -> None: @@ -283,7 +294,7 @@ def test_subcommand_help_follows_the_cli_docstring_rule(self, subcommand: str) - assert section not in result.output def test_create_help_lists_the_new_flags(self) -> None: - """create --help lists --todo/-t, --publish/-p, --base-ref, --from-current, and --commit/-c.""" + """create --help lists --todo/-t, --publish/-p, --base-ref, --from-current, --commit/-c, and --switch/-s.""" result = CliRunner().invoke(topics, ["create", "--help"]) assert result.exit_code == 0 assert "--todo" in result.output @@ -294,6 +305,8 @@ def test_create_help_lists_the_new_flags(self) -> None: assert "--from-current" in result.output assert "--commit" in result.output assert "-c" in result.output + assert "--switch" in result.output + assert "-s" in result.output def test_delete_help_lists_the_surface(self) -> None: """delete --help lists --yes/-y and the IDENTIFIERS argument.""" @@ -311,7 +324,7 @@ def test_year_defaults_to_none_for_the_domain(self, tmp_path: Path, monkeypatch: mock_create.return_value = "Created branch X and topic 2026/x" result = CliRunner().invoke(topics, ["create", "X", "--from-current"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("X", "HEAD", None, False, None, None) + mock_create.assert_called_once_with("X", "HEAD", None, False, None, None, False) class TestTopicsBoard: @@ -457,7 +470,7 @@ def test_create_echoes_the_domain_result_line(self) -> None: ) as mock_create: result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "--base-ref", "origin/main"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("Feature/Foo_Bar", "origin/main", None, False, None, None) + mock_create.assert_called_once_with("Feature/Foo_Bar", "origin/main", None, False, None, None, False) assert result.output.splitlines() == ["Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar"] def test_topics_create_todo_option_reaches_domain(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -467,7 +480,7 @@ def test_topics_create_todo_option_reaches_domain(self, tmp_path: Path, monkeypa with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "--from-current", "-t", "Payment retry"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("Feature/Foo_Bar", "HEAD", "Payment retry", False, None, None) + mock_create.assert_called_once_with("Feature/Foo_Bar", "HEAD", "Payment retry", False, None, None, False) assert result.output == "line\n" def test_topics_create_todo_long_form_binds_the_same_value(self) -> None: @@ -475,7 +488,7 @@ def test_topics_create_todo_long_form_binds_the_same_value(self) -> None: with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: result = CliRunner().invoke(topics, ["create", "feat-a", "--base-ref", "origin/main", "--todo", "T"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("feat-a", "origin/main", "T", False, None, None) + mock_create.assert_called_once_with("feat-a", "origin/main", "T", False, None, None, False) assert result.output == "line\n" @pytest.mark.parametrize( @@ -487,7 +500,7 @@ def test_create_flag_with_value_passes_todo(self, flag_form: list[str]) -> None: with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: result = CliRunner().invoke(topics, ["create", "feat-a", "--base-ref", "origin/main", *flag_form]) assert result.exit_code == 0 - assert mock_create.call_args == mock.call("feat-a", "origin/main", "Payment retry", False, None, None) + assert mock_create.call_args == mock.call("feat-a", "origin/main", "Payment retry", False, None, None, False) def test_create_empty_todo_value_counts_as_absent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An explicitly empty --todo value is None at the call — never an entry marker. @@ -500,7 +513,7 @@ def test_create_empty_todo_value_counts_as_absent(self, tmp_path: Path, monkeypa with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: result = CliRunner().invoke(topics, ["create", "feat-a", "--from-current", "--todo", ""]) assert result.exit_code == 0 - mock_create.assert_called_once_with("feat-a", "HEAD", None, False, None, None) + mock_create.assert_called_once_with("feat-a", "HEAD", None, False, None, None, False) @pytest.mark.parametrize("flag_form", [["--todo"], ["-t"]]) def test_create_bare_todo_flag_is_usage_error(self, flag_form: list[str]) -> None: @@ -587,8 +600,10 @@ def test_create_base_ref_flag_beats_config_beats_from_current( assert flag_base.exit_code == 0 assert config_base.exit_code == 0 # n1: the base flag wins; the template still comes from the config. - assert mock_create.call_args_list[0] == mock.call("n1", "origin/flag-base", None, False, "cfg tpl", None) - assert mock_create.call_args_list[1] == mock.call("n2", "origin/config-base", None, False, "cfg tpl", None) + assert mock_create.call_args_list[0] == mock.call("n1", "origin/flag-base", None, False, "cfg tpl", None, False) + assert mock_create.call_args_list[1] == mock.call( + "n2", "origin/config-base", None, False, "cfg tpl", None, False + ) # A config without topics.base_ref: --from-current yields the HEAD. _write_config(tmp_path, "language: python\n") @@ -596,7 +611,7 @@ def test_create_base_ref_flag_beats_config_beats_from_current( with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: from_current = CliRunner().invoke(topics, ["create", "n3", "--from-current"]) assert from_current.exit_code == 0 - assert mock_create.call_args == mock.call("n3", "HEAD", None, False, None, None) + assert mock_create.call_args == mock.call("n3", "HEAD", None, False, None, None, False) # A --commit flag beats the config template (publication-only, so # under --publish). @@ -608,7 +623,7 @@ def test_create_base_ref_flag_beats_config_beats_from_current( with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: flag_template = CliRunner().invoke(topics, ["create", "n4", "--publish", "-t", "T", "--commit", "x {slug}"]) assert flag_template.exit_code == 0 - assert mock_create.call_args == mock.call("n4", "origin/config-base", "T", True, "x {slug}", None) + assert mock_create.call_args == mock.call("n4", "origin/config-base", "T", True, "x {slug}", None, False) # A missing configuration file counts as unset — the lazy read # tolerates it and --from-current still yields the HEAD. @@ -619,7 +634,7 @@ def test_create_base_ref_flag_beats_config_beats_from_current( with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: missing = CliRunner().invoke(topics, ["create", "n5", "--from-current"]) assert missing.exit_code == 0 - assert mock_create.call_args == mock.call("n5", "HEAD", None, False, None, None) + assert mock_create.call_args == mock.call("n5", "HEAD", None, False, None, None, False) def test_create_no_base_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Nothing set: the error names --base-ref, --from-current, and the config line.""" @@ -655,7 +670,34 @@ def test_create_commit_without_publish_error(self) -> None: with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: base_alone = CliRunner().invoke(topics, ["create", "--base-ref", "origin/main", "name"]) assert base_alone.exit_code == 0 - mock_create.assert_called_once_with("name", "origin/main", None, False, None, None) + mock_create.assert_called_once_with("name", "origin/main", None, False, None, None, False) + + def test_create_switch_with_publish_is_clean_error(self) -> None: + """--switch together with --publish is a clean error — the publication never switches.""" + with mock.patch.object(_topics_module, "create_topic") as mock_create: + result = CliRunner().invoke( + topics, + ["create", "X", "--publish", "--switch", "-t", "T", "--base-ref", "origin/main"], + ) + assert result.exit_code == 1 + assert "--switch" in result.stderr + assert "--publish" in result.stderr + assert "never switches" in result.stderr + mock_create.assert_not_called() + + def test_create_switch_flag_forwarded(self) -> None: + """--switch (either form) reaches the domain as the switch positional True.""" + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + short_form = CliRunner().invoke(topics, ["create", "feat-a", "--base-ref", "origin/main", "-s", "-t", "T"]) + long_form = CliRunner().invoke( + topics, ["create", "feat-a", "--base-ref", "origin/main", "--switch", "-t", "T"] + ) + assert short_form.exit_code == 0 + assert long_form.exit_code == 0 + assert mock_create.call_args_list == [ + mock.call("feat-a", "origin/main", "T", False, None, None, True), + mock.call("feat-a", "origin/main", "T", False, None, None, True), + ] def test_create_both_values_given_reads_no_configuration( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -686,7 +728,9 @@ def test_create_both_values_given_reads_no_configuration( ], ) assert result.exit_code == 0 - mock_create.assert_called_once_with("Feature/Foo_Bar", "origin/flag-base", "T", True, "flag: {slug}", None) + mock_create.assert_called_once_with( + "Feature/Foo_Bar", "origin/flag-base", "T", True, "flag: {slug}", None, False + ) mock_load.assert_not_called() def test_create_publish_config_template_beats_domain_default( @@ -702,7 +746,7 @@ def test_create_publish_config_template_beats_domain_default( with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t", "T"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("X", "origin/config-base", "T", True, "config: {slug}", None) + mock_create.assert_called_once_with("X", "origin/config-base", "T", True, "config: {slug}", None, False) def test_create_publish_no_template_anywhere_passes_none( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -714,7 +758,7 @@ def test_create_publish_no_template_anywhere_passes_none( with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t", "T"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("X", "origin/config-base", "T", True, None, None) + mock_create.assert_called_once_with("X", "origin/config-base", "T", True, None, None, False) def test_create_publish_flag_template_with_config_base( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -731,7 +775,7 @@ def test_create_publish_flag_template_with_config_base( ): result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t", "T", "--commit", "flag: {slug}"]) assert result.exit_code == 0 - mock_create.assert_called_once_with("X", "origin/config-base", "T", True, "flag: {slug}", None) + mock_create.assert_called_once_with("X", "origin/config-base", "T", True, "flag: {slug}", None, False) # The base flag is absent, so the config is read for it. mock_load.assert_called_once_with() @@ -744,7 +788,7 @@ def test_create_publish_delegation(self) -> None: ) as mock_create: result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t", "T", "--base-ref", "origin/main"]) assert result.exit_code == 0 - assert mock_create.call_args == mock.call("X", "origin/main", "T", True, None, None) + assert mock_create.call_args == mock.call("X", "origin/main", "T", True, None, None, False) assert result.output == "Created branch X and published topic 2026/x\n" def test_create_invalid_config_surfaces_its_own_error( diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index 381c0178..a17a6b9c 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -549,7 +549,8 @@ class TestCreateTopicRealGit: def test_create_topic_creates_branch_and_topic_directory( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A free name creates the branch verbatim and the topic directory of the year. + """Under the switch flag a free name creates the branch verbatim, + checks it out, and creates the topic directory of the year. The editor entry runs on the mocked terminal and is cancelled — the no-op editor leaves the prefilled file untouched — so the @@ -561,7 +562,7 @@ def test_create_topic_creates_branch_and_topic_directory( monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) _export_editor(monkeypatch, tmp_path, "exit 0") - line = create_topic("Feature/Foo_Bar", "HEAD", year="2025") + line = create_topic("Feature/Foo_Bar", "HEAD", year="2025", switch=True) assert line == "Created branch Feature/Foo_Bar and topic 2025/feature-foo-bar" assert _current_branch(tmp_path) == "Feature/Foo_Bar" @@ -569,6 +570,33 @@ def test_create_topic_creates_branch_and_topic_directory( assert topic_dir.is_dir() assert not (topic_dir / "todo.md").exists() + def test_create_topic_default_path_plants_committed_branch( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The default path quarantines the topic into the branch — no switch. + + The branch carries exactly one commit over the base — the todo file + at the topic path, with the single trailing newline — while the + working copy, the index, and HEAD stay untouched: the current + branch keeps its name and no topic directory appears on disk. + """ + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + + line = create_topic("Feature/Foo_Bar", "feat-a", todo="Payment retry", year="2025") + + assert line == "Created branch Feature/Foo_Bar and topic 2025/feature-foo-bar" + assert _current_branch(tmp_path) == "feat-a" + assert not (tmp_path / ".goga" / "history" / "2025" / "feature-foo-bar").exists() + # The stripped read keeps the exact-content check honest: only the + # single trailing newline is lost to the capture. + assert ( + _git_out(tmp_path, "show", "Feature/Foo_Bar:.goga/history/2025/feature-foo-bar/todo.md") == "Payment retry" + ) + # Exactly one commit over the base, planted without a switch. + assert _git_out(tmp_path, "rev-list", "--count", "feat-a..Feature/Foo_Bar") == "1" + def test_create_topic_occupied_local_branch_errors_non_interactively( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -636,10 +664,13 @@ def test_create_todo_then_board_info_shows_summary_and_todo_status( ) -> None: """``create --todo`` and ``board --info`` close the loop over real git. - The written todo.md carries the multi-line todo verbatim plus one - trailing newline, the board reads the topic through it — the - ``[todo]`` status — and the summary column shows the first line the - ``#``-marker normalization qualifies, not the raw first line. + The default creation commits the todo.md into the fresh branch — + verbatim plus one trailing newline, no working-copy directory — + the board reads the topic through it — the ``[todo]`` status — + and the summary column shows the first line the ``#``-marker + normalization qualifies, not the raw first line. The current + branch keeps its row and its asterisk; the fresh branch carries + the committed topic without a switch. """ _init_topic_repo(tmp_path) monkeypatch.chdir(tmp_path) @@ -660,14 +691,20 @@ def test_create_todo_then_board_info_shows_summary_and_todo_status( assert created.exit_code == 0 assert created.output == "Created branch feat-new and topic 2025/feat-new\n" + assert _current_branch(tmp_path) == "feat-a" + assert not (tmp_path / ".goga" / "history" / "2025" / "feat-new").exists() + # The stripped read keeps the exact-content check honest: only the + # single trailing newline is lost to the capture. assert ( - tmp_path / ".goga" / "history" / "2025" / "feat-new" / "todo.md" - ).read_bytes() == b"###\n# Pay retry cap\n\nRetries ignore the cap.\n" + _git_out(tmp_path, "show", "feat-new:.goga/history/2025/feat-new/todo.md") + == "###\n# Pay retry cap\n\nRetries ignore the cap." + ) result = CliRunner().invoke(topics, ["--year", "2025", "board", "--info"]) assert result.exit_code == 0 - assert ("* feat-new", "feat-new", "Pay retry cap", "[todo]") in _board_rows(result.output, columns=4) + assert ("feat-new", "feat-new", "Pay retry cap", "[todo]") in _board_rows(result.output, columns=4) + assert ("* feat-a", "feat-a", "", "[planned]") in _board_rows(result.output, columns=4) def test_board_old_title_txt_only_topic_is_empty_status( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -1274,16 +1311,17 @@ def test_delete_unpublished_topic_by_exact_name_over_real_git( ) -> None: """A freshly created, unpublished topic deletes by its exact name. - ``create_topic`` leaves the todo.md uncommitted, so the branch is - bare of the topic and only the disk directory carries it — the - exact-name identifier, which the exact-branch tier matches as a - bare branch, must still reach the disk topic. The bare branch - itself stays: deletion deletes topics, not bare branches. + ``create_topic`` under the switch flag leaves the todo.md + uncommitted, so the branch is bare of the topic and only the disk + directory carries it — the exact-name identifier, which the + exact-branch tier matches as a bare branch, must still reach the + disk topic. The bare branch itself stays: deletion deletes + topics, not bare branches. """ _init_publish_repo(tmp_path) monkeypatch.chdir(tmp_path) year = current_year() - create_topic("feature-foo", "main", todo="the plan", year=year) + create_topic("feature-foo", "main", todo="the plan", year=year, switch=True) _git(tmp_path, "switch", "-q", "main") targets = resolve_delete_targets(["feature-foo"], year=year) diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index bdff1e89..dbe80e4f 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -6,8 +6,9 @@ - ``check_slug_occupancy(slug, year)`` — the branch-tree occupancy oracle of a topic slug - ``create_topic(branch_name, base_ref, todo, publish, commit_message, - year)`` — the fresh-work creation procedure off an explicit base with - its editor-sourced todo and its publication ask + year, switch)`` — the fresh-work creation procedure off an explicit base + with its editor-sourced todo and its publication ask: the quarantined + no-switch plant by default, the working-copy switch path under the flag - ``enter_topic_todo(topic, year)`` — the editor session over the topic's todo.md and the write of the saved text, without a commit @@ -61,12 +62,13 @@ def _wire_creation( base_commit: str = "c0ffee", ) -> mock.Mock: """Patch creation's import points: a free inventory, the current - branch, the base resolution, and the create/checkout mutations. + branch, the base resolution, and the create/checkout mutations, plus + the quarantined plant the no-switch path reaches through publishing. Returns: A recording parent mock whose ``resolve_ref_commit``, - ``create_branch``, and ``checkout`` children are the wired - touchpoints — ``mock_calls`` captures the procedure's order. + ``create_branch``, ``checkout``, and ``plant`` children are the + wired touchpoints — ``mock_calls`` captures the procedure's order. """ wired = mock.Mock() wired.resolve_ref_commit.return_value = base_commit @@ -74,6 +76,7 @@ def _wire_creation( monkeypatch.setattr(creation, "resolve_ref_commit", wired.resolve_ref_commit) monkeypatch.setattr(creation, "create_branch_at_commit", wired.create_branch) monkeypatch.setattr(creation, "checkout_local_branch", wired.checkout) + monkeypatch.setattr(publishing, "_plant_topic_branch", wired.plant) return wired @@ -200,7 +203,8 @@ def test_enter_topic_todo_signature(self) -> None: signature.bind("feature-foo", year="2026") def test_create_topic_signature(self) -> None: - """``create_topic(branch_name, base_ref, todo=None, publish=False, commit_message=None, year=None) -> str``.""" + """``create_topic(branch_name, base_ref, todo, publish, commit_message, year, switch) -> str`` + with the defaults ``None/False/None/None/False``.""" signature = inspect.signature(create_topic) assert list(signature.parameters) == [ "branch_name", @@ -209,6 +213,7 @@ def test_create_topic_signature(self) -> None: "publish", "commit_message", "year", + "switch", ] assert all( parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() @@ -217,6 +222,7 @@ def test_create_topic_signature(self) -> None: assert signature.parameters["publish"].default is False assert signature.parameters["commit_message"].default is None assert signature.parameters["year"].default is None + assert signature.parameters["switch"].default is False hints = typing.get_type_hints(create_topic) assert hints == { "branch_name": str, @@ -225,9 +231,10 @@ def test_create_topic_signature(self) -> None: "publish": bool, "commit_message": str | None, "year": str | None, + "switch": bool, "return": str, } - signature.bind("b", "origin/main", todo="t", publish=False, commit_message=None, year="2026") + signature.bind("b", "origin/main", todo="t", publish=False, commit_message=None, year="2026", switch=True) signature.bind("b", "HEAD") def test_no_cleanliness_probe_in_creation(self) -> None: @@ -420,13 +427,36 @@ def test_check_slug_occupancy_default_year_is_current( class TestCreateTopic: - def test_create_topic_normal_path_order(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The normal path runs its actions in the fixed order. + def test_create_topic_no_switch_path_order(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The default path quarantines the topic into the branch — no switch. + + The preflight resolves the base once, the quarantined plant builds + one commit carrying todo.md on it and plants the branch there, and + nothing else runs: no checkout, no working-copy directory, no + working-copy todo file — the declined publication ask keeps the + work local. + """ + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, current="main", base_commit="c0ffee") + _tty(monkeypatch) + monkeypatch.setattr(click, "confirm", mock.Mock(return_value=False)) + + result = create_topic("feature-foo", "origin/main", todo="Fix.", year="2026") + + assert result == "Created branch feature-foo and topic 2026/feature-foo" + assert wired.mock_calls == [ + mock.call.resolve_ref_commit("origin/main"), + mock.call.plant("feature-foo", "Fix.", "c0ffee", "feature-foo", "2026", None), + ] + assert not (tmp_path / ".goga" / "history" / "2026").exists() + + def test_create_topic_switch_path_order(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The switch path runs its actions in the fixed order. The branch is planted at the base commit the preflight resolved, the checkout follows, then the topic directory, and the todo write is the last action of the path — the declined publication - ask keeps the work local. + ask keeps the work local and the quarantined plant never runs. """ monkeypatch.chdir(tmp_path) wired = _wire_creation(monkeypatch, current="main", base_commit="c0ffee") @@ -437,7 +467,7 @@ def test_create_topic_normal_path_order(self, tmp_path: Path, monkeypatch: pytes _tty(monkeypatch) monkeypatch.setattr(click, "confirm", mock.Mock(return_value=False)) - result = create_topic("feature-foo", "origin/main", todo="Fix.", year="2026") + result = create_topic("feature-foo", "origin/main", todo="Fix.", year="2026", switch=True) assert result == "Created branch feature-foo and topic 2026/feature-foo" assert wired.mock_calls == [ @@ -451,14 +481,31 @@ def test_create_topic_normal_path_order(self, tmp_path: Path, monkeypatch: pytes assert todo_file.read_text(encoding="utf-8") == "Fix.\n" def test_create_topic_base_passed_to_the_plant(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The base is resolved once and the branch is planted at that commit.""" + """The base is resolved once and the quarantined commit is built on it. + + The no-switch default hands the plant the resolved base commit and + the built-in message — the template argument stays None. + """ monkeypatch.chdir(tmp_path) wired = _wire_creation(monkeypatch, base_commit="abc123") create_topic("feat-a", "origin/main", todo="T", year="2026") + wired.resolve_ref_commit.assert_called_once_with("origin/main") + wired.plant.assert_called_once_with("feat-a", "T", "abc123", "feat-a", "2026", None) + + def test_create_topic_switch_path_plants_at_the_base_commit( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The switch path plants the branch at the once-resolved base commit.""" + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, base_commit="abc123") + + create_topic("feat-a", "origin/main", todo="T", year="2026", switch=True) + wired.resolve_ref_commit.assert_called_once_with("origin/main") wired.create_branch.assert_called_once_with("feat-a", "abc123") + wired.plant.assert_not_called() def test_create_topic_publication_ask_yes_delegates(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """An accepted ask delegates the whole work to the publication cycle. @@ -487,7 +534,11 @@ def test_create_topic_publication_ask_yes_delegates(self, tmp_path: Path, monkey def test_create_topic_publication_ask_empty_answer_is_no( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """An empty answer at the ask reads the default no — the work stays local.""" + """An empty answer at the ask reads the default no — the work stays local. + + The local outcome of the declined ask is the no-switch plant — no + checkout runs. + """ monkeypatch.chdir(tmp_path) wired = _wire_creation(monkeypatch) _tty(monkeypatch) @@ -496,7 +547,8 @@ def test_create_topic_publication_ask_empty_answer_is_no( result = create_topic("feature-foo", "origin/main", todo="Fix.", year="2026") assert result == "Created branch feature-foo and topic 2026/feature-foo" - wired.create_branch.assert_called_once_with("feature-foo", "c0ffee") + wired.plant.assert_called_once_with("feature-foo", "Fix.", "c0ffee", "feature-foo", "2026", None) + wired.checkout.assert_not_called() def test_create_topic_failed_checkout_rolls_back_the_plant( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -519,7 +571,7 @@ def test_create_topic_failed_checkout_rolls_back_the_plant( ) with pytest.raises(click.ClickException, match="overwritten by checkout"): - create_topic("feature-foo", "origin/main", todo="Fix.", year="2026") + create_topic("feature-foo", "origin/main", todo="Fix.", year="2026", switch=True) wired.delete_branch.assert_called_once_with("feature-foo") @@ -538,7 +590,7 @@ def test_create_topic_rollback_failure_still_surfaces_checkout_reason( ) with pytest.raises(click.ClickException) as raised: - create_topic("feature-foo", "origin/main", todo="Fix.", year="2026") + create_topic("feature-foo", "origin/main", todo="Fix.", year="2026", switch=True) assert "checkout refused" in raised.value.message assert "ref lock" not in raised.value.message @@ -548,7 +600,8 @@ def test_create_topic_editor_todo_on_tty(self, tmp_path: Path, monkeypatch: pyte written with exactly one trailing newline. The editor's read-back already ends with a newline — the shared - write helper must not double it. + write helper must not double it. The switch path carries the text + into the working copy. """ monkeypatch.chdir(tmp_path) _wire_creation(monkeypatch) @@ -556,7 +609,7 @@ def test_create_topic_editor_todo_on_tty(self, tmp_path: Path, monkeypatch: pyte _tty(monkeypatch) monkeypatch.setattr(click, "confirm", mock.Mock(return_value=False)) - result = create_topic("feature-foo", "HEAD", year="2026") + result = create_topic("feature-foo", "HEAD", year="2026", switch=True) assert result == "Created branch feature-foo and topic 2026/feature-foo" todo_file = tmp_path / ".goga" / "history" / "2026" / "feature-foo" / "todo.md" @@ -713,14 +766,14 @@ def test_create_topic_occupied_name_error_no_reask( def test_create_topic_creates_branch_and_dir_with_cancelled_entry( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A free name with a cancelled editor entry: the verbatim branch - and the slug directory — and no todo file.""" + """A free name with a cancelled editor entry on the switch path: the + verbatim branch and the slug directory — and no todo file.""" monkeypatch.chdir(tmp_path) wired = _wire_creation(monkeypatch, current="main") _editor_script(monkeypatch, tmp_path, "exit 0") _tty(monkeypatch) - result = create_topic("Feature/Foo_Bar", "HEAD", year="2025") + result = create_topic("Feature/Foo_Bar", "HEAD", year="2025", switch=True) assert result == "Created branch Feature/Foo_Bar and topic 2025/feature-foo-bar" wired.create_branch.assert_called_once_with("Feature/Foo_Bar", "c0ffee") @@ -729,32 +782,90 @@ def test_create_topic_creates_branch_and_dir_with_cancelled_entry( assert topic_dir.is_dir() assert not (topic_dir / "todo.md").exists() + def test_create_topic_no_switch_cancelled_entry_is_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A cancelled editor entry on the default path is a clean error. + + Git keeps no empty directories — without a committed todo.md the + no-switch work exists in no tree, so the todo is required exactly + as it is under the publication; the error names the two ways out. + """ + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, current="main") + _editor_script(monkeypatch, tmp_path, "exit 0") + _tty(monkeypatch) + + with pytest.raises(click.ClickException) as raised: + create_topic("Feature/Foo_Bar", "HEAD", year="2025") + + assert "the local creation needs a todo" in raised.value.message + assert "todo.md" in raised.value.message + wired.plant.assert_not_called() + wired.create_branch.assert_not_called() + wired.checkout.assert_not_called() + def test_create_topic_default_year_is_current(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Without a year the topic directory lands in the current one.""" + """Without a year the topic of the switch-path directory lands in the current one.""" monkeypatch.chdir(tmp_path) _wire_creation(monkeypatch, current="main") _editor_script(monkeypatch, tmp_path, "exit 0") _tty(monkeypatch) monkeypatch.setattr(creation, "current_year", lambda: "2026") - result = create_topic("Feature/Foo_Bar", "HEAD") + result = create_topic("Feature/Foo_Bar", "HEAD", switch=True) assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" assert (tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar").is_dir() + def test_create_topic_no_switch_default_year_is_current( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Without a year the quarantined commit carries the current year's path.""" + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, current="main") + monkeypatch.setattr(creation, "current_year", lambda: "2026") + + result = create_topic("Feature/Foo_Bar", "HEAD", todo="T") + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + wired.plant.assert_called_once_with("Feature/Foo_Bar", "T", "c0ffee", "feature-foo-bar", "2026", None) + def test_create_topic_with_todo_value(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A free name with a todo value: the branch, the directory, the todo file.""" + """A free name with a todo value: the quarantined plant, nothing on disk. + + The default path hands the resolved todo to the plant — the + working copy keeps no directory and no file. + """ monkeypatch.chdir(tmp_path) - _wire_creation(monkeypatch, current="main") + wired = _wire_creation(monkeypatch, current="main") result = create_topic("Feature/Foo_Bar", "HEAD", todo="Payment retry", year="2026") + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + wired.plant.assert_called_once_with( + "Feature/Foo_Bar", "Payment retry", "c0ffee", "feature-foo-bar", "2026", None + ) + wired.checkout.assert_not_called() + assert not (tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar").exists() + + def test_create_topic_with_todo_value_switch_writes_the_file( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A free name with a todo value on the switch path: the branch, the + directory, the todo file.""" + monkeypatch.chdir(tmp_path) + _wire_creation(monkeypatch, current="main") + + result = create_topic("Feature/Foo_Bar", "HEAD", todo="Payment retry", year="2026", switch=True) + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" todo_file = tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" / "todo.md" assert todo_file.read_bytes() == b"Payment retry\n" def test_create_topic_writes_multiline_todo(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A multi-line todo: the file carries the text verbatim plus one newline.""" + """A multi-line todo on the switch path: the file carries the text + verbatim plus one newline.""" monkeypatch.chdir(tmp_path) _wire_creation(monkeypatch, current="main") @@ -763,6 +874,7 @@ def test_create_topic_writes_multiline_todo(self, tmp_path: Path, monkeypatch: p "HEAD", year="2026", todo="Fix payment retries.\n\nRetries ignore the cap.", + switch=True, ) assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" @@ -775,11 +887,12 @@ def test_create_topic_writes_multiline_todo(self, tmp_path: Path, monkeypatch: p def test_create_topic_whitespace_todo_writes_verbatim( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A whitespace-only todo is a non-empty text — it is written verbatim.""" + """A whitespace-only todo is a non-empty text — the switch path + writes it verbatim.""" monkeypatch.chdir(tmp_path) _wire_creation(monkeypatch, current="main") - result = create_topic("feat-a", "HEAD", year="2026", todo=" ") + result = create_topic("feat-a", "HEAD", year="2026", todo=" ", switch=True) assert result == "Created branch feat-a and topic 2026/feat-a" topic_dir = tmp_path / ".goga" / "history" / "2026" / "feat-a" @@ -788,7 +901,8 @@ def test_create_topic_whitespace_todo_writes_verbatim( def test_create_topic_todo_write_failure_is_clean_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A failing todo write becomes the generalized clean error.""" + """A failing todo write of the switch path becomes the generalized + clean error.""" monkeypatch.chdir(tmp_path) wired = _wire_creation(monkeypatch, current="main") monkeypatch.setattr( @@ -798,7 +912,7 @@ def test_create_topic_todo_write_failure_is_clean_error( ) with pytest.raises(click.ClickException) as raised: - create_topic("Feature/Foo_Bar", "HEAD", todo="T", year="2026") + create_topic("Feature/Foo_Bar", "HEAD", todo="T", year="2026", switch=True) assert "cannot create the topic directory or write the todo file" in raised.value.message # The traced order — the branch mutations run before the todo write. @@ -981,7 +1095,7 @@ def test_missing_git_binary_surfaces_as_clean_error(self, tmp_path: Path, monkey def test_create_mutation_failure_surfaces_as_clean_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A failing branch plant becomes a ``ClickException``.""" + """A failing branch plant of the switch path becomes a ``ClickException``.""" monkeypatch.chdir(tmp_path) _wire_creation(monkeypatch, current="main") failure = subprocess.CalledProcessError( @@ -992,11 +1106,37 @@ def test_create_mutation_failure_surfaces_as_clean_error( monkeypatch.setattr(creation, "create_branch_at_commit", mock.Mock(side_effect=failure)) with pytest.raises(click.ClickException) as raised: - create_topic("feat/x", "HEAD", todo="T", year="2026") + create_topic("feat/x", "HEAD", todo="T", year="2026", switch=True) assert "fatal: invalid branch name" in raised.value.message assert not (tmp_path / ".goga" / "history" / "2026" / "feat-x").exists() + def test_create_no_switch_plant_failure_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A failing quarantined plant of the default path becomes a ``ClickException``. + + The failure crosses the publishing module — the boundary still + wraps it, and no working-copy artifact appears. + """ + monkeypatch.chdir(tmp_path) + real_plant = publishing._plant_topic_branch + _wire_creation(monkeypatch, current="main") + monkeypatch.setattr(publishing, "_plant_topic_branch", real_plant) + monkeypatch.setattr(publishing, "commit_file_on_base", mock.Mock(return_value="beef00")) + failure = subprocess.CalledProcessError( + returncode=128, + cmd=["git", "update-ref", "--stdin", "-z"], + stderr="fatal: reference already exists", + ) + monkeypatch.setattr(publishing, "create_branch_at_commit", mock.Mock(side_effect=failure)) + + with pytest.raises(click.ClickException) as raised: + create_topic("feat/x", "HEAD", todo="T", year="2026") + + assert "reference already exists" in raised.value.message + assert not (tmp_path / ".goga" / "history" / "2026" / "feat-x").exists() + def test_missing_git_binary_at_creation_surfaces_as_clean_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1010,7 +1150,7 @@ def test_missing_git_binary_at_creation_surfaces_as_clean_error( ) with pytest.raises(click.ClickException) as raised: - create_topic("feat/x", "HEAD", todo="T", year="2026") + create_topic("feat/x", "HEAD", todo="T", year="2026", switch=True) assert "git" in raised.value.message @@ -1031,7 +1171,7 @@ def test_stray_file_at_topic_path_surfaces_as_clean_error( wired = _wire_creation(monkeypatch, current="main") with pytest.raises(click.ClickException) as raised: - create_topic("feat-x", "HEAD", todo="T", year="2026") + create_topic("feat-x", "HEAD", todo="T", year="2026", switch=True) assert "cannot create the topic directory or write the todo file" in raised.value.message assert "feat-x" in raised.value.message From 065b7f132bdf6a9410887fe61218ec08e1216b6f Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 4 Sep 2026 10:24:27 +0000 Subject: [PATCH 223/229] chore: bump AFM_VERSION to 0.5.67 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 30516aef..e820c358 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG AFM_VERSION=0.5.64 +ARG AFM_VERSION=0.5.67 ARG RALPHEX_VERSION=1.6 ARG PYTHON_VERSION=3.12 ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 From f57f31f60df3cf26394f8611abc2418bba18a3a0 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 4 Sep 2026 10:24:27 +0000 Subject: [PATCH 224/229] docs: clarify architecture plan reference in brainstorm skill --- goga/assets/skills/goga-cells-by-brainstorm/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md b/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md index 8c80f749..92f4ab7b 100644 --- a/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md +++ b/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md @@ -6,7 +6,7 @@ description: Creation and modification of cells by architecture plan ## Purpose -Creates new cells and modifies existing cells based on the architecture plan (the file at the path printed by `goga history path -f arch.md`). Materializes the plan into the cell file structure: +Creates new cells and modifies existing cells based on the architecture plan defined in the path printed by `goga history path -f arch.md`. Materializes the plan into the cell file structure: CODEMANIFEST, `.usages/`. --- From de22a5d17dcdc7ed1f60d83cbe5c671d100d5785 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 4 Sep 2026 10:33:21 +0000 Subject: [PATCH 225/229] docs: quote the publication ask prompt verbatim in topics-command usage --- goga/commands/topics/.usages/topics-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/goga/commands/topics/.usages/topics-command.md b/goga/commands/topics/.usages/topics-command.md index 81d8f7a0..99c0d5ee 100644 --- a/goga/commands/topics/.usages/topics-command.md +++ b/goga/commands/topics/.usages/topics-command.md @@ -57,7 +57,7 @@ clean error naming --todo and --switch). --switch/-s checks out the fresh branch instead — the topic directory and todo.md appear in the working copy uncommitted, and the todo is optional; --switch acts only without --publish. On a terminal without --publish the -"Publish? [y/N]" ask appears only when a todo was obtained; +"Publish the branch to origin? [y/N]" ask appears only when a todo was obtained; confirming publishes with a full rollback on failure, declining takes the local path. From a51e7d43b0676695c507fdc8fe3aff99beac7083 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 4 Sep 2026 11:11:39 +0000 Subject: [PATCH 226/229] feat(topics)!: separate board records with row dividers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit goga topics board now closes every record — the last included — with a row divider: the header separator's own dash run, printed after the record's wrapped status lines, which stay undivided. Rows no longer visually merge into one block. render.py echoes _separator(caps) after each record's lines; the CODEMANIFEST gains the algorithm step and the row-divider requirement; the topics-command usage and docs/cli/topics.md document the divider and show it in the sample output. New TestRenderTopicBoardRowDividers cases cover the closing divider, the undivided continuation lines, and the --info grid; the existing tests learn the new line counts and indices, and the integration _board_rows parser skips divider lines. BREAKING CHANGE: the stdout of goga topics board changes — every record, the last included, is now followed by a separator row identical to the header separator; scripts parsing the board table must skip those rows. --- docs/cli/topics.md | 4 + .../commands/topics/.usages/topics-command.md | 4 +- goga/commands/topics/CODEMANIFEST | 11 ++- goga/commands/topics/render.py | 15 +++- tests/commands/topics/test_render.py | 88 ++++++++++++++++--- tests/integration/test_topic_workflows.py | 11 ++- 6 files changed, 113 insertions(+), 20 deletions(-) diff --git a/docs/cli/topics.md b/docs/cli/topics.md index 517fac6f..36350df2 100644 --- a/docs/cli/topics.md +++ b/docs/cli/topics.md @@ -23,11 +23,15 @@ Prints the board — the cross-branch topic inventory of the scoped year — as | Topic | Branch | Statuses |----------------|----------------|------------------- | feat-b | feat-b | [defined] +|----------------|----------------|------------------- | * feat-a | feat-a | [planned] +|----------------|----------------|------------------- | feat-a | feat-b | [planned] +|----------------|----------------|------------------- ``` - One row per topic hosted by a branch; `*` marks the row hosting the current branch. +- A row divider — the same dash run as under the header — closes every record, the last included; a record's wrapped status lines stay undivided. - The current branch's row reads the working copy — uncommitted progress is visible; every other row reads the branch's committed tree (no checkout happens). - A local branch and its remote twin collapse to one row — the local branch wins; a topic hosted only by a remote-tracking ref keeps its row with the remote name in the branch column. - Rows sort by scale order of the first maximal status, then alphabetically by topic. diff --git a/goga/commands/topics/.usages/topics-command.md b/goga/commands/topics/.usages/topics-command.md index 99c0d5ee..81c57f19 100644 --- a/goga/commands/topics/.usages/topics-command.md +++ b/goga/commands/topics/.usages/topics-command.md @@ -18,7 +18,9 @@ creates fresh work without switching by default, switches under goga topics board --info Prints a three-column table — topic, branch, statuses — with column and -row separators fitted to the terminal width. `--info/-i` adds the todo +row separators fitted to the terminal width. A row divider — the header +separator's dash run — closes every record, the last included; the wrapped +status lines of one record stay undivided. `--info/-i` adds the todo column: topic, branch, todo, and statuses share the width — each of the first three capped at a quarter of it minus the dividers — and the todo cell shows the first line of the topic's `todo.md` that yields text diff --git a/goga/commands/topics/CODEMANIFEST b/goga/commands/topics/CODEMANIFEST index 5980bfee..f6c3916d 100644 --- a/goga/commands/topics/CODEMANIFEST +++ b/goga/commands/topics/CODEMANIFEST @@ -272,9 +272,12 @@ Annotations: | 3. Print each record: every text column truncated with an ellipsis when it exceeds its column, the statuses wrapped onto continuation lines without affecting the column widths - 4. Mark the record hosting the current branch with an asterisk; keep + 4. Print one row divider after every record — the same dash run as the + header separator — the last record included; the wrapped + continuation lines of one record stay undivided + 5. Mark the record hosting the current branch with an asterisk; keep the remote prefix of a remote host visible in the branch column - 5. An empty `records` prints nothing + 6. An empty `records` prints nothing Requirements: - Three-column widths: topic and branch get an equal share first, @@ -290,6 +293,10 @@ Annotations: | - A todo of None or an empty string renders an empty cell - The truncation marker is a single ellipsis character - An overlong status is truncated like the other columns + - A row divider — identical to the header separator row — closes every + record: it prints after the wrapped statuses of each record, the + last record included, and never between the continuation lines of + one record - The table never exceeds `width`, with one documented exception: when the minimum columns no longer fit — below the narrow threshold of the active column rule — every column keeps its minimum of 8 and the diff --git a/goga/commands/topics/render.py b/goga/commands/topics/render.py index 6f44b5c7..020e7cb5 100644 --- a/goga/commands/topics/render.py +++ b/goga/commands/topics/render.py @@ -47,9 +47,12 @@ def render_topic_board(records: list[BoardRecord], width: int, info: bool = Fals 3. Print each record: every text column truncated with an ellipsis when it exceeds its column, and the statuses wrapped onto continuation lines without affecting the column widths - 4. Mark the record hosting the current branch with an asterisk; the + 4. Print one row divider after every record — the same dash run as + the header separator — the last record included; the wrapped + continuation lines of one record stay undivided + 5. Mark the record hosting the current branch with an asterisk; the remote prefix of a remote host stays visible in the branch column - 5. An empty ``records`` prints nothing + 6. An empty ``records`` prints nothing Requirements: The three-column rule gives topic and branch an equal share first — @@ -61,7 +64,11 @@ def render_topic_board(records: list[BoardRecord], width: int, info: bool = Fals truncation applies. The todo column header is the word todo. A todo of ``None`` or an empty string renders an empty cell. The truncation marker is a single ellipsis character; - an overlong status segment is truncated like the other columns. The + an overlong status segment is truncated like the other columns. A + row divider — identical to the header separator row — closes every + record: it prints after the wrapped statuses of each record, the + last record included, and never between the continuation lines of + one record. The table never exceeds ``width``, with one documented exception: below the narrow threshold of the active column rule — 33 columns for the thirds, 44 for the quarters — every column keeps its minimum of 8 @@ -91,6 +98,8 @@ def render_topic_board(records: list[BoardRecord], width: int, info: bool = Fals cells = (*(cell if index == 0 else "" for cell in leading), statuses_line) click.echo(_row_line(cells, caps)) + click.echo(_separator(caps)) + def _column_widths(width: int, columns_count: int) -> tuple[int, ...]: """Resolve the column widths of the grid for one terminal width. diff --git a/tests/commands/topics/test_render.py b/tests/commands/topics/test_render.py index 5f9943ca..843ec936 100644 --- a/tests/commands/topics/test_render.py +++ b/tests/commands/topics/test_render.py @@ -86,8 +86,9 @@ def test_render_topic_board_widths_and_wrap(self, capsys: pytest.CaptureFixture[ render_topic_board(records, 60) lines = capsys.readouterr().out.splitlines() # usable = 51, so topic_cap = branch_cap = 17 and statuses_w = 17; - # every grid line stays within the measured width. - assert len(lines) == 4 + # every grid line stays within the measured width; the closing row + # divider adds the fifth line. + assert len(lines) == 5 assert all(len(line) <= 60 for line in lines) assert lines[0].startswith("| Topic") assert "Branch" in lines[0] @@ -161,7 +162,7 @@ def test_render_topic_board_boundary_width_33_32(self, capsys: pytest.CaptureFix assert all(len(line) == 33 for line in lines) assert "feat-a" in lines[2] assert "[done]" in lines[2] - assert "…" in lines[3] + assert "…" in lines[4] def test_render_topic_board_two_segments_fit_one_line(self, capsys: pytest.CaptureFixture[str]) -> None: """Width 80 — two short status segments join on one statuses line.""" @@ -177,8 +178,9 @@ def test_render_topic_board_two_segments_fit_one_line(self, capsys: pytest.Captu render_topic_board(records, 80) lines = capsys.readouterr().out.splitlines() # usable = 71, so topic_cap = branch_cap = 23 and statuses_w = 25; - # "[defined] [planned]" is 19 columns and fits — one data row only. - assert len(lines) == 3 + # "[defined] [planned]" is 19 columns and fits — one data row plus + # its closing row divider. + assert len(lines) == 4 assert "[defined] [planned]" in lines[2] assert all(len(line) <= 80 for line in lines) @@ -266,10 +268,12 @@ def test_render_topic_board_info_four_columns(self, capsys: pytest.CaptureFixtur assert line.count("|") == 4 assert all(len(line) <= 100 for line in lines) assert "Pay retry cap" in lines[2] - # The 37-column summary exceeds its cap of 22 — truncated with the ellipsis. - assert "…" in lines[3] + # The 37-column summary exceeds its cap of 22 — truncated with the + # ellipsis on the second record row, past the divider between the + # two records. + assert "…" in lines[4] assert "[planned]" in lines[2] - assert "[done]" in lines[3] + assert "[done]" in lines[4] def test_render_info_column_carries_todo_header(self, capsys: pytest.CaptureFixture[str]) -> None: """The ``info`` header carries the word todo and the record's todo summary.""" @@ -346,8 +350,9 @@ def test_render_topic_board_info_wraps_statuses_with_empty_leading_cells( render_topic_board(records, 44, info=True) lines = capsys.readouterr().out.splitlines() # usable = 32 = 8x4 — the minimum quarters; "[done]" and the - # truncated "[planned]" cannot share the 8-column statuses cell. - assert len(lines) == 4 + # truncated "[planned]" cannot share the 8-column statuses cell; + # the closing row divider adds the fifth line. + assert len(lines) == 5 assert "[done]" in lines[2] assert "[planne…" in lines[3] # The continuation row keeps the grid: topic, branch, and todo are @@ -359,3 +364,66 @@ def test_render_topic_board_info_empty_records_print_nothing(self, capsys: pytes """An empty board under ``info`` renders not a single line — header included.""" render_topic_board([], 100, info=True) assert capsys.readouterr().out == "" + + +class TestRenderTopicBoardRowDividers: + def test_render_topic_board_row_divider_closes_every_record(self, capsys: pytest.CaptureFixture[str]) -> None: + """A row divider — the header separator's own line — closes every record. + + Two records print header, header separator, row, divider, row, + divider: the divider after the last record included, and every + divider byte-identical to the header separator. + """ + records = [ + BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=False, remote=False), + BoardRecord(topic="feat-b", branch="feat/b", statuses=["done"], current=False, remote=False), + ] + render_topic_board(records, 80) + lines = capsys.readouterr().out.splitlines() + assert len(lines) == 6 + assert lines[0].startswith("| Topic") + # The dividers sit after every record — the last one included. + assert lines[3] == lines[1] + assert lines[5] == lines[1] + assert set(lines[3]) == {"-", "|"} + # The record rows themselves stay put between the dividers. + assert "feat-a" in lines[2] + assert "feat-b" in lines[4] + + def test_render_topic_board_continuation_lines_stay_undivided(self, capsys: pytest.CaptureFixture[str]) -> None: + """The wrapped statuses of one record stay undivided — the divider + closes the whole record, not every grid line.""" + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["planned", "mkdocs.published"], + current=False, + remote=False, + ) + ] + render_topic_board(records, 60) + lines = capsys.readouterr().out.splitlines() + # usable = 51 — the 18-column "[mkdocs.published]" wraps: the + # continuation line directly follows its record row, and the single + # divider closes the record after the continuation. + assert lines[3].startswith(f"|{' ' * 19}|{' ' * 19}|") + assert "mkdocs.publis" in lines[3] + assert lines[4] == lines[1] + + def test_render_topic_board_info_row_divider_closes_every_record(self, capsys: pytest.CaptureFixture[str]) -> None: + """Under ``info`` the dividers keep the four-pipe grid of the quarters.""" + records = [ + BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], todo="T", current=False, remote=False), + BoardRecord(topic="feat-b", branch="feat/b", statuses=["done"], todo=None, current=False, remote=False), + ] + render_topic_board(records, 100, info=True) + lines = capsys.readouterr().out.splitlines() + assert len(lines) == 6 + assert lines[3] == lines[1] + assert lines[5] == lines[1] + for line in lines: + assert line.count("|") == 4 + assert all(len(line) <= 100 for line in lines) + assert "feat-a" in lines[2] + assert "feat-b" in lines[4] diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py index a17a6b9c..37cadb77 100644 --- a/tests/integration/test_topic_workflows.py +++ b/tests/integration/test_topic_workflows.py @@ -253,13 +253,16 @@ def _board_rows(output: str, columns: int = 3) -> list[tuple[str, ...]]: 4 with it (the todo column between branch and statuses). Returns: - The cell tuples of the data rows — the header and separator rows - dropped, every cell stripped. + The cell tuples of the data rows — the header and every divider row + (the header separator and the closing row dividers alike) dropped, + every cell stripped. """ - lines = [line for line in output.splitlines() if line.startswith("|")] + # A divider row starts with "|-"; the header, data, and continuation + # rows start with "| " (a space follows the leading pipe). + lines = [line for line in output.splitlines() if line.startswith("| ")] rows = [] - for line in lines[2:]: + for line in lines[1:]: cells = line.split("|") rows.append(tuple(cell.strip() for cell in cells[1 : columns + 1])) From fe284f0003255400dbfff9cf1c741971fe1e0ac9 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 4 Sep 2026 13:15:11 +0000 Subject: [PATCH 227/229] docs!: restructure the site into Features domains Move every command page into its functional domain under docs/features/ (fourteen domains, five pages each: overview, CLI, configuration, hooks, API), absorb the Pipelines and Tools sections into the pipelines and tools domains, turn the CLI section into a command-to-domain cross-road, and move `goga config` next to the configuration it reads. Simplify project configuration to the global fields plus the codemanifest section and a domain-section map; document each domain-owned section (build, pipeline, tools, usages, lint, topics) in its domain page. Add per-domain facade API references from the CODEMANIFEST contracts, a status-scale hook page for history, a registration contract page for the hooks platform, and a lint error catalog. Re-trace every page in .goga/tools/mkdocs/traceability.yml and rewrite all links (mkdocs build --strict passes clean). --- .goga/tools/mkdocs/traceability.yml | 413 ++++++++++++------ README.md | 34 +- docs/cli/index.md | 40 +- docs/configuration/agents.md | 4 +- docs/{cli/config.md => configuration/cli.md} | 0 docs/configuration/index.md | 11 +- docs/configuration/project.md | 121 ++--- docs/features/build/api.md | 48 ++ docs/{cli/build.md => features/build/cli.md} | 0 docs/features/build/configuration.md | 54 +++ docs/features/build/hooks.md | 5 + docs/features/build/index.md | 19 + docs/features/connect/api.md | 23 + .../connect.md => features/connect/cli.md} | 2 +- docs/features/connect/configuration.md | 5 + docs/features/connect/hooks.md | 5 + docs/features/connect/index.md | 18 + docs/features/contract/api.md | 38 ++ .../contract.md => features/contract/cli.md} | 0 docs/features/contract/configuration.md | 5 + docs/features/contract/hooks.md | 5 + docs/features/contract/index.md | 18 + docs/features/history/api.md | 75 ++++ .../history.md => features/history/cli.md} | 4 +- docs/features/history/configuration.md | 7 + docs/features/history/hooks.md | 35 ++ docs/features/history/index.md | 19 + docs/features/hooks/api.md | 73 ++++ docs/{cli/hooks.md => features/hooks/cli.md} | 2 +- docs/features/hooks/configuration.md | 5 + docs/features/hooks/hooks.md | 40 ++ docs/features/hooks/index.md | 25 ++ docs/features/index.md | 38 ++ docs/features/init/api.md | 35 ++ docs/{cli/init.md => features/init/cli.md} | 0 docs/features/init/configuration.md | 5 + docs/features/init/hooks.md | 5 + docs/features/init/index.md | 16 + docs/features/install/api.md | 35 ++ .../install.md => features/install/cli.md} | 2 +- docs/features/install/configuration.md | 17 + docs/features/install/hooks.md | 14 + docs/features/install/index.md | 20 + docs/{cli => features/install}/uninstall.md | 4 +- docs/features/lint/api.md | 22 + docs/{cli/lint.md => features/lint/cli.md} | 2 +- docs/features/lint/configuration.md | 18 + docs/features/lint/errors.md | 93 ++++ docs/features/lint/hooks.md | 5 + docs/features/lint/index.md | 20 + docs/features/pipelines/api.md | 63 +++ .../pipeline.md => features/pipelines/cli.md} | 4 +- docs/features/pipelines/configuration.md | 22 + docs/features/pipelines/hooks.md | 5 + docs/{ => features}/pipelines/index.md | 16 +- .../{ => features}/pipelines/pipeline-file.md | 2 +- docs/{ => features}/pipelines/shipped.md | 4 +- docs/{ => features}/pipelines/workflows.md | 6 +- docs/features/schema/api.md | 20 + .../{cli/schema.md => features/schema/cli.md} | 0 docs/features/schema/configuration.md | 5 + docs/features/schema/hooks.md | 5 + docs/features/schema/index.md | 16 + docs/features/tools/api.md | 23 + docs/{cli/tool.md => features/tools/cli.md} | 0 docs/features/tools/configuration.md | 7 + docs/features/tools/hooks.md | 18 + docs/{tools.md => features/tools/index.md} | 72 +-- docs/features/topics/api.md | 86 ++++ .../{cli/topics.md => features/topics/cli.md} | 8 +- docs/features/topics/configuration.md | 18 + docs/features/topics/hooks.md | 5 + docs/features/topics/index.md | 25 ++ docs/features/upgrade/api.md | 24 + .../upgrade.md => features/upgrade/cli.md} | 4 +- docs/features/upgrade/configuration.md | 5 + docs/features/upgrade/hooks.md | 5 + docs/features/upgrade/index.md | 17 + docs/features/usages/api.md | 47 ++ .../{cli/usages.md => features/usages/cli.md} | 0 docs/features/usages/configuration.md | 26 ++ docs/features/usages/hooks.md | 5 + docs/features/usages/index.md | 18 + docs/getting-started.md | 8 +- docs/index.md | 8 +- docs/workflow/build.md | 4 +- docs/workflow/index.md | 2 +- mkdocs.yml | 117 ++++- 88 files changed, 1851 insertions(+), 373 deletions(-) rename docs/{cli/config.md => configuration/cli.md} (100%) create mode 100644 docs/features/build/api.md rename docs/{cli/build.md => features/build/cli.md} (100%) create mode 100644 docs/features/build/configuration.md create mode 100644 docs/features/build/hooks.md create mode 100644 docs/features/build/index.md create mode 100644 docs/features/connect/api.md rename docs/{cli/connect.md => features/connect/cli.md} (95%) create mode 100644 docs/features/connect/configuration.md create mode 100644 docs/features/connect/hooks.md create mode 100644 docs/features/connect/index.md create mode 100644 docs/features/contract/api.md rename docs/{cli/contract.md => features/contract/cli.md} (100%) create mode 100644 docs/features/contract/configuration.md create mode 100644 docs/features/contract/hooks.md create mode 100644 docs/features/contract/index.md create mode 100644 docs/features/history/api.md rename docs/{cli/history.md => features/history/cli.md} (96%) create mode 100644 docs/features/history/configuration.md create mode 100644 docs/features/history/hooks.md create mode 100644 docs/features/history/index.md create mode 100644 docs/features/hooks/api.md rename docs/{cli/hooks.md => features/hooks/cli.md} (94%) create mode 100644 docs/features/hooks/configuration.md create mode 100644 docs/features/hooks/hooks.md create mode 100644 docs/features/hooks/index.md create mode 100644 docs/features/index.md create mode 100644 docs/features/init/api.md rename docs/{cli/init.md => features/init/cli.md} (100%) create mode 100644 docs/features/init/configuration.md create mode 100644 docs/features/init/hooks.md create mode 100644 docs/features/init/index.md create mode 100644 docs/features/install/api.md rename docs/{cli/install.md => features/install/cli.md} (99%) create mode 100644 docs/features/install/configuration.md create mode 100644 docs/features/install/hooks.md create mode 100644 docs/features/install/index.md rename docs/{cli => features/install}/uninstall.md (90%) create mode 100644 docs/features/lint/api.md rename docs/{cli/lint.md => features/lint/cli.md} (96%) create mode 100644 docs/features/lint/configuration.md create mode 100644 docs/features/lint/errors.md create mode 100644 docs/features/lint/hooks.md create mode 100644 docs/features/lint/index.md create mode 100644 docs/features/pipelines/api.md rename docs/{cli/pipeline.md => features/pipelines/cli.md} (99%) create mode 100644 docs/features/pipelines/configuration.md create mode 100644 docs/features/pipelines/hooks.md rename docs/{ => features}/pipelines/index.md (89%) rename docs/{ => features}/pipelines/pipeline-file.md (99%) rename docs/{ => features}/pipelines/shipped.md (98%) rename docs/{ => features}/pipelines/workflows.md (99%) create mode 100644 docs/features/schema/api.md rename docs/{cli/schema.md => features/schema/cli.md} (100%) create mode 100644 docs/features/schema/configuration.md create mode 100644 docs/features/schema/hooks.md create mode 100644 docs/features/schema/index.md create mode 100644 docs/features/tools/api.md rename docs/{cli/tool.md => features/tools/cli.md} (100%) create mode 100644 docs/features/tools/configuration.md create mode 100644 docs/features/tools/hooks.md rename docs/{tools.md => features/tools/index.md} (66%) create mode 100644 docs/features/topics/api.md rename docs/{cli/topics.md => features/topics/cli.md} (98%) create mode 100644 docs/features/topics/configuration.md create mode 100644 docs/features/topics/hooks.md create mode 100644 docs/features/topics/index.md create mode 100644 docs/features/upgrade/api.md rename docs/{cli/upgrade.md => features/upgrade/cli.md} (93%) create mode 100644 docs/features/upgrade/configuration.md create mode 100644 docs/features/upgrade/hooks.md create mode 100644 docs/features/upgrade/index.md create mode 100644 docs/features/usages/api.md rename docs/{cli/usages.md => features/usages/cli.md} (100%) create mode 100644 docs/features/usages/configuration.md create mode 100644 docs/features/usages/hooks.md create mode 100644 docs/features/usages/index.md diff --git a/.goga/tools/mkdocs/traceability.yml b/.goga/tools/mkdocs/traceability.yml index 3010ae83..1a1e1760 100644 --- a/.goga/tools/mkdocs/traceability.yml +++ b/.goga/tools/mkdocs/traceability.yml @@ -1,6 +1,7 @@ # Traceability: documentation page → cell paths # Each entry maps a doc page to the cells whose CODEMANIFEST/.usages it draws from. + README.md: - goga - goga/commands/install @@ -10,6 +11,12 @@ README.md: - goga/build - goga/commands/build - goga/ralphex + - goga/topics + - goga/commands/topics + - goga/history + - goga/commands/history + - goga/history/statuses + - goga/hooks docs/index.md: - goga @@ -20,7 +27,6 @@ docs/index.md: - goga/commands/install - goga/connect - goga/docker - docs/getting-started.md: - goga/onboarding - goga/config @@ -29,187 +35,338 @@ docs/getting-started.md: - goga/pipeline - goga/commands/pipeline -docs/cell/index.md: - - goga +docs/cli/index.md: + - goga/commands -docs/cell/codemanifest.md: +docs/features/index.md: - goga -docs/cell/usages.md: - - goga +docs/features/topics/index.md: + - goga/topics + - goga/commands/topics + - goga/history +docs/features/topics/cli.md: + - goga/commands/topics + - goga/topics + - goga/topics/git + - goga/topics/editor + - goga/history + - goga/history/statuses + - goga/config +docs/features/topics/configuration.md: + - goga/config + - goga/config/project + - goga/commands/topics +docs/features/topics/hooks.md: + - goga/history/statuses + - goga/hooks +docs/features/topics/api.md: + - goga/topics + - goga/topics/git + - goga/topics/editor -docs/configuration/index.md: +docs/features/history/index.md: + - goga/history + - goga/commands/history +docs/features/history/cli.md: + - goga/commands/history + - goga/history + - goga/history/statuses +docs/features/history/configuration.md: + - goga/history +docs/features/history/hooks.md: + - goga/history + - goga/history/statuses + - goga/hooks +docs/features/history/api.md: + - goga/history + - goga/history/git + - goga/history/statuses + +docs/features/pipelines/index.md: + - goga/pipeline + - goga/pipeline/compiler + - goga/pipeline/workflow + - goga/commands/pipeline + - goga/connect +docs/features/pipelines/cli.md: + - goga/commands/pipeline + - goga/pipeline + - goga/docker + - goga/topics +docs/features/pipelines/configuration.md: - goga/config + - goga/config/project + - goga/commands/pipeline +docs/features/pipelines/hooks.md: + - goga/pipeline + - goga/hooks +docs/features/pipelines/api.md: + - goga/pipeline + - goga/pipeline/workflow + - goga/pipeline/compiler +docs/features/pipelines/pipeline-file.md: + - goga/pipeline + - goga/pipeline/compiler +docs/features/pipelines/workflows.md: + - goga/pipeline/workflow + - goga/pipeline/compiler + - goga/commands/pipeline +docs/features/pipelines/shipped.md: + - goga/pipeline + - goga/connect + # YAML assets directory, not a CODEMANIFEST cell — rule exception. + - goga/assets/pipelines +docs/features/build/index.md: + - goga/build + - goga/commands/build + - goga/ralphex +docs/features/build/cli.md: + - goga/commands/build + - goga/build + - goga/ralphex +docs/features/build/configuration.md: + - goga/config + - goga/config/project + - goga/build +docs/features/build/hooks.md: + - goga/build + - goga/hooks +docs/features/build/api.md: + - goga/build + +docs/features/tools/index.md: + - goga/connect + - goga/commands/tool + - goga/commands/install + - goga/pipeline + - goga/hooks +docs/features/tools/cli.md: + - goga/commands/tool +docs/features/tools/configuration.md: + - goga/commands/tool + - goga/commands/install +docs/features/tools/hooks.md: + - goga/hooks + - goga/commands/install +docs/features/tools/api.md: + - goga/commands/tool + +docs/features/connect/index.md: + - goga/connect + - goga/commands/connect +docs/features/connect/cli.md: + - goga/commands/connect + - goga/connect +docs/features/connect/configuration.md: + - goga/connect + - goga/config/home +docs/features/connect/hooks.md: + - goga/connect + - goga/hooks +docs/features/connect/api.md: + - goga/connect + +docs/features/upgrade/index.md: + - goga/commands/upgrade +docs/features/upgrade/cli.md: + - goga/commands/upgrade +docs/features/upgrade/configuration.md: + - goga/commands/upgrade + - goga/config/home +docs/features/upgrade/hooks.md: + - goga/commands/upgrade +docs/features/upgrade/api.md: + - goga/commands/upgrade + +docs/features/install/index.md: + - goga/commands/install + - goga/commands/connect +docs/features/install/cli.md: + - goga/commands/install +docs/features/install/uninstall.md: + - goga/commands/install + - goga/connect +docs/features/install/configuration.md: + - goga/config + - goga/config/project + - goga/commands/install +docs/features/install/hooks.md: + - goga/commands/install +docs/features/install/api.md: + - goga/commands/install + +docs/features/init/index.md: + - goga/commands/init + - goga/onboarding + - goga/scaffold +docs/features/init/cli.md: + - goga/commands/init + - goga/onboarding + - goga/scaffold + - goga/config/git +docs/features/init/configuration.md: + - goga/onboarding + - goga/config +docs/features/init/hooks.md: + - goga/onboarding +docs/features/init/api.md: + - goga/onboarding + +docs/features/usages/index.md: + - goga/usages + - goga/commands/usages +docs/features/usages/cli.md: + - goga/commands/usages + - goga/usages +docs/features/usages/configuration.md: + - goga/config + - goga/config/project + - goga/usages +docs/features/usages/hooks.md: + - goga/usages +docs/features/usages/api.md: + - goga/usages + - goga/usages/sync + - goga/usages/status + +docs/features/schema/index.md: + - goga/schema + - goga/commands/schema +docs/features/schema/cli.md: + - goga/commands/schema + - goga/schema +docs/features/schema/configuration.md: + - goga/schema +docs/features/schema/hooks.md: + - goga/schema +docs/features/schema/api.md: + - goga/schema + +docs/features/contract/index.md: + - goga/contract + - goga/commands/contract +docs/features/contract/cli.md: + - goga/commands/contract + - goga/contract +docs/features/contract/configuration.md: + - goga/contract + - goga/config +docs/features/contract/hooks.md: + - goga/contract +docs/features/contract/api.md: + - goga/contract + - goga/contract/data + +docs/features/hooks/index.md: + - goga/hooks + - goga/commands/hooks +docs/features/hooks/cli.md: + - goga/commands/hooks + - goga/hooks +docs/features/hooks/configuration.md: + - goga/hooks +docs/features/hooks/hooks.md: + - goga/hooks + - goga/hooks/catalog + - goga/hooks/dispatch + - goga/hooks/registry + - goga/hooks/tools +docs/features/hooks/api.md: + - goga/hooks + - goga/hooks/catalog + - goga/hooks/dispatch + - goga/hooks/registry + - goga/hooks/tools + +docs/features/lint/index.md: + - goga/commands/lint + - goga/ast +docs/features/lint/cli.md: + - goga/commands/lint + - goga/ast +docs/features/lint/configuration.md: + - goga/config + - goga/config/project + - goga/commands/lint +docs/features/lint/hooks.md: + - goga/ast +docs/features/lint/api.md: + - goga/ast +docs/features/lint/errors.md: + - goga/ast/rules + - goga/ast/rules/base + - goga/ast/rules/document/imports + - goga/ast/rules/document/usages + - goga/ast/rules/document/structures + - goga/ast/rules/document/mutation + - goga/ast/rules/document/annotations + - goga/ast/rules/ast + +docs/configuration/index.md: + - goga/config docs/configuration/project.md: - goga/config + - goga/config/project - goga/docker - docs/configuration/home.md: - goga/config/home - goga/docker - docs/configuration/agents.md: - goga/config - goga/agents/wrapper - goga/docker +docs/configuration/cli.md: + - goga/commands/config + - goga/config -docs/tools.md: - - goga/connect - - goga/commands/tool - - goga/commands/install - - goga/pipeline +docs/cell/index.md: + - goga +docs/cell/codemanifest.md: + - goga +docs/cell/usages.md: + - goga docs/workflow/index.md: - goga/connect - docs/workflow/define.md: - goga/connect - docs/workflow/discover.md: - goga/connect - docs/workflow/propose.md: - goga/connect - docs/workflow/review.md: - goga/connect - docs/workflow/brainstorm.md: - goga/connect - docs/workflow/apply.md: - goga/connect - docs/workflow/design.md: - goga/connect - docs/workflow/plan.md: - goga/connect - docs/workflow/build.md: - goga/build - goga/commands/build - goga/ralphex - docs/workflow/change.md: - goga/connect - docs/workflow/accept.md: - goga/connect -docs/cli/index.md: - - goga/commands - -docs/cli/init.md: - - goga/commands/init - - goga/onboarding - - goga/scaffold - - goga/config/git - -docs/cli/lint.md: - - goga/commands/lint - - goga/ast - -docs/cli/build.md: - - goga/commands/build - - goga/build - - goga/ralphex - -docs/cli/contract.md: - - goga/commands/contract - - goga/contract - -docs/cli/config.md: - - goga/commands/config - - goga/config - -docs/cli/schema.md: - - goga/commands/schema - - goga/schema - -docs/cli/connect.md: - - goga/commands/connect - - goga/connect - -docs/cli/tool.md: - - goga/commands/tool - -docs/cli/install.md: - - goga/commands/install - -docs/cli/uninstall.md: - - goga/commands/install - - goga/connect - -docs/cli/upgrade.md: - - goga/commands/upgrade - -docs/cli/usages.md: - - goga/commands/usages - - goga/usages - -docs/cli/pipeline.md: - - goga/commands/pipeline - - goga/pipeline - - goga/docker - -docs/cli/history.md: - - goga/commands/history - - goga/history - -docs/cli/topics.md: - - goga/commands/topics - - goga/topics - - goga/topics/git - - goga/topics/editor - - goga/history - - goga/config - -docs/cli/hooks.md: - - goga/commands/hooks - - goga/hooks - -docs/pipelines/index.md: - - goga/pipeline - - goga/pipeline/compiler - - goga/pipeline/workflow - - goga/commands/pipeline - - goga/connect - -docs/pipelines/pipeline-file.md: - - goga/pipeline - - goga/pipeline/compiler - -docs/pipelines/workflows.md: - - goga/pipeline/workflow - - goga/pipeline/compiler - - goga/commands/pipeline - -docs/pipelines/shipped.md: - - goga/pipeline - - goga/connect - # YAML assets directory, not a CODEMANIFEST cell — see rule exception below. - - goga/assets/pipelines - docs/architecture/index.md: - goga/ast - docs/architecture/ast-nodes.md: - goga/ast/nodes - docs/architecture/ast-factory.md: - goga/ast/factory - docs/architecture/ast-visitor.md: - goga/ast/visitor - docs/architecture/ast-analyzer.md: - goga/ast/analyzer - docs/architecture/ast-errors.md: - goga/ast/errors - docs/architecture/validation-rules.md: - goga/ast/rules - goga/ast/rules/base @@ -219,25 +376,19 @@ docs/architecture/validation-rules.md: - goga/ast/rules/document/mutation - goga/ast/rules/document/annotations - goga/ast/rules/ast - docs/architecture/contract-extraction.md: - goga/contract - goga/contract/data docs/languages/index.md: - goga/contract - docs/languages/python.md: - goga/contract/python - docs/languages/golang.md: - goga/contract/golang - docs/languages/kotlin.md: - goga/contract/kotlin - docs/languages/swift.md: - goga/contract/swift - docs/languages/javascript.md: - goga/contract/javascript diff --git a/README.md b/README.md index cb9ee4a5..dea432c9 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ AI development without a framework collapses into uncoordinated agent runs — t </tr> </table> -[Documentation](https://qarium.github.io/goga/) · [Getting Started](https://qarium.github.io/goga/getting-started/) · [Pipelines](https://qarium.github.io/goga/pipelines/) · [Tools](https://qarium.github.io/goga/tools/) · [Configuration](https://qarium.github.io/goga/configuration/) +[Documentation](https://qarium.github.io/goga/) · [Getting Started](https://qarium.github.io/goga/getting-started/) · [Pipelines](https://qarium.github.io/goga/features/pipelines/) · [Tools](https://qarium.github.io/goga/features/tools/) · [Configuration](https://qarium.github.io/goga/configuration/) </div> @@ -89,7 +89,7 @@ To upgrade goga later and re-sync all connected agents, use: goga upgrade ``` -To stay within your current version line while upgrading, use `goga upgrade --patch` (latest patch of the installed minor line) or `goga upgrade --minor` (latest release of the installed major line). See [`goga upgrade`](https://qarium.github.io/goga/cli/upgrade/) for the full surface. +To stay within your current version line while upgrading, use `goga upgrade --patch` (latest patch of the installed minor line) or `goga upgrade --minor` (latest release of the installed major line). See [`goga upgrade`](https://qarium.github.io/goga/features/upgrade/cli/) for the full surface. ## Quick start @@ -101,7 +101,7 @@ Start a new project from scratch and ship your first piece of work end-to-end. goga init ``` -You can also start from a [copier](https://copier.readthedocs.io/) template (`goga init <template-url>`, optionally pinned with `#ref` or `--ref`), and later migrate a scaffolded project with `goga init --upgrade`. See [`goga init`](https://qarium.github.io/goga/cli/init/) for the full surface. +You can also start from a [copier](https://copier.readthedocs.io/) template (`goga init <template-url>`, optionally pinned with `#ref` or `--ref`), and later migrate a scaffolded project with `goga init --upgrade`. See [`goga init`](https://qarium.github.io/goga/features/init/cli/) for the full surface. **2. Open your agent** — launch the agent you connected via `goga connect` (e.g., Claude Code) in the project directory. All `goga-<command>` skills are now available. @@ -116,7 +116,7 @@ goga pipeline review # scoped review of code, contracts, docs, then lint goga pipeline sync # sync specifications & tests with the code after changes ``` -Each pipeline is a flat YAML file describing the stages; layer project-specific behavior on top via an optional [workflow](https://qarium.github.io/goga/pipelines/workflows/) file (per-stage agent, additional skills, prompt context, loop expansion, auto-approval, manual stage launch, stage skipping, note buttons, new stages, project-memory participation). +Each pipeline is a flat YAML file describing the stages; layer project-specific behavior on top via an optional [workflow](https://qarium.github.io/goga/features/pipelines/workflows/) file (per-stage agent, additional skills, prompt context, loop expansion, auto-approval, manual stage launch, stage skipping, note buttons, new stages, project-memory participation). **4. Drive the cycle by hand (optional)** — if you want explicit control over each step instead of running a full pipeline, formulate the task and step through each command manually: @@ -128,7 +128,7 @@ Each pipeline is a flat YAML file describing the stages; layer project-specific propose → brainstorm → apply → design → plan → goga build → change → accept ``` -The slash-command form `/goga:<command>` works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](https://qarium.github.io/goga/cli/connect/)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-propose` (Codex uses the `$` prefix — `$goga-propose`). Reviews are optional at every stage. +The slash-command form `/goga:<command>` works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](https://qarium.github.io/goga/features/connect/cli/)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-propose` (Codex uses the `$` prefix — `$goga-propose`). Reviews are optional at every stage. **5. Visualize the result** — once `apply` has produced cells on disk, inspect the architecture: @@ -154,11 +154,11 @@ goga topics delete feat-x # delete the branch, its origin twin, and the di goga topics --year 2025 board # the board of an explicit year ``` -Every `create` needs a base: `--base-ref`, or `topics.base_ref` in `.goga/config.yml`, or the current HEAD under `--from-current`. The default creation quarantines the topic into the branch — one commit carrying the topic's `todo.md` on top of the base — while you stay on your branch; the todo is required there, so with no `-t` given a terminal opens the external editor for the todo, and once a todo is resolved the command asks on a terminal whether to publish. `-s`/`--switch` checks out the fresh branch instead — the topic directory and `todo.md` land in the working copy uncommitted, and the todo is optional. `--publish`/`-p` is the fast mode: it builds the branch off the resolved base with a single `todo.md` commit and pushes it to `origin` without switching — your working copy, index, and HEAD stay untouched, and a failed push rolls the branch back. See [`goga topics`](https://qarium.github.io/goga/cli/topics/). +Every `create` needs a base: `--base-ref`, or `topics.base_ref` in `.goga/config.yml`, or the current HEAD under `--from-current`. The default creation quarantines the topic into the branch — one commit carrying the topic's `todo.md` on top of the base — while you stay on your branch; the todo is required there, so with no `-t` given a terminal opens the external editor for the todo, and once a todo is resolved the command asks on a terminal whether to publish. `-s`/`--switch` checks out the fresh branch instead — the topic directory and `todo.md` land in the working copy uncommitted, and the todo is optional. `--publish`/`-p` is the fast mode: it builds the branch off the resolved base with a single `todo.md` commit and pushes it to `origin` without switching — your working copy, index, and HEAD stay untouched, and a failed push rolls the branch back. See [`goga topics`](https://qarium.github.io/goga/features/topics/cli/). The board is a three-column table — topic, branch, statuses, plus a todo column under `--info` — with `*` marking the current branch and a local branch absorbing its remote twin. Each topic carries its **maximal statuses** in scale order: `empty → todo → defined → discovered → backlog → designed → specified → planned → done`, deepening as `todo.md`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. A topic can carry several statuses at once (`goga history status` prints them; `-s` filters by any of them). -Topics no branch hosts anymore are orphans — [`goga history`](https://qarium.github.io/goga/cli/history/) `prune --dry-run` lists the orphans of a year, and `goga history -y <year> prune` deletes them (the year is the group's `-y`/`--year` option, given once before the subcommand; irreversibly: the history tree is not in git). +Topics no branch hosts anymore are orphans — [`goga history`](https://qarium.github.io/goga/features/history/cli/) `prune --dry-run` lists the orphans of a year, and `goga history -y <year> prune` deletes them (the year is the group's `-y`/`--year` option, given once before the subcommand; irreversibly: the history tree is not in git). To resume work inside a pipeline, pass the identifier to the run — `goga pipeline development -t feat/x` switches to the hosting branch first (creating a local branch from its remote-tracking ref when needed) and is an idempotent no-op when you are already on it; adding `--todo` opens the topic's `todo.md` in your editor after the switch. Fresh work is better started with `goga topics create` — it takes an explicit base, todo, and publication; a pipeline `-t` creates from the current HEAD when nothing hosts the identifier. @@ -312,7 +312,7 @@ stages: file: shared.md ``` -Additionally: `skip: true` removes a stage with transparent reconnection of dependents, and `extend:` adds brand-new stages with `before`/`after` positioning (a new stage's own launch mode is authored in its body via `trigger: manual`). Names under `stages:` must name stages of the target pipeline — `propose` exists only in `refinement`, `brainstorm` only in `development`; brand-new stages come via `extend:`. The full model is in the [Workflows](https://qarium.github.io/goga/pipelines/workflows/) documentation. Workflow memory requires afm 0.5.60+ (the shipped image carries it). +Additionally: `skip: true` removes a stage with transparent reconnection of dependents, and `extend:` adds brand-new stages with `before`/`after` positioning (a new stage's own launch mode is authored in its body via `trigger: manual`). Names under `stages:` must name stages of the target pipeline — `propose` exists only in `refinement`, `brainstorm` only in `development`; brand-new stages come via `extend:`. The full model is in the [Workflows](https://qarium.github.io/goga/features/pipelines/workflows/) documentation. Workflow memory requires afm 0.5.60+ (the shipped image carries it). Run with a workflow: @@ -322,7 +322,7 @@ goga pipeline development --workflow custom # explicit goga pipeline development --no-workflow # disable workflow application entirely ``` -Read the full functional model in the [Pipelines](https://qarium.github.io/goga/pipelines/) section of the docs. +Read the full functional model in the [Pipelines](https://qarium.github.io/goga/features/pipelines/) section of the docs. ## Tools @@ -357,7 +357,7 @@ goga connect <agent> Pass `goga install --no-connect` to opt out of the post-install agent re-sync (CI/Docker escape-hatch; the post-install hooks still run). Pass `goga install --sudo` for system-Python installs requiring root. -See [`goga install`](https://qarium.github.io/goga/cli/install/) for the full version-grammar rules and single/bulk/empty/local semantics. +See [`goga install`](https://qarium.github.io/goga/features/install/cli/) for the full version-grammar rules and single/bulk/empty/local semantics. ### Removing a tool @@ -380,7 +380,7 @@ goga uninstall <tool-name> --user alice After a successful pip uninstall, every connected agent is re-synced: the removed tool's skills and pipelines disappear from `~/.goga/` and from each agent's symlink tree. A tool removed by hand with plain pip leaves those artifacts behind until the next re-sync. -See [`goga uninstall`](https://qarium.github.io/goga/cli/uninstall/) for the full confirmation, sudo/user, and exit-code semantics. +See [`goga uninstall`](https://qarium.github.io/goga/features/install/uninstall/) for the full confirmation, sudo/user, and exit-code semantics. ### Using a tool @@ -446,7 +446,7 @@ def register_published(context): context.register("published", "mkdocs/published.md", after="planned") ``` -The hook receives the delivered status registry through `context` — read and call freely, attribute assignment is blocked. The name is shown qualified as `<tool>.<name>` (here `mkdocs.published`); the tool identity is the package name with the `goga_tool_` prefix dropped and underscores turned into hyphens, so `goga_tool_hello_world` registers `hello-world.*`. The filepath is relative to the topic directory (nested paths allowed), and `before=`/`after=` anchor the entry to an existing scale entry — at least one anchor is required, both define a range. Built-in entries are immutable. A bad registration — an unknown anchor, an invalid range, or a crashed hook — is skipped with a warning on stderr and never aborts the command; only a package that fails to import is fatal. Run [`goga hooks`](https://qarium.github.io/goga/cli/hooks/) to inspect what is registered. The removed `register_topic_statuses(statuses)` callback is no longer called — a package still carrying it loses its statuses silently after the update. +The hook receives the delivered status registry through `context` — read and call freely, attribute assignment is blocked. The name is shown qualified as `<tool>.<name>` (here `mkdocs.published`); the tool identity is the package name with the `goga_tool_` prefix dropped and underscores turned into hyphens, so `goga_tool_hello_world` registers `hello-world.*`. The filepath is relative to the topic directory (nested paths allowed), and `before=`/`after=` anchor the entry to an existing scale entry — at least one anchor is required, both define a range. Built-in entries are immutable. A bad registration — an unknown anchor, an invalid range, or a crashed hook — is skipped with a warning on stderr and never aborts the command; only a package that fails to import is fatal. Run [`goga hooks`](https://qarium.github.io/goga/features/hooks/cli/) to inspect what is registered. The removed `register_topic_statuses(statuses)` callback is no longer called — a package still carrying it loses its statuses silently after the update. After publication, install into any project: @@ -457,7 +457,7 @@ goga pipeline acme:spec # namespaced pipeline from the tool The subcommands become ordinary agent skills — `goga-tool-acme-explore`, `goga-tool-acme-propose`, `goga-tool-acme-apply`, `goga-tool-acme-archive` — that can be invoked directly (`/goga:tool acme explore`, `goga-tool-acme-explore`, or `$goga-tool-acme-explore` in Codex) or merged into any stage of any pipeline via `skills:` in a workflow-file. The `acme` cycle `explore → propose → apply → archive` can be run end-to-end through `acme:spec`, woven stage-by-stage into the SDD cycle, or composed into a custom pipeline where `acme-propose` runs next to `goga-brainstorm`. -The entry point may optionally declare a keyword-capable `ast` parameter to receive the project AST (loaded lazily from the current project root, only when declared). A tool that does not need the AST keeps the minimal `main(argv)` form and the AST is never built. See [`goga tool`](https://qarium.github.io/goga/cli/tool/) for the entry-point forms and opt-in rules. +The entry point may optionally declare a keyword-capable `ast` parameter to receive the project AST (loaded lazily from the current project root, only when declared). A tool that does not need the AST keeps the minimal `main(argv)` form and the AST is never built. See [`goga tool`](https://qarium.github.io/goga/features/tools/cli/) for the entry-point forms and opt-in rules. ### Skill naming @@ -484,9 +484,9 @@ Tool pipelines are namespaced on install. A file `<name>.yml` in a tool's `pipel | Internal goga source (`goga/assets/pipelines/`) | `development.yml` (un-prefixed) | `goga pipeline development` | | Tool package `goga_tool_acme/pipelines/deploy.yml` | `acme:deploy.yml` | `goga pipeline acme:deploy` | -Namespacing structurally prevents collisions — between a tool pipeline and an internal-source pipeline, and between two tools shipping the same name. See [Shipped Pipelines](https://qarium.github.io/goga/pipelines/shipped/) for the full installation algorithm. +Namespacing structurally prevents collisions — between a tool pipeline and an internal-source pipeline, and between two tools shipping the same name. See [Shipped Pipelines](https://qarium.github.io/goga/features/pipelines/shipped/) for the full installation algorithm. -Read the full Tools model in the [Tools](https://qarium.github.io/goga/tools/) section of the docs. +Read the full Tools model in the [Tools](https://qarium.github.io/goga/features/tools/) section of the docs. ## SDD — the reference cycle @@ -630,7 +630,7 @@ stages: approve: auto ``` -These are not special "SDD extension points" — they are exactly the same workflow mechanisms from the Pipelines section, applied to the SDD cycle. Combining tools and workflows, SDD can be compressed to `propose → accept` for prototypes or expanded with threat-modelling, security review, and compliance gates for production. Read the full functional model in the [Workflows](https://qarium.github.io/goga/pipelines/workflows/) section of the docs. +These are not special "SDD extension points" — they are exactly the same workflow mechanisms from the Pipelines section, applied to the SDD cycle. Combining tools and workflows, SDD can be compressed to `propose → accept` for prototypes or expanded with threat-modelling, security review, and compliance gates for production. Read the full functional model in the [Workflows](https://qarium.github.io/goga/features/pipelines/workflows/) section of the docs. ## Build @@ -664,7 +664,7 @@ Both review bounds apply to review-carrying passes only: the single full-cycle p A running build executes inside a Docker container, where its run-state and logs are written to a persistent host directory and survive across runs of the same project on the same branch — so an interrupted build can be resumed. Pass `--clean` (or `-c`) to wipe that state before launch for a fresh run. After the build, test the implementation manually. -See [`goga build`](https://qarium.github.io/goga/cli/build/) for the full CLI reference, configuration, and exit codes. +See [`goga build`](https://qarium.github.io/goga/features/build/cli/) for the full CLI reference, configuration, and exit codes. ## Documentation diff --git a/docs/cli/index.md b/docs/cli/index.md index 96e3b5db..389a45a3 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -2,6 +2,8 @@ Goga is a command-line tool built with [Click](https://click.palletsprojects.com/) for validating and managing CODEMANIFEST-based projects. +This page is the **command cross-road**: every command of the root `goga` group, mapped to the functional domain that owns it. The full reference of each command — synopsis, options, behavior, exit codes — lives in its domain's **CLI** page under [Features](../features/index.md). + ## Installation ```bash @@ -22,24 +24,24 @@ python -m goga --help ## Commands -| Command | Description | -|---|---| -| [`goga init`](init.md) | Interactive project initialization | -| [`goga install`](install.md) | Install goga-tool packages into the current interpreter and re-sync connected agents | -| [`goga uninstall`](uninstall.md) | Remove a goga-tool package from the current interpreter and re-sync connected agents | -| [`goga lint`](lint.md) | Validate CODEMANIFEST files | -| [`goga build`](build.md) | Execute build plan via a ralph-loop | -| [`goga contract`](contract.md) | Compare CODEMANIFEST with implementation | -| [`goga config`](config.md) | Display configuration values | -| [`goga schema`](schema.md) | Generate JSON schema from project cells | -| [`goga connect`](connect.md) | Install goga skills for AI agents | -| [`goga upgrade`](upgrade.md) | Upgrade goga and re-sync connected agents | -| [`goga usages`](usages.md) | Sync cell-level usages from declared git dependencies and check their status against the remote | -| [`goga pipeline`](pipeline.md) | Run a goga pipeline, or inspect the available ones (`--list`, `--info`) | -| [`goga history`](history.md) | Work with the `.goga/history/` tree (`list`, `status`, `path`, `ensure`, `prune`) | -| [`goga topics`](topics.md) | Work with the topics of one year (`board`, `create`, `switch`, `delete`) | -| [`goga tool`](tool.md) | Dynamic tool package invocation | -| [`goga hooks`](hooks.md) | Inspect the hooks registered by installed tool packages | +| Command | Domain | Description | +|---|---|---| +| [`goga init`](../features/init/cli.md) | [Init](../features/init/index.md) | Interactive project initialization | +| [`goga install`](../features/install/cli.md) | [Install](../features/install/index.md) | Install goga-tool packages into the current interpreter and re-sync connected agents | +| [`goga uninstall`](../features/install/uninstall.md) | [Install](../features/install/index.md) | Remove a goga-tool package from the current interpreter and re-sync connected agents | +| [`goga lint`](../features/lint/cli.md) | [Lint](../features/lint/index.md) | Validate CODEMANIFEST files | +| [`goga build`](../features/build/cli.md) | [Build](../features/build/index.md) | Execute build plan via a ralph-loop | +| [`goga contract`](../features/contract/cli.md) | [Contract](../features/contract/index.md) | Compare CODEMANIFEST with implementation | +| [`goga config`](../configuration/cli.md) | [Configuration](../configuration/index.md) | Display configuration values | +| [`goga schema`](../features/schema/cli.md) | [Schema](../features/schema/index.md) | Generate JSON schema from project cells | +| [`goga connect`](../features/connect/cli.md) | [Connect](../features/connect/index.md) | Install goga skills for AI agents | +| [`goga upgrade`](../features/upgrade/cli.md) | [Upgrade](../features/upgrade/index.md) | Upgrade goga and re-sync connected agents | +| [`goga usages`](../features/usages/cli.md) | [Usages](../features/usages/index.md) | Sync cell-level usages from declared git dependencies and check their status against the remote | +| [`goga pipeline`](../features/pipelines/cli.md) | [Pipelines](../features/pipelines/index.md) | Run a goga pipeline, or inspect the available ones (`--list`, `--info`) | +| [`goga history`](../features/history/cli.md) | [History](../features/history/index.md) | Work with the `.goga/history/` tree (`list`, `status`, `path`, `ensure`, `prune`) | +| [`goga topics`](../features/topics/cli.md) | [Topics](../features/topics/index.md) | Work with the topics of one year (`board`, `create`, `switch`, `delete`) | +| [`goga tool`](../features/tools/cli.md) | [Tools](../features/tools/index.md) | Dynamic tool package invocation | +| [`goga hooks`](../features/hooks/cli.md) | [Hooks](../features/hooks/index.md) | Inspect the hooks registered by installed tool packages | ## Global Options @@ -72,4 +74,4 @@ The option belongs to the root group and is processed eagerly, before any subcom When the installed version cannot be determined (goga is not installed for the current interpreter, or its metadata is broken), the command fails cleanly: a one-line `Error: cannot determine the installed goga version (...)` message on stderr, exit code `1`, no traceback. -The flag takes no part in the host–image version check performed before container launches — that check is part of [`goga build`](build.md) and [`goga pipeline`](pipeline.md). +The flag takes no part in the host–image version check performed before container launches — that check is part of [`goga build`](../features/build/cli.md) and [`goga pipeline`](../features/pipelines/cli.md). diff --git a/docs/configuration/agents.md b/docs/configuration/agents.md index 05522f5a..b7421215 100644 --- a/docs/configuration/agents.md +++ b/docs/configuration/agents.md @@ -23,7 +23,7 @@ Edge cases: | Wrapper file missing in image (custom Dockerfile forgot `COPY`) | Path resolves but the file is absent. | Runtime error inside the container. No upfront validation by goga. | | Wrapper present but not executable (forgot `chmod +x`) | Permission denied. | Runtime error inside the container. | | `cursor` configured without `CURSOR_API_KEY` | The wrapper is env-based, not credential-file-based — there is no credential mount to fall back on. | Wrapper exits with error (`CURSOR_API_KEY is required`). See [cursor](#cursor). | -| `workflow.stages.<name>.agent: <unknown>` | Wrapper path is composed verbatim; no validation against a known agent set. | Runtime error inside the container. See [workflows](../pipelines/workflows.md#workflow-agent-choosing-the-cli-agent). | +| `workflow.stages.<name>.agent: <unknown>` | Wrapper path is composed verbatim; no validation against a known agent set. | Runtime error inside the container. See [workflows](../features/pipelines/workflows.md#workflow-agent-choosing-the-cli-agent). | ## Baseline wrappers @@ -131,4 +131,4 @@ If `agent: myname` is set but the wrapper is not `COPY`'d into the image or is n ## Relationship to `goga connect` -> **Two different `agent` concepts.** The runtime `agent` (this section) picks which CLI binary runs **inside the goga Docker container** during `goga build` / `goga pipeline`. [`goga connect`](../cli/connect.md) is a separate, host-side mechanism that installs goga skills and commands **into** an AI agent (claude/codex/cursor/opencode/qwen) as a target. They are orthogonal: you can run `goga connect claude codex` to get goga skills inside both of your host-installed CLIs, and still set `build.task_executor.agent: codex` — in that case the codex wrapper runs inside the container, not your host-side CLI. +> **Two different `agent` concepts.** The runtime `agent` (this section) picks which CLI binary runs **inside the goga Docker container** during `goga build` / `goga pipeline`. [`goga connect`](../features/connect/cli.md) is a separate, host-side mechanism that installs goga skills and commands **into** an AI agent (claude/codex/cursor/opencode/qwen) as a target. They are orthogonal: you can run `goga connect claude codex` to get goga skills inside both of your host-installed CLIs, and still set `build.task_executor.agent: codex` — in that case the codex wrapper runs inside the container, not your host-side CLI. diff --git a/docs/cli/config.md b/docs/configuration/cli.md similarity index 100% rename from docs/cli/config.md rename to docs/configuration/cli.md diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 6e220ebc..03d32370 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -7,6 +7,15 @@ goga reads configuration from two files: the **project** config `.goga/config.ym | [Project Configuration](project.md) — `.goga/config.yml` | One project: language, image, build and pipeline executors, codemanifest, tools, usages, lint, topics | Required for `goga build` / `goga pipeline` | | [Home Configuration](home.md) — `~/.goga/config.yml` | Whole machine: base env layer, extra `docker run` / `docker build` arguments | Optional — absent by default | +## Pages in this section + +| Page | Content | +|---|---| +| [Project Configuration](project.md) | `.goga/config.yml` — global fields, the `codemanifest` section, and the map of domain-owned sections | +| [Home Configuration](home.md) | `~/.goga/config.yml` — the machine-wide layer | +| [Agents](agents.md) | How `agent: <name>` values in both configs resolve into wrapper scripts inside the Docker container | +| [`goga config`](cli.md) | Read configuration values back from the command line | + The home config is the lower-priority layer: `home.env` is the base of the env layering formula `{**home.env, **project_env, **cli_env}`, and `docker.run` / `docker.build` fragments are appended to every container invocation regardless of the project. See [Home Configuration](home.md#env-layering) for the layering details. -See also [Agents](agents.md) for how `agent: <name>` values in both configs resolve into wrapper scripts inside the Docker container. +Every domain-owned section of the project config (`build`, `pipeline`, `tools`, `usages`, `lint`, `topics`) is documented in full in its domain's **Configuration** page — see [Project Configuration — Domain sections](project.md#domain-sections). diff --git a/docs/configuration/project.md b/docs/configuration/project.md index 8bd05f70..0bb6d6a2 100644 --- a/docs/configuration/project.md +++ b/docs/configuration/project.md @@ -2,6 +2,8 @@ goga reads project configuration from `.goga/config.yml` in the project root. This file is created by `goga init` and can be edited manually. +The page covers the **global** fields and the sections that belong to no single domain. Every domain-owned section (`build`, `pipeline`, `tools`, `usages`, `lint`, `topics`) is documented in full in its domain's **Configuration** page — see [Domain sections](#domain-sections). + ## File location ``` @@ -10,7 +12,7 @@ goga reads project configuration from `.goga/config.yml` in the project root. Th The config loader looks for this file relative to the current working directory. -For the machine-wide `~/.goga/config.yml`, see [Home Configuration](home.md). +For the machine-wide `~/.goga/config.yml`, see [Home Configuration](home.md). To read values back from the command line, see [`goga config`](cli.md). ## Example configuration @@ -91,110 +93,39 @@ codemanifest: | Field | Type | Required | Description | |-------|------|----------|-------------| | `language` | `string` | Yes | Project language. One of: `python`, `golang`, `kotlin`, `swift`, `javascript` | -| `image` | `string` | No | Docker image used by `goga build` and `goga pipeline` (e.g. `qarium/goga-python-3.14:1.3`). Consumers raise an error when it is unset. | +| `image` | `string` | No | Docker image used by `goga build` and `goga pipeline` (e.g. `qarium/goga-python-3.14:1.3`). Consumers raise an error when it is unset. The deprecated `build.image` field is rejected — set this top-level field instead | | `dockerfile` | `string` | No | Path to a project Dockerfile. When set, `goga build --update` and `goga pipeline --update` build the image locally from this Dockerfile (fatal on build failure). When unset (default), `--update` pulls `image` from the registry instead (non-fatal warning on pull failure) | -| `build` | mapping | No | Build pipeline settings. Optional at the loader level; `goga build` raises a `ClickException` when the section is absent | -| `pipeline` | mapping | No | Pipeline (afm) execution settings. Optional at the loader level; `goga pipeline` raises a `ClickException` when the section is absent | | `commands` | mapping | No | Reserved for future prompt customization. Defaults to `{}` | -| `codemanifest` | mapping | No | Global codemanifest configuration | -| `tools` | mapping | No | goga-tool version declarations consumed by `goga install` in bulk mode. Keys are tool names (without the `goga-tool-` prefix); values are version-form strings. Values are stored verbatim — the four-form grammar (`1.0.x`, `1.x`, `1.0.1`, `latest`) is validated by `goga install`, not the loader. Defaults to `None` (absent); an empty mapping is `{}`. YAML-null values (`viewer:`) are rejected | -| `usages` | mapping | No | Git dependencies whose cell-level `.usages/` files are synced into `.goga/usages/<group>/<dep>/` by [`goga usages sync`](../cli/usages.md) and checked for drift against the remote by [`goga usages status`](../cli/usages.md). Two-level mapping: `<group>` → `<dep>` → `{ git, ref, root }`. Defaults to `None` (absent), which makes `goga usages sync` a no-op (exit 0); an empty mapping is `{}`. `<group>` and `<dep>` keys are validated as filesystem path segments — empty, `.` / `..`, or any name containing `/` or `\` raise `ValueError` | -| `lint` | mapping | No | Optional linter section consumed by [`goga lint`](../cli/lint.md). Currently holds `ignore`, a list of directory relative paths to prune from lint traversal. Defaults to `None` (absent); an empty mapping is equivalent to no ignore list. Structural type errors (non-mapping `lint`, non-list `lint.ignore`, or a non-string element) raise `ValueError` | -| `topics` | mapping | No | Topic creation base and publication template section consumed by [`goga topics create`](../cli/topics.md). Defaults to `None` (absent); a present-but-empty mapping is a `TopicsConfig` with both fields `None`. A non-mapping value raises `ValueError` | - -### build - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `task_executor` | mapping | Yes | AI agent configuration | -| `worktree` | `bool` | No | Use isolated git worktree for builds | -| `skip_finalize` | `bool` | No | Skip the ralph-loop finalization step | -| `session_timeout` | `string` | No | Session timeout in Go duration format (e.g. `30m`, `1h`) | -| `idle_timeout` | `string` | No | Idle timeout in Go duration format | -| `wait` | `string` | No | Wait time on rate limit in Go duration format | -| `max_iterations` | `int` | No | Maximum task iterations | -| `prompts_dir` | `string` | No | Path to custom ralph-loop prompts | -| `agents_dir` | `string` | No | Path to custom ralph-loop agents | -| `codex_review` | `bool` | No | Enable external codex review | -| `proxy` | `string` | No | HTTP/HTTPS proxy URL for the build container. When set, `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` are written to the container env-file. Overridden by the `--proxy` CLI option | -| `hosts` | mapping | No | Host→IP mapping for `docker run --add-host`. Defaults to `{}`. Augmented by the repeatable `--add-host` CLI option (CLI wins on key conflict) | -| `review_executor` | mapping | No | Review-phase configuration. See [build.review_executor](#buildreview_executor) | - -> The deprecated `build.image` field is rejected with a `ValueError`. Set the top-level `image` field instead. - -### build.task_executor - -| Field | Type | Required | Description | -|---------|----------|-----------|-------------------------------------------------------------------------------------------------------------------------| -| `agent` | `string` | No | AI executor that runs the build inside the container. Optional at the loader level — absent/YAML-null/empty/whitespace resolves to `None`; `goga build` raises a `ClickException` when it is `None` (the build needs an agent to resolve the in-container wrapper). Resolved to `/home/goga/bin/<agent>-as-claude.sh` — no whitelist; any name whose wrapper file exists in the image works. Baseline wrappers: `claude`, `codex`, `cursor`, `opencode`, `qwen`. See [Agents](./agents.md) for the resolution mechanic, per-agent env variables, and how to add a custom agent. | -| `env` | mapping | No | Environment variables passed to the agent. Keys and values must be strings. Defaults to `{}` | - -### build.review_executor - -Optional section controlling the review phase of `goga build`. When absent, the full cycle (tasks + review) runs in a single pass with the task executor's wrapper. - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `skip` | `bool` | No | Skip the review phase entirely — the run executes tasks only (ralph-loop `--tasks-only`). Absent/YAML-null means "not set" (the CLI flag decides); must be a real bool — a YAML `1` is rejected | -| `agent` | `string` | No | Review executor agent name (same resolution mechanic as `build.task_executor.agent`; its wrapper must exist in the image). When it differs from `task_executor.agent`, **or when a non-empty `env` is declared alongside it**, the build runs two passes: tasks with the task wrapper, then the review pass with the review wrapper. Combining either two-pass form with an active worktree (`--worktree` or `build.worktree: true`) is rejected with exit 1 on the host | -| `roles` | list of `string` | No | Reviewer composition for the review prompts: keeps only the `{{agent:X}}` lines of the selected roles and adapts the counters of the accompanying text. Whitelist: `quality`, `implementation`, `testing`, `simplification`, `documentation`. Absent or `[]` means the full default set (prompts stay byte-identical to the vendored defaults) | -| `env` | mapping of `string` | No | Review-pass environment layer (`{str: str}`). Keys overlay same-named container variables for the review-pass subprocess only — the tasks pass and the container env-file are unaffected, and the values never reach logs or dry-run output. Absent/YAML-null/`{}` all resolve to `{}` (unlike `build.task_executor.env`, where YAML-null is an error). A non-empty `env` induces a two-pass run like a differing agent does, and requires `agent` — a non-empty `env` without `agent` fails in-container validation when the review phase runs; a skipped run ignores the layer entirely | -| `base_ref` | `string` | No | Review diff base — a branch name or commit hash, stored verbatim (no resolvability or format check; ralphex owns the diagnostics). Overrides ralphex's default-branch detection on review-carrying passes. Absent/YAML-null/empty/whitespace resolves to `None`. Overridden by the `--base-ref` CLI option | -| `patience` | `int` | No | Stop the external review after N consecutive unchanged rounds. Absent/YAML-null resolves to `None`; a YAML boolean is rejected. Moved from `build.review_patience`, which is no longer parsed. Overridden by the `--review-patience` CLI option | - -Precedence: the `--skip-review`/`--no-skip-review` CLI pair overrides `skip`; an explicit `--no-skip-review` forces the full cycle even when the config sets `skip: true`. Role names, the env-requires-agent rule, and the review wrapper are validated in-container before any pass runs — but only when the review phase will actually run (a skipped run never validates them). - -### pipeline - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `agent` | `string` | No | AI agent that runs the pipeline stages inside the container. Optional at the loader level — absent/YAML-null/empty/whitespace resolves to `None`. When `None`, the agent may be supplied by a per-stage workflow override (see [Workflows](../pipelines/workflows.md)) or afm's own default, so `goga pipeline` does not require it. Same resolution mechanic and baseline set as `build.task_executor.agent` — see [Agents](./agents.md). | -| `env` | mapping | No | Environment variables passed into the pipeline container. Keys and values must be strings. Defaults to `{}` | -| `proxy` | `string` | No | HTTP/HTTPS proxy URL for the pipeline container. When set, `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` are written to the container env-file. Overridden by the `--proxy` CLI option | -| `hosts` | mapping | No | Host→IP mapping for `docker run --add-host`. Defaults to `{}`. Augmented by the repeatable `--add-host` CLI option (CLI wins on key conflict) | +| `codemanifest` | mapping | No | Global codemanifest configuration — see [codemanifest](#codemanifest) | +| `build` | mapping | No | Build pipeline settings — see [Build — Configuration](../features/build/configuration.md) | +| `pipeline` | mapping | No | Pipeline (afm) execution settings — see [Pipelines — Configuration](../features/pipelines/configuration.md) | +| `tools` | mapping | No | goga-tool version declarations for bulk install — see [Install — Configuration](../features/install/configuration.md) | +| `usages` | mapping | No | Git dependencies of cell-level usages — see [Usages — Configuration](../features/usages/configuration.md) | +| `lint` | mapping | No | Linter ignore list — see [Lint — Configuration](../features/lint/configuration.md) | +| `topics` | mapping | No | Topic creation base and publication template — see [Topics — Configuration](../features/topics/configuration.md) | + +### Domain sections + +Each domain-owned section is documented in full — every field, typing rule, and CLI precedence — in its domain's **Configuration** page: + +| Section | Domain | Consumed by | +|---|---|---| +| `build` (incl. `task_executor`, `review_executor`) | [Build](../features/build/configuration.md) | `goga build` | +| `pipeline` | [Pipelines](../features/pipelines/configuration.md) | `goga pipeline` | +| `tools` | [Install](../features/install/configuration.md) | `goga install` (bulk mode) | +| `usages` | [Usages](../features/usages/configuration.md) | `goga usages sync` / `goga usages status` | +| `lint` | [Lint](../features/lint/configuration.md) | `goga lint` | +| `topics` | [Topics](../features/topics/configuration.md) | `goga topics create` | ### codemanifest +The `codemanifest` section is global — it belongs to no single domain. It feeds every CODEMANIFEST of the project (see [Cell](../cell/index.md)). + | Field | Type | Required | Description | |-------|------|----------|-------------| | `usages` | mapping | No | Named practices available in CODEMANIFEST files. Format: `{name: path/to/file.md}`. Defaults to `{}` | | `annotations` | `string` | No | Free-text instructions for AI agents. Defaults to `None` | -### usages - -Git dependencies whose cell-level `.usages/` files are synced into `.goga/usages/<group>/<dep>/` by [`goga usages sync`](../cli/usages.md) and checked for drift against the remote by [`goga usages status`](../cli/usages.md). - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `usages.<group>` | mapping | Yes when `usages` present | Group bucket. The key becomes a top-level subdirectory of `.goga/usages/`. Validated as a path segment (no empty / `.` / `..` / `/` / `\`). | -| `usages.<group>.<dep>` | mapping | Yes when `<group>` present | Dependency entry. The key becomes a subdirectory under the group. Same path-segment validation. | -| `usages.<group>.<dep>.git` | `string` | Yes | Git URL of the source repository. Must be non-empty. | -| `usages.<group>.<dep>.ref` | `string` | No | Git ref — branch, tag, or commit. `None` (omitted) clones the default branch. | -| `usages.<group>.<dep>.root` | `string` | No | Subpath inside the clone to discover `.usages` folders from. Absent (or an empty string) → clone root. Must be relative; no `..` or absolute paths (leading `/` or UNC `//host/share`). | - -When `usages` is absent, `config.usages` is `None` and `goga usages sync` exits `0` without invoking git. A present-but-non-mapping value raises `ValueError`. - -### lint - -Optional section consumed by [`goga lint`](../cli/lint.md) to prune directories from validation. - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `lint.ignore` | list of strings | No | Directory relative paths to skip during lint traversal, stored verbatim. A directory matches when its exact normalized relative path equals an entry; glob patterns are not interpreted and a trailing separator is insignificant. Defaults to `[]` when `lint` is present but `ignore` is absent | - -When `lint` is absent, `config.lint` is `None` and `goga lint` lints every directory. A present-but-non-mapping `lint`, a non-list `lint.ignore`, or a non-string element raises `ValueError`. The `lint` command derives `ignore` **tolerantly** — any loader error falls back to no filtering rather than failing the lint run. - -### topics - -Optional section consumed by [`goga topics create`](../cli/topics.md). Read lazily — only when a value that no CLI flag supplied has to come from it. - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `topics.base_ref` | `string` | No | Base revision of a created topic branch — any revision string (branch, remote-tracking ref, tag, hash), stored verbatim with no resolvability check. Absent/YAML-null/empty/whitespace resolves to `None`; a non-string raises `ValueError`. Overridden by the `--base-ref` CLI option; the base resolves as `--base-ref` > `topics.base_ref` > the current HEAD under `--from-current` — a creation with none of the three exits 1 | -| `topics.publish_commit` | `string` | No | Commit message template of the published todo commit; the optional `{slug}` placeholder is replaced with the topic slug, and a template without it is used verbatim. Same normalization and typing rules as `base_ref`. Overridden by the `--commit`/`-c` CLI option (publication-only); the built-in default is `goga: create topic {slug}` | - -When `topics` is absent, `config.topics` is `None` ("everything unset"). Unknown keys inside the mapping are ignored — the same stance as `lint` and `codemanifest`. - ## Pre-built Docker images goga provides prebuilt language images for build execution: diff --git a/docs/features/build/api.md b/docs/features/build/api.md new file mode 100644 index 00000000..783562fe --- /dev/null +++ b/docs/features/build/api.md @@ -0,0 +1,48 @@ +# Build — API + +The facade of the domain package **`goga.build`** — the host-side orchestration of a plan execution through the ralph-loop in a Docker container. + +The signatures below are the CODEMANIFEST contract of the cell. + +## Entry points + +```python +build(plan: str, config: ProjectConfig, cli_options: dict) -> int +main() -> int +``` + +`build` is the full orchestration — precondition checks (Docker, config, uncommitted manifests), agent wrapper resolution, ralphex defaults sync, optional image refresh, and the container launch; the exit code is returned. `main` is the console entry point. The `cli_options` dict carries the CLI-surface values (timeouts, `--update`, review flags, …) resolved by the command layer. + +## Review options + +```python +resolve_review_options(config: BuildConfig, cli_options: dict) -> ReviewOptions +validate_review_config(config: BuildConfig, review: ReviewOptions) -> None +ReviewOptions(skip: bool, review_agent: str | None, roles: list[str] | None, + two_pass: bool, review_env: dict[str, str], + base_ref: str | None, patience: int | None) +``` + +`resolve_review_options` composes the review-scoped settings with the precedence CLI > `build.review_executor.*` > omit. `validate_review_config` enforces the host-side guards — among them the rejection of a two-pass review form combined with an active worktree. + +## Run plumbing + +```python +sync_ralphex_defaults(config: BuildConfig, review: ReviewOptions) -> None +write_ralphex_config(config: BuildConfig, wrapper_path: str) -> None +run_build_pass(plan: str, config: BuildConfig, options: dict[str, str | int | bool], + wrapper_path: str, dry_run: bool, + env: dict[str, str] | None = None) -> int +move_completed_plan(plan: str, outcome: bool, dry_run: bool) -> None +``` + +`sync_ralphex_defaults` rewrites `.ralphex/prompts/` and `.ralphex/agents/` from the configured or vendored defaults (filtering review prompts to the selected `roles`); `write_ralphex_config` writes the ralph-loop config with the resolved wrapper. `run_build_pass` launches one container pass (tasks or review) — `dry_run=True` prints the assembled command. `move_completed_plan` moves the plan into the topic's `completed/` directory after the run. + +## Example + +```python +from goga.build import build +from goga.config import load_project_config + +exit_code = build("plan.md", load_project_config(), {"update": False, "dry_run": False}) +``` diff --git a/docs/cli/build.md b/docs/features/build/cli.md similarity index 100% rename from docs/cli/build.md rename to docs/features/build/cli.md diff --git a/docs/features/build/configuration.md b/docs/features/build/configuration.md new file mode 100644 index 00000000..1966ef5b --- /dev/null +++ b/docs/features/build/configuration.md @@ -0,0 +1,54 @@ +# Build — Configuration + +The build domain reads one section of `.goga/config.yml` — `build`. The section is optional at the loader level; `goga build` raises a `ClickException` when it is absent. + +```yaml +image: qarium/goga-python-3.12:1.3 # top-level image, shared with pipelines (build.image is rejected) +build: + task_executor: + agent: claude # the agent that runs the build inside the container + env: {} + review_executor: + agent: codex # optional: a separate review-pass agent + roles: [quality, testing] + base_ref: origin/1.3.x # review diff base + patience: 3 +``` + +### `build` + +| Field | Type | Required | Description | +|---|---|---|---| +| `task_executor` | mapping | Yes | AI agent configuration — see [build.task_executor](#buildtask_executor) | +| `worktree` | `bool` | No | Use an isolated git worktree for builds | +| `skip_finalize` | `bool` | No | Skip the ralph-loop finalization step | +| `session_timeout` | `string` | No | Session timeout in Go duration format (e.g. `30m`, `1h`) | +| `idle_timeout` | `string` | No | Idle timeout in Go duration format | +| `wait` | `string` | No | Wait time on rate limit in Go duration format | +| `max_iterations` | `int` | No | Maximum task iterations | +| `prompts_dir` | `string` | No | Path to custom ralph-loop prompts | +| `agents_dir` | `string` | No | Path to custom ralph-loop agents | +| `codex_review` | `bool` | No | Enable external codex review | +| `proxy` | `string` | No | HTTP/HTTPS proxy URL for the build container. When set, `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` are written to the container env-file. Overridden by the `--proxy` CLI option | +| `hosts` | mapping | No | Host→IP mapping for `docker run --add-host`. Defaults to `{}`. Augmented by the repeatable `--add-host` CLI option (CLI wins on key conflict) | +| `review_executor` | mapping | No | Review-phase configuration — see [build.review_executor](#buildreview_executor) | + +### `build.task_executor` + +| Field | Type | Required | Description | +|---|---|---|---| +| `agent` | `string` | No | AI executor that runs the build inside the container. Optional at the loader level — absent/YAML-null/empty/whitespace resolves to `None`; `goga build` raises a `ClickException` when it is `None`. Resolved to `/home/goga/bin/<agent>-as-claude.sh` — no whitelist; any name whose wrapper file exists in the image works. Baseline wrappers: `claude`, `codex`, `cursor`, `opencode`, `qwen`. See [Agents](../../configuration/agents.md) | +| `env` | mapping | No | Environment variables passed to the agent. Keys and values must be strings. Defaults to `{}` | + +### `build.review_executor` + +| Field | Type | Required | Description | +|---|---|---|---| +| `skip` | `bool` | No | Skip the review phase entirely — the run executes tasks only (ralph-loop `--tasks-only`). Absent/YAML-null means "not set" (the CLI flag decides); must be a real bool — a YAML `1` is rejected | +| `agent` | `string` | No | Review executor agent name (same resolution mechanic as `build.task_executor.agent`; its wrapper must exist in the image). When it differs from `task_executor.agent`, **or when a non-empty `env` is declared alongside it**, the build runs two passes: tasks with the task wrapper, then the review pass with the review wrapper. Combining either two-pass form with an active worktree (`--worktree` or `build.worktree: true`) is rejected with exit 1 on the host | +| `roles` | list of `string` | No | Reviewer composition for the review prompts: keeps only the `{{agent:X}}` lines of the selected roles and adapts the counters of the accompanying text. Whitelist: `quality`, `implementation`, `testing`, `simplification`, `documentation`. Absent or `[]` means the full default set (prompts stay byte-identical to the vendored defaults) | +| `env` | mapping of `string` | No | Review-pass environment layer (`{str: str}`). Keys overlay same-named container variables for the review-pass subprocess only — the tasks pass and the container env-file are unaffected, and the values never reach logs or dry-run output. Absent/YAML-null/`{}` all resolve to `{}`. A non-empty `env` induces a two-pass run like a differing agent does, and requires `agent`; a skipped run ignores the layer entirely | +| `base_ref` | `string` | No | Review diff base — a branch name or commit hash, stored verbatim (no resolvability or format check; ralphex owns the diagnostics). Overrides ralphex's default-branch detection on review-carrying passes. Overridden by the `--base-ref` CLI option | +| `patience` | `int` | No | Stop the external review after N consecutive unchanged rounds. Absent/YAML-null resolves to `None`; a YAML boolean is rejected. Overridden by the `--review-patience` CLI option | + +The image itself is configured at the top level (`image`, `dockerfile`) — shared with [Pipelines](../pipelines/configuration.md). The general file location, loading rules, and the shared example live in [Project Configuration](../../configuration/project.md); the validation errors of the section are listed there (see [validation errors](../../configuration/project.md#validation-errors)). diff --git a/docs/features/build/hooks.md b/docs/features/build/hooks.md new file mode 100644 index 00000000..6df6db9c --- /dev/null +++ b/docs/features/build/hooks.md @@ -0,0 +1,5 @@ +# Build — Hooks + +The build domain exposes **no hook actions** for tool packages today. + +The build's extension surface is configuration-shaped instead: custom ralph-loop prompts (`build.prompts_dir`), custom agent definitions (`build.agents_dir`), and any CLI agent whose wrapper exists in the image (see [Configuration](configuration.md)). The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/build/index.md b/docs/features/build/index.md new file mode 100644 index 00000000..bccaa13f --- /dev/null +++ b/docs/features/build/index.md @@ -0,0 +1,19 @@ +# Build + +Execute a build plan through a **ralph-loop** inside a Docker container. + +The build domain is the headless execution surface: a plan file (the output of the [planning cycle](../../workflow/index.md)) is walked task-by-task by an AI agent inside the isolated goga container, with an optional external review pass. Which tasks it solves: + +- **Run plans unattended** — `goga build plan.md` prepares the environment, validates preconditions (Docker, config, agent wrappers), and delegates to the ralph-loop running in-container. +- **Keep state persistent** — the ralph-loop state survives across runs of the same plan on the same branch; `--clean` wipes it for a fresh run. +- **Separate the reviewer from the executor** — `build.review_executor` configures a second agent (and an env layer) for the review pass: the build runs tasks with one wrapper, then the review with another. +- **Scope the review diff** — `base_ref` overrides the review's default-branch detection; `patience` stops the external review after N unchanged rounds. + +The interactive, stage-by-stage counterpart of this domain is [Pipelines](../pipelines/index.md); the SDD cycle that produces the plans is covered in [Workflow](../../workflow/index.md). + +## In this directory + +- [CLI](cli.md) — the full `goga build` command reference +- [Configuration](configuration.md) — the `build:` section of `.goga/config.yml` +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.build` package facade diff --git a/docs/features/connect/api.md b/docs/features/connect/api.md new file mode 100644 index 00000000..5720298f --- /dev/null +++ b/docs/features/connect/api.md @@ -0,0 +1,23 @@ +# Connect — API + +The facade of the domain package **`goga.connect`** — the installation of goga skills and commands into AI agents. + +The signatures below are the CODEMANIFEST contract of the cell. + +```python +connect(agents: list[str], force_overwrite: bool = False) -> int +install_pipelines(pipelines_dir: Path, force_overwrite: bool = False) -> int +resync_registered_agents(goga_home: Path) -> int +``` + +- `connect` — the full connection run for the named agents: install the goga skill bundle centrally into `~/.goga/skills/`, symlink it into each agent's skills directory, auto-discover the installed `goga_tool_*` packages and surface their skills and pipelines, and record the agents in `~/.goga/connect.yml`. `force_overwrite` replaces existing skills that goga does not own. Returns the exit code. +- `install_pipelines` — copy a source directory's flat `*.yml` pipeline-files into `~/.goga/pipelines/` namespaced as `<tool>:<name>.yml` (a residual conflict on the destination is resolved with the `force_overwrite` semantics). +- `resync_registered_agents` — re-run the connection for every agent recorded in the home `connect.yml` — the re-sync invoked by `goga install`, `goga uninstall`, and `goga upgrade` after they change the installed package set. + +## Example + +```python +from goga.connect import connect + +exit_code = connect(["claude", "opencode"], force_overwrite=False) +``` diff --git a/docs/cli/connect.md b/docs/features/connect/cli.md similarity index 95% rename from docs/cli/connect.md rename to docs/features/connect/cli.md index 850a89f9..ba3eb744 100644 --- a/docs/cli/connect.md +++ b/docs/features/connect/cli.md @@ -10,7 +10,7 @@ goga connect AGENTS... [--force-overwrite] ## Description -`goga connect` installs goga's commands, skills, and DSL specification for one or more AI coding agents. Assets are installed **centrally** into `~/.goga/`, then each connected agent receives **symlinks** into that central store. A registry at `~/.goga/connect.yml` records the connected agents and their `force_overwrite` setting, so [`goga install`](install.md), [`goga upgrade`](upgrade.md), and [`goga uninstall`](uninstall.md) can re-sync them after a package change. +`goga connect` installs goga's commands, skills, and DSL specification for one or more AI coding agents. Assets are installed **centrally** into `~/.goga/`, then each connected agent receives **symlinks** into that central store. A registry at `~/.goga/connect.yml` records the connected agents and their `force_overwrite` setting, so [`goga install`](../install/cli.md), [`goga upgrade`](../upgrade/cli.md), and [`goga uninstall`](../install/uninstall.md) can re-sync them after a package change. ## Arguments diff --git a/docs/features/connect/configuration.md b/docs/features/connect/configuration.md new file mode 100644 index 00000000..fca0d302 --- /dev/null +++ b/docs/features/connect/configuration.md @@ -0,0 +1,5 @@ +# Connect — Configuration + +The connect domain reads **no section of the project `.goga/config.yml`**. + +Its state lives at the home level instead: the connected agents are recorded in `~/.goga/connect.yml` with each agent's persisted `force_overwrite` flag — see [Configuration — Home](../../configuration/home.md). The general configuration model of the product is covered in [Configuration](../../configuration/index.md). diff --git a/docs/features/connect/hooks.md b/docs/features/connect/hooks.md new file mode 100644 index 00000000..90c8217b --- /dev/null +++ b/docs/features/connect/hooks.md @@ -0,0 +1,5 @@ +# Connect — Hooks + +The connect domain exposes **no hook actions** for tool packages today. + +A tool package reaches the agent layer through its artifacts, not hooks: its skills are installed into `~/.goga/skills/` and its pipeline-files into `~/.goga/pipelines/` by the connect run itself (see [Overview](index.md)). The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/connect/index.md b/docs/features/connect/index.md new file mode 100644 index 00000000..393cebed --- /dev/null +++ b/docs/features/connect/index.md @@ -0,0 +1,18 @@ +# Connect + +Install goga skills and commands into AI coding agents. + +The connect domain wires goga into the agent layer. Which tasks it solves: + +- **One-time setup** — `goga connect <agents>` installs the goga skill bundle centrally into `~/.goga/skills/` and symlinks it into each named agent's skills directory, so `/goga:<command>` slash commands and the `goga-*` skills appear in the agent session. +- **Tool surfacing** — the same run auto-discovers every installed `goga_tool_*` package: their skills land in the shared catalog and their pipeline-files install into `~/.goga/pipelines/` namespaced as `<tool>:<name>.yml`. +- **Re-sync** — every command that changes the installed package set (`goga install`, `goga uninstall`, `goga upgrade`) re-syncs the registered agents through this domain, keeping `~/.goga/` and each agent's symlink tree in step. + +The connected agents are recorded in `~/.goga/connect.yml` (the home-level state — see [Configuration — Home](../../configuration/home.md)); the agent wrapper mechanics used by build and pipelines are covered in [Configuration — Agents](../../configuration/agents.md). + +## In this directory + +- [CLI](cli.md) — the full `goga connect` command reference +- [Configuration](configuration.md) — the domain reads no project configuration section +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.connect` package facade diff --git a/docs/features/contract/api.md b/docs/features/contract/api.md new file mode 100644 index 00000000..50f8b4f4 --- /dev/null +++ b/docs/features/contract/api.md @@ -0,0 +1,38 @@ +# Contract — API + +The facade of the domain package **`goga.contract`** — the comparison of CODEMANIFEST declarations with the implementation. The per-language extractors live in the nested cells (`goga.contract.python`, `.golang`, `.kotlin`, `.swift`, `.javascript`); the shared contract model in `goga.contract.data`. + +The signatures below are the CODEMANIFEST contract of the cell. + +```python +contract(lang: str, cell_path: str) -> list[EntityContract | RoutineContract] +``` + +Parse the cell's `CODEMANIFEST`, extract the implementation surface of `cell_path`'s source files with the `lang` extractor, and return the contract view — one `EntityContract` or `RoutineContract` per declared type, carrying the match between the declaration and the implementation. + +```python +BaseContract() +EntityContract(...) # a declared entity: methods and properties matched against the code +RoutineContract(...) # a declared routine: the callable signature matched against the code +MethodContract(...) +PropertyContract(...) +``` + +The contract result types. The per-language entry points are re-exported on the facade: + +```python +python_contract(...) # goga.contract.python +golang_contract(...) # goga.contract.golang +kotlin_contract(...) # goga.contract.kotlin +swift_contract(...) # goga.contract.swift +javascript_contract(...) # goga.contract.javascript +``` + +## Example + +```python +from goga.contract import contract + +for entry in contract("python", "goga/topics"): + print(entry) +``` diff --git a/docs/cli/contract.md b/docs/features/contract/cli.md similarity index 100% rename from docs/cli/contract.md rename to docs/features/contract/cli.md diff --git a/docs/features/contract/configuration.md b/docs/features/contract/configuration.md new file mode 100644 index 00000000..fda502d8 --- /dev/null +++ b/docs/features/contract/configuration.md @@ -0,0 +1,5 @@ +# Contract — Configuration + +The contract domain reads **no dedicated section of `.goga/config.yml`** — it consumes the global top-level `language` field (the default of `--lang`; see [Project Configuration](../../configuration/project.md)). + +The global `codemanifest:` section (named practices and agent annotations) belongs to no single domain and stays in the [Configuration](../../configuration/project.md#codemanifest) section. diff --git a/docs/features/contract/hooks.md b/docs/features/contract/hooks.md new file mode 100644 index 00000000..5acba8ef --- /dev/null +++ b/docs/features/contract/hooks.md @@ -0,0 +1,5 @@ +# Contract — Hooks + +The contract domain exposes **no hook actions** for tool packages today. + +The comparison is a read-only analysis over the project files and the parsed manifests. The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/contract/index.md b/docs/features/contract/index.md new file mode 100644 index 00000000..ee2767c5 --- /dev/null +++ b/docs/features/contract/index.md @@ -0,0 +1,18 @@ +# Contract + +Compare CODEMANIFEST declarations with the source code implementation. + +The contract domain is the drift detector between the DSL and the code. Which tasks it solves: + +- **Verify a cell** — `goga contract <cell>` parses the cell's `CODEMANIFEST` and extracts the implementation surface from its source files: every declared entity and routine is matched against the actual classes and functions. +- **Per language** — extraction is language-aware (`--lang python | golang | kotlin | swift | javascript`, defaulting to the project's `language`); the per-language rules are covered in [Languages](../../languages/index.md). +- **Report the drift** — each declared type that the implementation does not match (missing, signature mismatch, misplaced) is reported; the report is what acceptance and review cycles act on. + +Together with [Lint](../lint/index.md) — which validates the DSL itself — this closes the loop: the manifest is structurally valid *and* faithfully implemented. + +## In this directory + +- [CLI](cli.md) — the full `goga contract` command reference +- [Configuration](configuration.md) — the domain reads the global `language` field +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.contract` package facade diff --git a/docs/features/history/api.md b/docs/features/history/api.md new file mode 100644 index 00000000..140776b6 --- /dev/null +++ b/docs/features/history/api.md @@ -0,0 +1,75 @@ +# History — API + +The facade of the domain package **`goga.history`** — the single owner of the `.goga/history/` tree. The git branch reader lives in the nested leaf cell `goga.history.git`, the status scale in `goga.history.statuses`; both are re-exported on this facade. + +The signatures below are the CODEMANIFEST contract of the cell. + +## Identity and addressing + +```python +normalize_topic_slug(name: str) -> str +current_year() -> str +``` + +The slug grammar — lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed (`Feature/Foo_Bar` → `feature-foo-bar`) — and the current year as four digits. + +```python +resolve_history_root() -> Path +resolve_topic_dir(topic: str, year: str | None = None) -> Path +resolve_topic_file(topic: str, filename: str, year: str | None = None) -> Path +topic_exists(topic: str, year: str | None = None) -> bool +ensure_topic_dir(name: str, year: str | None = None) -> Path +remove_topic_dir(name: str, year: str | None = None) -> bool +``` + +Topic addressing: the tree root, the topic directory, and an artifact file path (the filename taken verbatim, extension required). `ensure_topic_dir` creates the directory idempotently (parents as needed); `remove_topic_dir` removes it idempotently (`True` — removed). + +## Statuses + +```python +resolve_topic_status(topic_dir: Path, scale: StatusScale) -> list[str] +collect_topic_statuses(year: str | None = None, scale: StatusScale | None = None) -> list[TopicRecord] +TopicRecord(topic: str, statuses: list[str]) +``` + +The maximal present statuses of one topic and of one year's every topic — computed against the assembled scale, in scale order. + +```python +assemble_status_scale() -> StatusScale +StatusScale(stages: list[Stage]) +Stage(name: str, filepath: str, before: str | None = None, after: str | None = None) +StatusRegistry(builtin_stages: list[Stage], tool_prefix: str) +``` + +The scale assembly — the built-in artifact axis plus the registrations of installed tool packages (see [Hooks](hooks.md)). `assemble_status_scale` is the single entry point; a broken `goga_tool_*` import raises `ImportError`, an invalid registration surfaces as `ValueError`. + +## Tree traversal and cleanup + +```python +collect_history_tree(year: str | None = None) -> list[HistoryYear] +HistoryYear(year: str, topics: list[str]) +prune_topics(year: str | None = None, dry_run: bool = False) -> list[str] +``` + +`collect_history_tree` walks the tree (one year with `year`, every year with `None`). `prune_topics` deletes the orphan topics of the scoped year — the topics no branch of the repository inventory hosts — returning the removed slugs (`dry_run=True` lists without deleting). + +## Git embedding + +```python +resolve_current_branch_name() -> str +list_branch_refs() -> list[BranchRef] +BranchRef(name: str, ...) +``` + +The read-only branch surface: the current branch name (`None`-modes surface as `ValueError` reasons at the CLI layer) and the branch inventory used by the board, switching, and prune protection. + +## Example + +```python +from goga.history import collect_topic_statuses, resolve_topic_file + +for record in collect_topic_statuses("2026"): + print(record.topic, record.statuses) + +plan = resolve_topic_file("feat-x", "plan.md") # .goga/history/2026/feat-x/plan.md +``` diff --git a/docs/cli/history.md b/docs/features/history/cli.md similarity index 96% rename from docs/cli/history.md rename to docs/features/history/cli.md index e40aaee7..9882c412 100644 --- a/docs/cli/history.md +++ b/docs/features/history/cli.md @@ -61,7 +61,7 @@ A topic carries its **maximal present statuses** in scale order — one brackete | `planned` | `plan.md` | | | `done` | `completed/plan.md` | | -A topic can carry several statuses at once: every artifact present that is outranked by no other present artifact stays visible (tool statuses included, shown qualified such as `mkdocs.published` — see [Tools](../tools.md) for how a tool package registers its own statuses). The year comes from the group's `-y`/`--year` (default: the current year) and is never printed; topics come out alphabetically. +A topic can carry several statuses at once: every artifact present that is outranked by no other present artifact stays visible (tool statuses included, shown qualified such as `mkdocs.published` — see [Tools](../tools/index.md) for how a tool package registers its own statuses). The year comes from the group's `-y`/`--year` (default: the current year) and is never printed; topics come out alphabetically. The status segments print colored (`cyan`) unless a non-empty `NO_COLOR` is set in the environment. @@ -118,4 +118,4 @@ goga history -y 2025 prune # one explicit year ## Notes - The topic slug grammar: lowercase, non-ASCII dropped, anything outside `[a-z0-9]` becomes `-`, repeat hyphens collapsed, edge hyphens trimmed (`Feature/Foo_Bar` → `feature-foo-bar`, `release/1.3.0` → `release-1-3-0`). -- `goga topics board` shows the same statuses across branches; `goga history status` shows the working copy of one year (see [topics](topics.md)). +- `goga topics board` shows the same statuses across branches; `goga history status` shows the working copy of one year (see [topics](../topics/cli.md)). diff --git a/docs/features/history/configuration.md b/docs/features/history/configuration.md new file mode 100644 index 00000000..fa3614e5 --- /dev/null +++ b/docs/features/history/configuration.md @@ -0,0 +1,7 @@ +# History — Configuration + +The history domain reads **no section of `.goga/config.yml`** — it is configured by nothing. + +The tree root is fixed at `<project>/.goga/history/`, the year defaults to the current one, and the status scale assembles from the built-in axis plus the registrations of installed tool packages (see [Hooks](hooks.md)) — none of it is configurable. + +The general configuration model of the product is covered in [Configuration](../../configuration/index.md). diff --git a/docs/features/history/hooks.md b/docs/features/history/hooks.md new file mode 100644 index 00000000..874d2c3c --- /dev/null +++ b/docs/features/history/hooks.md @@ -0,0 +1,35 @@ +# History — Hooks + +The history domain declares one hook action: the **status-scale registration** — the way an installed `goga_tool_*` package attaches its own statuses to the topic status scale, with no goga code changes. + +## The action + +| Address | Error class | Fires | +|---|---|---| +| `statuses` / `register_statuses` | **soft** — a failing hook is skipped with a stderr warning; the command continues | when a command first assembles the status scale (`goga history status`, `goga topics board`, …) | + +A tool subscribes inside its `register_hooks` callback: + +```python +# inside the goga_tool_<tool> package +def register_hooks(hooks): + hooks.subscribe("statuses", "register_statuses", "published", register_published) + + +def register_published(context): + context.register("published", "mkdocs/published.md", after="planned") +``` + +The hook receives `context` — the registration surface scoped to the tool. Every registered name is stored **qualified** with the tool prefix (`<tool>.<name>`, e.g. `mkdocs.published`), so registrations from different tools never collide and a topic can carry several statuses at once. A hook may also declare `self` — the isolated per-tool context of the run. + +## The registration surface + +- `name` — the status name as the tool defines it; shown as `<tool>.<name>`. +- `filepath` — the artifact path relative to the topic directory; nested paths allowed. +- `before` / `after` — anchors: qualified names of statuses this one precedes or follows. At least one anchor is required; both given define a placement range. +- The built-in statuses are immutable — registration is add-only. +- Two tools may reference the same artifact path — both statuses apply independently. + +A registration missing an anchor, carrying empty values, an unresolvable anchor, or an invalid range is skipped with a stderr warning naming the tool, the action, and the reason — it never aborts the command and never cancels other registrations. + +The platform mechanism behind the action (enumeration, the registry, delivery, inspection with `goga hooks`) is the [Hooks](../hooks/index.md) domain; the tool-package side of authoring a `register_hooks` callback is covered in [Tools — Hooks](../tools/hooks.md). diff --git a/docs/features/history/index.md b/docs/features/history/index.md new file mode 100644 index 00000000..d64a756c --- /dev/null +++ b/docs/features/history/index.md @@ -0,0 +1,19 @@ +# History + +The `.goga/history/` tree — one directory per topic per year, carrying the artifacts of the work. + +The history domain is the single owner of the tree: it answers *where the work's artifacts live and in what state they are*. Which tasks it solves: + +- **Address artifacts** — every piece of work has a topic directory `.goga/history/<YYYY>/<slug>/`; the domain resolves directory and artifact-file paths, checks existence, creates directories idempotently, and removes them. +- **See the state** — each artifact that lands (`todo.md`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, `completed/plan.md`) deepens the topic's status on the built-in scale `empty → todo → defined → discovered → backlog → designed → specified → planned → done`. `goga history status` prints the maximal present statuses of one year; tool packages extend the scale with their own qualified statuses. +- **Browse the tree** — `goga history list` prints the inventory (years and topics); `goga history path` prints exactly one path for scripting. +- **Clean up** — `goga history prune` deletes the orphan topics of a year (topics no branch hosts anymore). + +The tree is meant to stay out of git — add `.goga/history/` to your `.gitignore`. The cross-branch view of the same statuses (the board) is the [Topics](../topics/index.md) domain. + +## In this directory + +- [CLI](cli.md) — the full `goga history` command reference +- [Configuration](configuration.md) — the domain reads no configuration section +- [Hooks](hooks.md) — the `statuses` action: how tool packages extend the status scale +- [API](api.md) — the `goga.history` package facade diff --git a/docs/features/hooks/api.md b/docs/features/hooks/api.md new file mode 100644 index 00000000..beaeff69 --- /dev/null +++ b/docs/features/hooks/api.md @@ -0,0 +1,73 @@ +# Hooks — API + +The facade of the domain package **`goga.hooks`** — the extension surface of the goga domains for installed tool packages. The facade declares no type of its own: it re-exports the declared action catalog, the run registry with its per-tool inspection view, and the emission of an action at a domain checkpoint. Importing the package imports no tool package and enumerates nothing. + +The signatures below are the CODEMANIFEST contract of the platform cells. + +## The facade + +```python +from goga.hooks import HookRegistry, ToolHooks, declared_actions, emit_hook_event +``` + +| Name | Origin | Purpose | +|---|---|---| +| `declared_actions()` | `goga.hooks.catalog` | The declared action catalog | +| `HookRegistry()`, `ToolHooks` | `goga.hooks.registry` | The run registry and its per-tool view | +| `emit_hook_event(...)` | `goga.hooks.dispatch` | The emission of an action at a domain checkpoint | + +## The action catalog + +```python +declared_actions() -> list[Action] +Action(domain: str, name: str, error_class: str) +``` + +Every action declared by the domains — its address (`domain`, `name`) and its error class (`soft` or `hard`). A domain checkpoint consults the catalog to validate the address before emitting. + +## The registry + +```python +HookRegistry() +ToolHooks(tool: str, subscriptions: list[Subscription], rejections: list[RejectedRegistration]) +ToolContext(tool: str) +``` + +`HookRegistry` is the one-registry-per-run state: one `ToolHooks` entry per tool with registrations, carrying its subscriptions and its refused registrations (with reasons). `ToolContext` is the isolated per-tool context delivered as `self` to the tool's hooks. + +The registration envelope lives in the tools leaf cell: + +```python +HookRegistrar(tool: str) +Subscription(tool: str, domain: str, action: str, name: str, hook: Callable) +RejectedRegistration(tool: str, domain: str, action: str, name: str, reason: str) +enumerate_tool_packages() -> list[ToolPackage] +ToolPackage(module_name: str) +call_register_hooks(package: ToolPackage, registrar: HookRegistrar) -> bool +``` + +`HookRegistrar` is the `hooks` object delivered to a package's `register_hooks` callback — `subscribe(domain, action, name, hook)` registers one hook; a wrong address, an empty name, or a repeated name on the same address is refused with a recorded reason. `enumerate_tool_packages` discovers the installed `goga_tool_*` packages deterministically (alphabetical order of top-level module name); `call_register_hooks` invokes a package's callback (`True` — the package declares one). + +## The emission + +```python +emit_hook_event(registry: HookRegistry, domain: str, action: str, context_for: Callable) +wrap_context(target: object) -> object +build_hook_arguments(hook: Callable, context: object, self_context: ToolContext) -> dict[str, object] +``` + +`emit_hook_event` delivers an action to every subscribed hook — assembling the registry on first use. The context mediation: `wrap_context` makes the delivered object read-only (attribute assignment blocked); `build_hook_arguments` passes a hook only the values it declares by the offered names (`context`, `self`). + +## Example + +```python +from goga.hooks import HookRegistry, declared_actions, emit_hook_event + +registry = HookRegistry() + +for action in declared_actions(): + print(action.domain, action.name, action.error_class) + +# a domain checkpoint — the scale assembly of the history domain +emit_hook_event(registry, "statuses", "register_statuses", context_for=make_registry_surface) +``` diff --git a/docs/cli/hooks.md b/docs/features/hooks/cli.md similarity index 94% rename from docs/cli/hooks.md rename to docs/features/hooks/cli.md index 643a370c..159dd003 100644 --- a/docs/cli/hooks.md +++ b/docs/features/hooks/cli.md @@ -10,7 +10,7 @@ goga hooks [--tool NAME]... ## Description -`goga hooks` assembles the run registry once — it imports every installed `goga_tool_*` package and runs its `register_hooks` callback — and prints the registrations as a tree: tool, then domain, then action. It states the **fact of registration**, never whether a hook ran in a particular command. See [Tools — Domain extensions](../tools.md#domain-extensions) for the registration contract. +`goga hooks` assembles the run registry once — it imports every installed `goga_tool_*` package and runs its `register_hooks` callback — and prints the registrations as a tree: tool, then domain, then action. It states the **fact of registration**, never whether a hook ran in a particular command. See [Hooks — the registration contract](hooks.md) for the registration contract. ## The tree diff --git a/docs/features/hooks/configuration.md b/docs/features/hooks/configuration.md new file mode 100644 index 00000000..398e2f88 --- /dev/null +++ b/docs/features/hooks/configuration.md @@ -0,0 +1,5 @@ +# Hooks — Configuration + +The hooks domain reads **no section of `.goga/config.yml`** — it is configured by nothing. + +The registry assembles from the `goga_tool_*` packages installed in the running interpreter (see [Install](../install/index.md)); its shape is fully derived from their `register_hooks` callbacks. The general configuration model of the product is covered in [Configuration](../../configuration/index.md). diff --git a/docs/features/hooks/hooks.md b/docs/features/hooks/hooks.md new file mode 100644 index 00000000..9b41d907 --- /dev/null +++ b/docs/features/hooks/hooks.md @@ -0,0 +1,40 @@ +# Hooks — The registration contract + +How a `goga_tool_*` package subscribes its hooks to domain actions — the tool-author side of the platform. + +A tool **may** expose a `register_hooks(hooks)` callable in its facade package — the registration of domain hooks. goga calls it when a command first reaches a hook checkpoint of the run, or when you inspect the registry with [`goga hooks`](cli.md); commands that use no hooks never call it. Registration is never cached — package edits apply from the next run, without reinstall. + +```python +# inside the goga_tool_<tool> package +def register_hooks(hooks): + hooks.subscribe("statuses", "register_statuses", "published", register_published) + + +def register_published(context): + context.register("published", "mkdocs/published.md", after="planned") +``` + +`hooks.subscribe(domain, action, name, hook)` registers one hook: + +- `domain` + `action` — the action address: the semantic owner domain and the action name within it (`"statuses"` / `"register_statuses"` is the topic-status action — see [History — Hooks](../history/hooks.md)). +- `name` — the hook name, unique per tool per address; registrations appear in the [`goga hooks`](cli.md) tree under their tool line. +- `hook` — the callable executed when the action fires. + +The tool identity is assigned by goga from the package name — a package never names itself, and identical hook names of different tools never collide. Enumeration is deterministic: packages in alphabetical order of top-level module name, subscriptions delivered in enumeration order. + +## The hook signature + +A hook receives values only for the parameters it declares by the fixed offered names — `context` and `self`: + +- `context` — the delivered object of the action. Read attributes and call methods freely; attribute assignment is blocked. What the object carries is fixed by the owner domain's contract — for `register_statuses` it is the status registration surface (`register(name, filepath, before=..., after=...)`, names stored qualified `<tool>.<name>`; see [History — Hooks](../history/hooks.md) for the scale rules). +- `self` — the isolated context of your tool. One instance links all its hook invocations of a run; freely mutable by your tool, invisible to the domains. + +The declaration order does not matter; names you did not declare receive nothing. + +## Error classes and diagnostics + +Each action in the catalog fixes how a failing hook is treated. The topic-status action is **soft**: a failing hook is skipped with a stderr warning naming the tool, the action, and the reason, and the command continues. A **hard** action stops the command at the first failing hook with a clean error — the class is chosen by the owner domain when it declares the action. + +At registration: a wrong address, an empty name, or a repeated name on the same address is refused with a stderr warning naming the tool, the action, and the reason — the registration is skipped, the rest apply. A crashing callback is a warning; the registrations made before the crash survive. A broken package import is the only fatal case: a clean error naming the package. + +> **Migration note.** The old `register_topic_statuses(statuses)` callback is gone. After a goga update, a package still carrying it loses its statuses **without any diagnostic** — they silently disappear from the scale. Moving to `register_hooks` is the package author's responsibility. Qualified names of packages with underscores in their name change too: the qualifier is the canonical hyphen identity, so `goga_tool_hello_world` now registers `hello-world.published` where it used to register `hello_world.published` — existing `goga history status -s <tool>.<name>` filters must use the hyphen form. diff --git a/docs/features/hooks/index.md b/docs/features/hooks/index.md new file mode 100644 index 00000000..835097dc --- /dev/null +++ b/docs/features/hooks/index.md @@ -0,0 +1,25 @@ +# Hooks + +The extension platform connecting goga domains and tool packages. + +The hooks domain is the mechanism behind every domain extension: a domain declares an **action** at a checkpoint of its run; an installed `goga_tool_*` package subscribes a hook to that action; when a command first reaches the checkpoint, goga enumerates the tool packages, calls each `register_hooks` callback, and delivers the action's context to the subscribed hooks. Which tasks it solves: + +- **Domains expose extension points without knowing their consumers** — a domain declares an action address and the error class; the platform owns the enumeration, registration, and delivery. +- **Tool packages extend domains with no goga code changes** — a package registers its hooks at run time; registration is never cached, so package edits apply from the next run without reinstall. +- **Inspection** — `goga hooks` assembles the registry once and prints it as a tree: tool, domain, action — the fact of registration, including every refused registration with its reason. + +The declared actions today: the status-scale registration of the [History](../history/hooks.md) domain. The authoring side — how a tool package writes its `register_hooks` callback — is the [registration contract](hooks.md). + +## Model + +- **Action** — an address `domain / name` plus an error class (**soft**: a failing hook is skipped with a warning; **hard**: the command stops at the first failure). The owner domain chooses the class when it declares the action. +- **Registry** — one per run, assembled lazily on first use at a checkpoint: packages in alphabetical order of top-level module name, subscriptions delivered in enumeration order. Commands that use no hooks never build the registry. +- **Tool identity** — assigned by goga from the package name (the `goga_tool_` prefix dropped, underscores as hyphens); a package never names itself, and identical hook names of different tools never collide. +- **Delivery** — a hook receives values only for the parameters it declares by the fixed offered names (`context`, `self`); the context is read-only (attribute assignment blocked), `self` is the tool's isolated per-run state. + +## In this directory + +- [CLI](cli.md) — the `goga hooks` command reference +- [Configuration](configuration.md) — the domain reads no configuration section +- [Hooks](hooks.md) — the registration contract for tool-package authors +- [API](api.md) — the `goga.hooks` package facade diff --git a/docs/features/index.md b/docs/features/index.md new file mode 100644 index 00000000..c3cc33d7 --- /dev/null +++ b/docs/features/index.md @@ -0,0 +1,38 @@ +# Features + +The functional domains of goga — one directory per domain, five pages per domain. + +A **domain** is a user-facing functional area of the product: what it solves, how it is configured, which CLI commands drive it, which hook points it offers to tool packages, and which Python API its package facade exposes. Internal machinery (the AST, the pipeline compiler, contract extraction) lives in [Architecture](../architecture/index.md) and [Languages](../languages/index.md); the DSL itself is covered in [Cell](../cell/index.md). + +## The domains + +| Domain | What it solves | CLI | +|---|---|---| +| [Topics](topics/index.md) | Organizing work: branches, the board, todo entries, creation, switching, deletion, publication | `goga topics` | +| [History](history/index.md) | The `.goga/history/` artifact tree, the status scale, orphan cleanup, scriptable paths | `goga history` | +| [Pipelines](pipelines/index.md) | Running agent-driven cycles: pipeline-files, workflows, shipped pipelines | `goga pipeline` | +| [Build](build/index.md) | Executing build plans through a ralph-loop in a container | `goga build` | +| [Tools](tools/index.md) | The tool ecosystem: using, packaging, and naming `goga-tool` packages | `goga tool` | +| [Connect](connect/index.md) | Installing goga skills and commands into AI agents | `goga connect` | +| [Upgrade](upgrade/index.md) | Upgrading goga (and tools) with agent re-sync | `goga upgrade` | +| [Install](install/index.md) | Installing and removing tool packages into the running interpreter | `goga install`, `goga uninstall` | +| [Init](init/index.md) | Interactive project initialization and template scaffolding | `goga init` | +| [Usages](usages/index.md) | Syncing cell-level usages from declared git dependencies | `goga usages` | +| [Schema](schema/index.md) | JSON schema trees from CODEMANIFEST files | `goga schema` | +| [Contract](contract/index.md) | Comparing CODEMANIFEST declarations with the implementation | `goga contract` | +| [Hooks](hooks/index.md) | The extension platform connecting domains and tool packages | `goga hooks` | +| [Lint](lint/index.md) | Validating CODEMANIFEST files | `goga lint` | + +## The page model + +Every domain directory carries the same five pages: + +| Page | Content | +|---|---| +| **Overview** (`index.md`) | The functional area — which tasks the domain solves, its model and boundaries | +| **CLI** (`cli.md`) | The full normative command reference: synopsis, options, behavior, exit codes | +| **Configuration** (`configuration.md`) | The `.goga/config.yml` sections the domain reads (or a statement that it reads none) | +| **Hooks** (`hooks.md`) | The hook points the domain offers to tool packages (or a statement that it offers none) | +| **API** (`api.md`) | The facade API of the domain's Python package: types, signatures, parameters, purpose, usage examples | + +The command reference for the whole product — one table, every command mapped to its domain — is kept in the [CLI](../cli/index.md) cross-road. diff --git a/docs/features/init/api.md b/docs/features/init/api.md new file mode 100644 index 00000000..66b601d9 --- /dev/null +++ b/docs/features/init/api.md @@ -0,0 +1,35 @@ +# Init — API + +The facade of the domain package **`goga.onboarding`** — the interactive project initialization and template scaffolding. + +The signatures below are the CODEMANIFEST contract of the cell. + +```python +InitLogic(questionnaire: Questionnaire, generator: FileGenerator) +Questionnaire() +FileGenerator() +``` + +- `InitLogic` — the orchestration: run the questionnaire, resolve the answers (template answers first, the interactive dialogue for what the template left open), and generate the project files. +- `Questionnaire` — the interactive dialogue — the questions behind `.goga/config.yml` and the optional Dockerfile. +- `FileGenerator` — the file materialization: `.goga/config.yml`, the Dockerfile, and the template scaffold application (a copier template with `goga init --upgrade` migrates an existing scaffold). + +```python +InitAnswers(goga_config: GogaConfigAnswers | None = None) +GogaConfigAnswers(language: str, image: str, agent: str | None, + pipeline_agent: str | None, pipeline_env: dict | None, + env: dict | None, codemanifest_usages: dict | None, + codemanifest_annotations: str | None, + dockerfile_path: str | None, dockerfile_base_image: str | None) +``` + +The resolved answers: the project language and image, the build/pipeline agent settings with their env layers, the `codemanifest` section values, and the optional Dockerfile pair (path + base image). `InitAnswers` with `goga_config=None` — a template answered everything. + +## Example + +```python +from goga.onboarding import FileGenerator, InitLogic, Questionnaire + +logic = InitLogic(questionnaire=Questionnaire(), generator=FileGenerator()) +logic.run() +``` diff --git a/docs/cli/init.md b/docs/features/init/cli.md similarity index 100% rename from docs/cli/init.md rename to docs/features/init/cli.md diff --git a/docs/features/init/configuration.md b/docs/features/init/configuration.md new file mode 100644 index 00000000..c1c629b9 --- /dev/null +++ b/docs/features/init/configuration.md @@ -0,0 +1,5 @@ +# Init — Configuration + +The init domain reads **no section of `.goga/config.yml`** — it **writes** the file: the questionnaire's answers become the initial `language`, `image`, `build`, and `codemanifest` values (and, optionally, a project Dockerfile). + +What each written field means afterwards is covered by the domains that read it — see [Project Configuration](../../configuration/project.md) and the per-domain [Configuration](../index.md#the-page-model) pages. diff --git a/docs/features/init/hooks.md b/docs/features/init/hooks.md new file mode 100644 index 00000000..83ded91f --- /dev/null +++ b/docs/features/init/hooks.md @@ -0,0 +1,5 @@ +# Init — Hooks + +The init domain exposes **no hook actions** for tool packages today. + +Initialization is a one-time interactive flow over the goga assets; the tool packages enter the project afterwards — through [Install](../install/index.md) and [Connect](../connect/index.md). The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/init/index.md b/docs/features/init/index.md new file mode 100644 index 00000000..d6fd8cda --- /dev/null +++ b/docs/features/init/index.md @@ -0,0 +1,16 @@ +# Init + +Interactive project initialization, with optional template scaffolding. + +The init domain turns an empty directory into a goga project. Which tasks it solves: + +- **Configure a project** — `goga init` walks an interactive questionnaire: the language, the container image, the agents, the initial `.goga/config.yml` sections, the optional Dockerfile — and writes `.goga/config.yml` (plus the Dockerfile) from the answers. +- **Scaffold from a template** — `goga init <template-url>` starts from a [copier](https://copier.readthedocs.io/) repo template (optionally pinned with `#ref` or `--ref`) and then asks only the questions the template left open. +- **Migrate a scaffolded project** — `goga init --upgrade` migrates an existing scaffolded project to the current generator version. + +## In this directory + +- [CLI](cli.md) — the full `goga init` command reference +- [Configuration](configuration.md) — the domain writes (not reads) the configuration +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.onboarding` package facade diff --git a/docs/features/install/api.md b/docs/features/install/api.md new file mode 100644 index 00000000..33f55aea --- /dev/null +++ b/docs/features/install/api.md @@ -0,0 +1,35 @@ +# Install — API + +The facade of the domain package **`goga.commands.install`** — the pip install/uninstall of tool packages with the post-install hooks and agent activation. + +The signatures below are the CODEMANIFEST contract of the cell. + +```python +install(ctx: click.Context, name: str | None, sudo: bool, version: str | None, + local: str | None, no_connect: bool = False) -> int +uninstall(ctx: click.Context, name: str, sudo: bool = False, yes: bool = False, + target_user: str | None = None) -> int +``` + +- `install` — pip-install a tool into the running interpreter (single mode with `name`, local mode with `local`, bulk mode with neither — the `tools:` declarations), then run the post-install hooks and activate the registered agents (skipped with `no_connect`). Returns the exit code. +- `uninstall` — remove exactly one tool package after the confirmation (`yes` skips it; a non-interactive terminal without `yes` is a clean error), then re-sync the agents. `sudo` targets a system-Python install; `target_user` re-syncs another user's installation. + +```python +resolve_initiating_user() -> str +run_install_hooks(tools: list[str]) -> None +call_install_hook(tool: str, user: str) -> bool +``` + +- `resolve_initiating_user` — the initiating user of the run (`SUDO_USER` under sudo, else the current OS user) — the value passed to keyword-capable hooks. +- `run_install_hooks` — run the post-install hook of each named tool (the pip-fresh set); a failing hook raises. +- `call_install_hook` — invoke one tool's `install` facade callable (`True` — the package declares one and it ran). + +## Example + +```python +import click +from goga.commands.install import install + +exit_code = install(click.get_current_context(), name="mkdocs", sudo=False, + version="1.0.x", local=None, no_connect=False) +``` diff --git a/docs/cli/install.md b/docs/features/install/cli.md similarity index 99% rename from docs/cli/install.md rename to docs/features/install/cli.md index c44fb15f..5ebf422e 100644 --- a/docs/cli/install.md +++ b/docs/features/install/cli.md @@ -2,7 +2,7 @@ `goga install` adds goga-tool packages into the **current runtime interpreter** — the exact Python that runs goga. It targets the running interpreter's pip directly, so the install lands in the correct environment regardless of how goga was deployed (pipx venv, system Python, or any other). -After a successful pip in single, local, or bulk mode, the command runs each freshly installed tool's optional **post-install hook** (see [Post-install hooks](#post-install-hooks)), then **activates** every agent already recorded in `~/.goga/connect.yml` (re-syncing each with its persisted `force_overwrite`) so the freshly installed tool's skills and pipelines appear in `~/.goga/` and in each connected agent's symlink tree. Pass `--no-connect` to skip activation and perform the install only (useful in CI/Docker where a transient activation failure must not fail the install); the post-install hooks still run. To execute an installed tool without going through an agent, run [`goga tool`](tool.md). +After a successful pip in single, local, or bulk mode, the command runs each freshly installed tool's optional **post-install hook** (see [Post-install hooks](#post-install-hooks)), then **activates** every agent already recorded in `~/.goga/connect.yml` (re-syncing each with its persisted `force_overwrite`) so the freshly installed tool's skills and pipelines appear in `~/.goga/` and in each connected agent's symlink tree. Pass `--no-connect` to skip activation and perform the install only (useful in CI/Docker where a transient activation failure must not fail the install); the post-install hooks still run. To execute an installed tool without going through an agent, run [`goga tool`](../tools/cli.md). ## Modes diff --git a/docs/features/install/configuration.md b/docs/features/install/configuration.md new file mode 100644 index 00000000..3b7e191c --- /dev/null +++ b/docs/features/install/configuration.md @@ -0,0 +1,17 @@ +# Install — Configuration + +The install domain reads one optional section of `.goga/config.yml` — `tools`, the version declarations consumed by [`goga install`](cli.md) in bulk mode (a bare `goga install` with no name and no `--local`). + +```yaml +tools: + viewer: latest + mkdocs: 1.0.x +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `tools` | mapping | No | goga-tool version declarations. Keys are tool names (without the `goga-tool-` prefix); values are version-form strings. Values are stored verbatim — the four-form grammar (`1.0.x`, `1.x`, `1.0.1`, `latest`) is validated by `goga install`, not the loader. Defaults to `None` (absent); an empty mapping is `{}`. YAML-null values (`viewer:`) are rejected | + +Single and local modes ignore the section entirely — the version comes from `--version` or the local path. + +The general file location, loading rules, and the shared example live in [Project Configuration](../../configuration/project.md). diff --git a/docs/features/install/hooks.md b/docs/features/install/hooks.md new file mode 100644 index 00000000..7b2ac110 --- /dev/null +++ b/docs/features/install/hooks.md @@ -0,0 +1,14 @@ +# Install — Hooks + +The install domain exposes **no hook actions** of its own — it invokes a tool-package **lifecycle callback** instead. + +## The post-install hook + +A tool package **may** expose an `install(user: str | None = None)` callable in its facade. `goga install` calls it after a successful pip, passing the initiating user (`SUDO_USER` when goga itself runs under sudo, else the current OS user) only when the parameter is declared keyword-capable; otherwise the hook is called with no arguments. + +- A missing or non-callable `install` is skipped quietly. +- A failing hook exits 1 — the tool name and hook message go to stderr, the pip package stays, activation does not run, and a bulk install stops at the first failing hook. +- The hook still runs under `--no-connect` (the flag skips activation only). +- In local mode, the `:<tool-name>` suffix of `--local` names the tool whose hook runs; without it no hook runs (a warning is logged). + +The invocation surface is covered in [CLI — post-install hooks](cli.md#post-install-hooks). The domain hook actions (a tool's `register_hooks` subscriptions) are the [Hooks](../hooks/hooks.md) platform — fired at domain checkpoints, not at install time. diff --git a/docs/features/install/index.md b/docs/features/install/index.md new file mode 100644 index 00000000..6ac5aa16 --- /dev/null +++ b/docs/features/install/index.md @@ -0,0 +1,20 @@ +# Install + +Install and remove `goga-tool` packages — into the **exact interpreter that runs goga**. + +The install domain manages the tool packages of the running environment. Which tasks it solves: + +- **Install a tool** — `goga install <name>` pip-installs the package into the running interpreter's pip, regardless of how goga was deployed (pipx venv, system Python, anything else); `--version` pins through the four-form grammar (`1.0.x`, `1.x`, `1.0.1`, `latest`). +- **Install from source** — `goga install --local <path>[:<tool-name>]` pip-installs a local directory without a PyPI lookup. +- **Install the declared set** — a bare `goga install` installs every tool declared under `tools:` in `.goga/config.yml` in one pip call (see [Configuration](configuration.md)). +- **Post-install hooks** — after a successful pip, each freshly installed tool's optional `install(user)` facade callable runs (see [Hooks](hooks.md)). +- **Activate** — every agent recorded in `~/.goga/connect.yml` is re-synced, so the new tool's skills and pipelines appear immediately (`--no-connect` opts out — the install-only form for CI/Docker). +- **Remove a tool** — `goga uninstall <name>` removes exactly one package after a confirmation, then re-syncs the agents; a tool removed by hand with plain pip leaves its artifacts behind until the next re-sync. + +## In this directory + +- [CLI](cli.md) — the full `goga install` command reference +- [Uninstall](uninstall.md) — the full `goga uninstall` command reference +- [Configuration](configuration.md) — the `tools:` section of `.goga/config.yml` +- [Hooks](hooks.md) — the post-install hook and hook points for tool packages +- [API](api.md) — the `goga.commands.install` package facade diff --git a/docs/cli/uninstall.md b/docs/features/install/uninstall.md similarity index 90% rename from docs/cli/uninstall.md rename to docs/features/install/uninstall.md index b183261d..740fff69 100644 --- a/docs/cli/uninstall.md +++ b/docs/features/install/uninstall.md @@ -14,7 +14,7 @@ goga uninstall <name> [--yes/-y] [--sudo] [--user NAME] Exactly one tool is removed per invocation — no bulk, empty, or local-path forms. The tool `name` is not validated before pip runs: an unknown package is skipped by pip with a WARNING and exit code 0. -The re-sync is the cleanup mechanism. Because tool skills and pipelines are installed centrally into `~/.goga/` and symlinked into each agent directory (see [`goga connect`](connect.md)), removing the package alone would leave orphaned artifacts behind. The post-removal re-sync recreates `~/.goga/skills/` and `~/.goga/pipelines/` from the packages that remain and rebuilds agent symlinks only for entries that still exist — the removed tool's skills and pipelines disappear from `~/.goga/` and from each agent's symlink tree. +The re-sync is the cleanup mechanism. Because tool skills and pipelines are installed centrally into `~/.goga/` and symlinked into each agent directory (see [`goga connect`](../connect/cli.md)), removing the package alone would leave orphaned artifacts behind. The post-removal re-sync recreates `~/.goga/skills/` and `~/.goga/pipelines/` from the packages that remain and rebuilds agent symlinks only for entries that still exist — the removed tool's skills and pipelines disappear from `~/.goga/` and from each agent's symlink tree. ## Confirmation @@ -105,5 +105,5 @@ goga uninstall foo --sudo --user alice ## Notes - `--sudo` and `--user` rely on `sudo` and `pwd.getpwnam` respectively and are unavailable on Windows — omit them there. -- `goga uninstall` never reads or writes `connect.yml` itself — [`goga connect`](connect.md) is the single writer of the registry, reached only through the shared re-sync routine. +- `goga uninstall` never reads or writes `connect.yml` itself — [`goga connect`](../connect/cli.md) is the single writer of the registry, reached only through the shared re-sync routine. - Removing a package by hand with plain pip leaves stale skills and pipelines in `~/.goga/` until the next re-sync runs; prefer `goga uninstall`. diff --git a/docs/features/lint/api.md b/docs/features/lint/api.md new file mode 100644 index 00000000..a6db7be6 --- /dev/null +++ b/docs/features/lint/api.md @@ -0,0 +1,22 @@ +# Lint — API + +The facade of the domain package **`goga.ast`** — the parse-and-validate surface behind `goga lint`. + +The signature below is the CODEMANIFEST contract of the cell. + +```python +AST(path: str, ignore: list[str] | None = None) +``` + +Load the project tree rooted at `path` and validate it: parse every `CODEMANIFEST`, apply the document-level rules to each document, then the tree-level rules across the import graph. `ignore` — directory relative paths pruned from traversal (the `lint.ignore` configuration). The violations surface as the AST error types — `DocumentParseError` for structural YAML/DSL failures, `DocumentRuleError` for document-level rule violations, `ASTRuleError` for tree-level ones (see [Architecture — Error Handling](../../architecture/ast-errors.md)). + +The parse product is the same tree the [Contract](../contract/api.md) and [Schema](../schema/api.md) domains consume, and the one injected into a tool's keyword-capable `ast` parameter (see [Tools — CLI](../tools/cli.md#optional-injections)). + +## Example + +```python +from goga.ast import AST + +tree = AST(path=".", ignore=[".venv"]) +print(tree) # the validated document tree +``` diff --git a/docs/cli/lint.md b/docs/features/lint/cli.md similarity index 96% rename from docs/cli/lint.md rename to docs/features/lint/cli.md index 9f2d61cf..49a1de46 100644 --- a/docs/cli/lint.md +++ b/docs/features/lint/cli.md @@ -25,7 +25,7 @@ lint: - build/dist ``` -A directory is pruned when its exact normalized relative path matches an `ignore` entry. Matching is literal — glob patterns are **not** interpreted, and a trailing separator is insignificant (`.venv/` and `.venv` are equivalent). Only full relative paths match: `ignore: [.venv]` prunes a top-level `.venv` but not a nested `a/b/.venv`. The `lint` section is optional; when it is absent or the config cannot be loaded, lint behavior is unchanged (every directory is linted). See [Configuration](../configuration/project.md#lint). +A directory is pruned when its exact normalized relative path matches an `ignore` entry. Matching is literal — glob patterns are **not** interpreted, and a trailing separator is insignificant (`.venv/` and `.venv` are equivalent). Only full relative paths match: `ignore: [.venv]` prunes a top-level `.venv` but not a nested `a/b/.venv`. The `lint` section is optional; when it is absent or the config cannot be loaded, lint behavior is unchanged (every directory is linted). See [Configuration](configuration.md). ## Arguments diff --git a/docs/features/lint/configuration.md b/docs/features/lint/configuration.md new file mode 100644 index 00000000..7aa49eac --- /dev/null +++ b/docs/features/lint/configuration.md @@ -0,0 +1,18 @@ +# Lint — Configuration + +The lint domain reads one optional section of `.goga/config.yml` — `lint`, consumed by [`goga lint`](cli.md). + +```yaml +lint: + ignore: + - .venv/ + - build/dist +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `lint.ignore` | list of strings | No | Directory relative paths to skip during lint traversal, stored verbatim. A directory matches when its exact normalized relative path equals an entry; glob patterns are not interpreted and a trailing separator is insignificant. Defaults to `[]` when `lint` is present but `ignore` is absent | + +When the section is absent (or the config cannot be loaded), lint behavior is unchanged — every directory is linted. Structural type errors (a non-mapping `lint`, a non-list `lint.ignore`, or a non-string element) raise `ValueError` at load time. + +The general file location, loading rules, and the shared example live in [Project Configuration](../../configuration/project.md). diff --git a/docs/features/lint/errors.md b/docs/features/lint/errors.md new file mode 100644 index 00000000..9cb5266c --- /dev/null +++ b/docs/features/lint/errors.md @@ -0,0 +1,93 @@ +# Lint — Errors + +The catalog of validation errors [`goga lint`](cli.md) reports — one entry per rule. 24 rules in two scopes: **document-level** (21, applied to each CODEMANIFEST by the AST visitor) and **tree-level** (3, applied across the import graph by the analyzer). + +## Reading an error + +``` +[RULE_NAME] Error message + --> path/to/CODEMANIFEST + --- + yaml_fragment_key: value + ... +``` + +The rule name in brackets, the message, the offending document, and the YAML fragment that triggered it. A closing summary counts the run: + +``` +goga lint +------------------------- +cells: N errors: M +``` + +Structural failures that are not rule violations surface as parse errors: a missing `CODEMANIFEST` (`DocumentNotFoundError`), a document that is not valid YAML or violates the document shape (`DocumentParseError`) — see [Architecture — Error Handling](../../architecture/ast-errors.md). + +## Import errors (8) + +The `Imports` section of the header. + +| Rule | Scope | The error means | +|---|---|---| +| `ImportsCanNotBeEmpty` | Document | The document has no import block — every document must carry one (an empty `Imports: []` where nothing is imported is still declared) | +| `ImportsHasOnlyValidKeys` | Document | An import item carries a key other than `Types`, `Usages`, `From` | +| `ImportItemIsValid` | Document | An import item is malformed — a non-mapping item, or a missing/invalid `From` | +| `ImportHasNotDuplicate` | Document | The same import entry appears twice in the list | +| `ImportHasValidFromPath` | Document | The `From` path is not a valid source path (escapes the project, absolute, malformed) | +| `ImportUsageExists` | Document | A usage file referenced in imports does not exist at `{From}/.usages/<name>.md` | +| `ImportIsUsed` | Document | A declared import is never referenced in the document body | +| `ImportTypeExists` | Tree | An imported type exists nowhere in the project tree | + +## Usage errors (4) + +The `Usages` section of the header. + +| Rule | Scope | The error means | +|---|---|---| +| `AllUsagesIsUsed` | Document | A declared usage is never referenced in any annotation | +| `UsageFilepathExists` | Document | A usage declared by file path does not exist on disk (project-level practices must reside in `.goga/usages/`) | +| `UsageUrlIsAccessible` | Document | A usage declared by URL is not reachable (results are cached between runs) | +| `UsageLinksHasNotConflicts` | Document | Two usage links resolve to the same name — an import collides with a local `Usages` key | + +## Structure errors (6) + +The body — entities, routines, signatures, locations. + +| Rule | Scope | The error means | +|---|---|---| +| `EntitiesAndRoutinesHasNotConflicts` | Document | An entity and a routine collide by name in one document | +| `EntityHasOnlyValidKeys` | Document | An entity declaration carries a key other than `location`, `annotations`, `methods`, `properties` | +| `RoutineHasOnlyValidKeys` | Document | A routine declaration carries a key other than `location`, `annotations` | +| `SignatureIsValid` | Document | A type signature does not follow the expected format | +| `LocationIsRequired` | Document | An entity or routine has no `location` — the expected file placement | +| `ReturnTypeHasLink` | Document | A return type in a signature has no paired semantic label (`-> value:Type`, not `-> Type`) | + +## Mutation errors (3) + +Mutation declarations on entities. + +| Rule | Scope | The error means | +|---|---|---| +| `MutationExists` | Document | The base type of a mutation does not exist | +| `MutationIsValid` | Document | The mutation declaration is malformed | +| `EmbeddedEntityCanNotHasMutations` | Document | An embedded entity (`->Type: {}`) declares mutations | + +## Annotation errors (1) + +| Rule | Scope | The error means | +|---|---|---| +| `AnnotationLinksExists` | Document | A backtick reference in an annotation points to no entity of the document context — a signature variable, a type, or a practice that does not resolve | + +## Tree-level errors (3) + +Rules that need the cross-document context. + +| Rule | The error means | +|---|---| +| `ImportsHasNotCyclicalDeps` | A circular import chain exists between CODEMANIFEST documents (cell A imports from B while B imports from A) | +| `ImportTypeExists` | An imported type cannot be found anywhere in the full document tree | +| `EmbeddedTypeHasLowLevel` | An embedded entity does not follow the correct hierarchy level relative to its parent | + +## Where to next + +- [Validation Rules Reference](../../architecture/validation-rules.md) — the same rules from the implementation side. +- [AST Visitor](../../architecture/ast-visitor.md) / [AST Analyzer](../../architecture/ast-analyzer.md) — how document-level and tree-level rules are applied. diff --git a/docs/features/lint/hooks.md b/docs/features/lint/hooks.md new file mode 100644 index 00000000..5e6cdab8 --- /dev/null +++ b/docs/features/lint/hooks.md @@ -0,0 +1,5 @@ +# Lint — Hooks + +The lint domain exposes **no hook actions** for tool packages today. + +The rule set is fixed by the goga AST; a project extends validation through its conventions (`.goga/usages/`) rather than through hooks. The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/lint/index.md b/docs/features/lint/index.md new file mode 100644 index 00000000..62ca6c1d --- /dev/null +++ b/docs/features/lint/index.md @@ -0,0 +1,20 @@ +# Lint + +Validate CODEMANIFEST files in a project. + +The lint domain is the structural gate of the DSL. Which tasks it solves: + +- **Validate the manifest** — `goga lint` parses every `CODEMANIFEST` in the project tree and checks it against the rule set: 21 document-level rules (applied per document by the AST visitor) and 3 tree-level rules (applied across the import graph by the analyzer). +- **Report precisely** — every error carries the rule name, the message, the document path, and the offending YAML fragment; a closing summary counts cells and errors. +- **Scope the noise** — the `lint.ignore` list prunes directories (a vendored `.venv`, a build output) from traversal before validation. +- **Explain the failures** — the [error catalog](errors.md) describes what each rule checks and what a violation means. + +What each rule *means* semantically — the DSL itself — is covered in [Cell](../../cell/index.md); how the rules are implemented (the visitor, the analyzer, the error hierarchy) in [Architecture](../../architecture/index.md). + +## In this directory + +- [CLI](cli.md) — the full `goga lint` command reference +- [Configuration](configuration.md) — the `lint:` section of `.goga/config.yml` +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.ast` package facade +- [Errors](errors.md) — the catalog of validation errors diff --git a/docs/features/pipelines/api.md b/docs/features/pipelines/api.md new file mode 100644 index 00000000..03e69abb --- /dev/null +++ b/docs/features/pipelines/api.md @@ -0,0 +1,63 @@ +# Pipelines — API + +The facade of the domain package **`goga.pipeline`** — discovery and run coordination of goga pipeline files. The DSL parsing and flow compilation live in the nested cells `goga.pipeline.workflow` and `goga.pipeline.compiler`; this facade carries the discovery, description, and run surfaces. + +The signatures below are the CODEMANIFEST contract of the cell. + +## Discovery and description + +```python +list_pipelines(project_dir: Path, user_dir: Path) -> list[PipelineEntry] +describe_pipelines(project_dir: Path, user_dir: Path) -> list[PipelineSummary] +describe_pipeline(name: str, project_dir: Path, user_dir: Path, + workflow: str | None, no_workflow: bool) -> PipelineCard +``` + +`list_pipelines` enumerates the flat `*.yml` files of the two sources (project wins on name conflict); `describe_pipelines` adds each pipeline's header fields; `describe_pipeline` compiles the pipeline with the same workflow rule set as a run and returns its card — the stages in execution order. + +```python +PipelineEntry(name: str, source: PipelineSource) +PipelineSummary(name: str, source: PipelineSource, description: str, display_name: str = "") +PipelineCard(name: str, description: str, stages: list[CardStage]) +CardStage(id: str, title: str) +``` + +The discovery and description result types. `PipelineSource` distinguishes the project and user origins. + +## Workflow resolution and stage ordering + +```python +resolve_workflow(pipeline_name: str, workflow_name: str | None, + no_workflow: bool) -> WorkflowDocument | None +apply_skip_stages(workflow: WorkflowDocument | None, skip_stages: list[str]) -> WorkflowDocument | None +order_stages(stages: list[FlowStage]) -> list[FlowStage] +``` + +`resolve_workflow` applies the three invocation modes — auto-match, explicit `--workflow`, `--no-workflow`. `apply_skip_stages` removes skipped stages and reconnects their dependents. `order_stages` topologically orders the compiled stages for execution. + +## Execution + +```python +run_pipeline(name: str, project_dir: Path, user_dir: Path, port: int, + parallel: int | None = None) -> int +pipeline_cli(argv: list[str]) -> int +``` + +`run_pipeline` is the in-container execution: compile the pipeline-file into a flow-file, materialize the agent prompts, and run it via `afm` — the container exit code is returned. `parallel` caps the stages afm executes concurrently (`None` — unbounded). `pipeline_cli` is the in-container argparse entry point behind `goga pipeline` (the host-side launcher is the [Install/CLI layer](cli.md)). + +## Example + +```python +from pathlib import Path +from goga.pipeline import describe_pipeline, list_pipelines + +project = Path(".goga/pipelines") +user = Path.home() / ".goga/pipelines" + +for entry in list_pipelines(project, user): + print(entry.name, entry.source) + +card = describe_pipeline("development", project, user, None, False) +for stage in card.stages: + print(stage.id, "—", stage.title) +``` diff --git a/docs/cli/pipeline.md b/docs/features/pipelines/cli.md similarity index 99% rename from docs/cli/pipeline.md rename to docs/features/pipelines/cli.md index 24acd9fe..b5ede081 100644 --- a/docs/cli/pipeline.md +++ b/docs/features/pipelines/cli.md @@ -67,7 +67,7 @@ Pipelines are flat `*.yml` files (one per pipeline) resolved from two directorie | Source | Directory | Origin | |---------|--------------------------|------------------------------------------------------------| | project | `<cwd>/.goga/pipelines/` | Checked into / authored for the current project | -| user | `~/.goga/pipelines/` | Installed centrally by `goga connect` (see [connect](connect.md)) | +| user | `~/.goga/pipelines/` | Installed centrally by `goga connect` (see [connect](../connect/cli.md)) | Only top-level `*.yml` is scanned — subdirectories are ignored, and `.yaml` files are excluded. Pipeline path resolution and discovery happen **inside** the container (the host does not resolve pipeline paths). @@ -167,7 +167,7 @@ For a run, the decision reaches the container via the env-file (`GOGA_WORKFLOW_N When a workflow will actually be applied to a run (explicit `--workflow`, or an auto-match file that exists), the launcher prints `Pipeline running with workflow "<name>"` to stdout. When no workflow applies, the launcher prints no workflow line. The launcher surfaces only the workflow log line, the `docker` output stream, any pre-launch version-check warning or refusal on stderr (see [Pre-launch version check](#pre-launch-version-check)), and, in the run form with `-t`, the single topic result line. -Inside the container the goga in-container process resolves and parses the workflow-file, then forwards it to the compiler, which reconstructs the parsed body: `extend` entries inject new stages positioned via `before`/`after`, per-stage `agent` overrides compose the in-container wrapper path into the stage's `command` slot, per-stage `prompt` overrides fill its `description` slot, `skip: true` removes the stage and reconnects its dependents' `depends_on`, a `loop: N` (N ≥ 2) expands the stage into `NAME-1`..`NAME-N` copies with chained internal `depends_on` (external references are rewritten to the LAST expanded id), `manual: true|false` forces or cancels the stage's manual launch mode (compiling to the afm `auto_run` key), and a `memory` block with per-stage `reflect` / `memory` instructions emits the afm top-level `memory` block and the per-stage `reflect` / `memory_use` keys (only when at least one stage participates — see [Workflows — Project memory](../pipelines/workflows.md#project-memory-memory-reflect)). +Inside the container the goga in-container process resolves and parses the workflow-file, then forwards it to the compiler, which reconstructs the parsed body: `extend` entries inject new stages positioned via `before`/`after`, per-stage `agent` overrides compose the in-container wrapper path into the stage's `command` slot, per-stage `prompt` overrides fill its `description` slot, `skip: true` removes the stage and reconnects its dependents' `depends_on`, a `loop: N` (N ≥ 2) expands the stage into `NAME-1`..`NAME-N` copies with chained internal `depends_on` (external references are rewritten to the LAST expanded id), `manual: true|false` forces or cancels the stage's manual launch mode (compiling to the afm `auto_run` key), and a `memory` block with per-stage `reflect` / `memory` instructions emits the afm top-level `memory` block and the per-stage `reflect` / `memory_use` keys (only when at least one stage participates — see [Workflows — Project memory](workflows.md#project-memory-memory-reflect)). Example workflow-file: diff --git a/docs/features/pipelines/configuration.md b/docs/features/pipelines/configuration.md new file mode 100644 index 00000000..9d66ed82 --- /dev/null +++ b/docs/features/pipelines/configuration.md @@ -0,0 +1,22 @@ +# Pipelines — Configuration + +The pipelines domain reads one optional section of `.goga/config.yml` — `pipeline`, the afm execution settings. The section must be present for the run form: `goga pipeline <name>` exits with a `ClickException` naming `pipeline` when it is absent (the list/info forms do not read it). + +```yaml +pipeline: + agent: claude # the agent that runs the stages inside the container + env: {} # environment variables of the pipeline container + proxy: http://corp:3128 + hosts: {foo.local: "127.0.0.1"} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `pipeline.agent` | `string` | No | AI agent that runs the pipeline stages inside the container. Optional at the loader level — absent/YAML-null/empty/whitespace resolves to `None`. When `None`, the agent may be supplied by a per-stage workflow override or afm's own default, so `goga pipeline` does not require it. Same resolution mechanic and baseline set as `build.task_executor.agent` — see [Agents](../../configuration/agents.md) | +| `pipeline.env` | mapping | No | Environment variables passed into the pipeline container. Keys and values must be strings. Defaults to `{}` | +| `pipeline.proxy` | `string` | No | HTTP/HTTPS proxy URL for the pipeline container. When set, `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` are written to the container env-file. Overridden by the `--proxy` CLI option | +| `pipeline.hosts` | mapping | No | Host→IP mapping for `docker run --add-host`. Defaults to `{}`. Augmented by the repeatable `--add-host` CLI option (CLI wins on key conflict) | + +Two adjacent settings the domain consumes live at the top level of the file, not inside the section: `image` — the container image both `goga build` and `goga pipeline` launch — and `dockerfile` — the project Dockerfile `goga pipeline --update` builds from when set (see [Project Configuration](../../configuration/project.md)). + +The general file location, loading rules, and the shared example live in [Project Configuration](../../configuration/project.md). diff --git a/docs/features/pipelines/hooks.md b/docs/features/pipelines/hooks.md new file mode 100644 index 00000000..639980b8 --- /dev/null +++ b/docs/features/pipelines/hooks.md @@ -0,0 +1,5 @@ +# Pipelines — Hooks + +The pipelines domain exposes **no hook actions** for tool packages today. + +A tool package reaches the pipeline surface not through hooks but through its own artifacts: its skills merge into pipeline stages via the workflow `skills:` mechanism, and its pipeline-files install namespaced as `<tool>:<name>.yml` and run as `goga pipeline <tool>:<name>` (see [Tools](../tools/index.md)). The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/pipelines/index.md b/docs/features/pipelines/index.md similarity index 89% rename from docs/pipelines/index.md rename to docs/features/pipelines/index.md index 3d725d9f..d814657e 100644 --- a/docs/pipelines/index.md +++ b/docs/features/pipelines/index.md @@ -20,10 +20,10 @@ Pipelines ship six ready-to-use definitions: The shipped pipelines are described in detail in [Shipped Pipelines](shipped.md). -This section documents the **functional model** of pipelines — what a +This page documents the **functional model** of pipelines — what a pipeline-file is, what a workflow is, and how the two relate. For invocation flags, exit codes, and Docker mechanics, see the -[`goga pipeline` CLI reference](../cli/pipeline.md). +[`goga pipeline` CLI reference](cli.md). ## Pipeline files vs workflows @@ -104,4 +104,14 @@ for the full semantics. - Layer project-specific behavior on top — read the [Workflows](workflows.md) reference. - Run a pipeline from the command line — see the - [`goga pipeline` CLI reference](../cli/pipeline.md). + [`goga pipeline` CLI reference](cli.md). + +## In this directory + +- [CLI](cli.md) — the full `goga pipeline` command reference +- [Configuration](configuration.md) — the `pipeline:` section of `.goga/config.yml` +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.pipeline` package facade +- [Pipeline File](pipeline-file.md) — the base authoring document +- [Workflows](workflows.md) — the project-specific layering document +- [Shipped Pipelines](shipped.md) — the six ready-to-use definitions diff --git a/docs/pipelines/pipeline-file.md b/docs/features/pipelines/pipeline-file.md similarity index 99% rename from docs/pipelines/pipeline-file.md rename to docs/features/pipelines/pipeline-file.md index 60f702ca..e3aebdab 100644 --- a/docs/pipelines/pipeline-file.md +++ b/docs/features/pipelines/pipeline-file.md @@ -471,5 +471,5 @@ workflow-agent semantics. - [Workflows](workflows.md) — layer project-specific overrides on top of a compiled pipeline. -- [`goga pipeline` CLI reference](../cli/pipeline.md) — invocation flags, +- [`goga pipeline` CLI reference](cli.md) — invocation flags, exit codes, and Docker mechanics. diff --git a/docs/pipelines/shipped.md b/docs/features/pipelines/shipped.md similarity index 98% rename from docs/pipelines/shipped.md rename to docs/features/pipelines/shipped.md index c1ae2885..466723a2 100644 --- a/docs/pipelines/shipped.md +++ b/docs/features/pipelines/shipped.md @@ -61,7 +61,7 @@ resolved by the `--force-overwrite` flag passed to `goga connect`: | `true` | The tool's pipeline overwrites the existing file at `<tool>:<name>.yml`. | This mirrors the residual-conflict semantics used for tool-skill -installation — see [`goga connect`](../cli/connect.md). +installation — see [`goga connect`](../connect/cli.md). ### Idempotency @@ -233,6 +233,6 @@ recreates that directory on every run. forking a pipeline. - [Workflows](workflows.md) — layer project-specific behavior on top of a shipped pipeline without forking it. -- [`goga connect` CLI reference](../cli/connect.md) — install shipped +- [`goga connect` CLI reference](../connect/cli.md) — install shipped pipelines into the user pipeline directory, namespacing rules, and residual conflict-resolution semantics. diff --git a/docs/pipelines/workflows.md b/docs/features/pipelines/workflows.md similarity index 99% rename from docs/pipelines/workflows.md rename to docs/features/pipelines/workflows.md index 332a3837..981f20a1 100644 --- a/docs/pipelines/workflows.md +++ b/docs/features/pipelines/workflows.md @@ -158,7 +158,7 @@ pipeline itself does not care which concrete CLI is underneath. The canonical baseline wrappers shipped with the image — `claude`, `codex`, `cursor`, `opencode`, `qwen` — and their per-agent environment variables are -documented in [Agents](../configuration/agents.md) under +documented in [Agents](../../configuration/agents.md) under the Configuration reference. This page keeps the workflow-scoped agent semantics (per-stage override, inline-extend agent); the wrapper set itself is shared with build and pipeline. @@ -824,7 +824,7 @@ The card form honors the same three modes: `goga pipeline deploy --info rule set, with the same host-side validation. The decision travels as `docker run` argv (not the env-file), and the stage list the card prints is exactly the composition a run with the same flags executes. See -[pipeline](../cli/pipeline.md). +[pipeline](cli.md). ### Log line @@ -1004,5 +1004,5 @@ untouched — `extend` layers new stages on top at run time. - [Pipeline File](pipeline-file.md) — the base document a workflow layers on top of. -- [`goga pipeline` CLI reference](../cli/pipeline.md) — invocation flags +- [`goga pipeline` CLI reference](cli.md) — invocation flags for `--workflow` / `--no-workflow` and exit codes. diff --git a/docs/features/schema/api.md b/docs/features/schema/api.md new file mode 100644 index 00000000..31c90a4d --- /dev/null +++ b/docs/features/schema/api.md @@ -0,0 +1,20 @@ +# Schema — API + +The facade of the domain package **`goga.schema`** — the JSON schema tree generation from project CODEMANIFEST files. + +The signature below is the CODEMANIFEST contract of the cell. + +```python +schema(cells: list[str], max_depth: int = None, depends_on: list[str] = []) -> str +``` + +Walk the project cells and emit the JSON schema tree. `cells` — the positional scope (the named cells only); `max_depth` — the bound on the import expansion depth; `depends_on` — keep only the cells connected to the named ones. The returned string is the JSON document the `goga schema` command prints: every declared entity and routine with its signature, location, annotations, methods, and properties. + +## Example + +```python +from goga.schema import schema + +doc = schema(cells=[], max_depth=None, depends_on=[]) +print(doc) +``` diff --git a/docs/cli/schema.md b/docs/features/schema/cli.md similarity index 100% rename from docs/cli/schema.md rename to docs/features/schema/cli.md diff --git a/docs/features/schema/configuration.md b/docs/features/schema/configuration.md new file mode 100644 index 00000000..f03b4c97 --- /dev/null +++ b/docs/features/schema/configuration.md @@ -0,0 +1,5 @@ +# Schema — Configuration + +The schema domain reads **no section of `.goga/config.yml`** — the export is fully scoped by the CLI arguments (the positional cells, `--max-depth`, `--depends-on`). + +The cells it walks come from the project structure itself (every directory with a `CODEMANIFEST` — see [Cell](../../cell/index.md)). The general configuration model of the product is covered in [Configuration](../../configuration/index.md). diff --git a/docs/features/schema/hooks.md b/docs/features/schema/hooks.md new file mode 100644 index 00000000..cb9e3a5d --- /dev/null +++ b/docs/features/schema/hooks.md @@ -0,0 +1,5 @@ +# Schema — Hooks + +The schema domain exposes **no hook actions** for tool packages today. + +A tool that needs the project structure receives it through the optional AST injection of its `main` entry point instead (see [Tools — CLI](../tools/cli.md#optional-injections)). The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/schema/index.md b/docs/features/schema/index.md new file mode 100644 index 00000000..9fe99220 --- /dev/null +++ b/docs/features/schema/index.md @@ -0,0 +1,16 @@ +# Schema + +Generate a JSON schema tree from project CODEMANIFEST files. + +The schema domain exposes the contract layer to schema consumers. Which tasks it solves: + +- **Export the contract** — `goga schema` walks the project cells and emits a JSON tree of the declared types: every entity and routine with its signature, location, annotations, methods, and properties. +- **Scope the export** — positional cells limit the output to the named cells; `--max-depth N` bounds the import expansion; `--depends-on CELL` keeps only the cells connected to the given one. +- **Feed tooling** — the JSON output is the machine-readable projection of the CODEMANIFEST layer: viewers, generators, and external validators consume it instead of re-parsing the DSL. + +## In this directory + +- [CLI](cli.md) — the full `goga schema` command reference +- [Configuration](configuration.md) — the domain reads no configuration section +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.schema` package facade diff --git a/docs/features/tools/api.md b/docs/features/tools/api.md new file mode 100644 index 00000000..8b831a71 --- /dev/null +++ b/docs/features/tools/api.md @@ -0,0 +1,23 @@ +# Tools — API + +The facade of the domain package **`goga.commands.tool`** — the dynamic invocation of an installed tool package. + +The signatures below are the CODEMANIFEST contract of the cell. + +```python +tool(name: str, args: list[str]) +build_injections(main: Callable) -> dict[str, object] +``` + +- `tool` — resolve the `goga_tool_<name>` package installed in the running interpreter, import it, and call its `main` facade with `args`. The call is transparent: the tool's output and exit behavior pass through unchanged; a missing package or a broken import surfaces as a clean CLI error. +- `build_injections` — inspect a tool's `main` callable and build the opt-in injection values for the parameters it declares (the keyword-capable `ast` parameter receives the project AST). A `main` that declares no injections receives an empty mapping and the AST is never built. + +A tool package's own facade API — `main(argv)`, `install(user)`, `register_hooks(hooks)` — is authored by the tool; the contract of each callback is covered in [Hooks](hooks.md). + +## Example + +```python +from goga.commands.tool import tool + +tool("mkdocs", ["--help"]) +``` diff --git a/docs/cli/tool.md b/docs/features/tools/cli.md similarity index 100% rename from docs/cli/tool.md rename to docs/features/tools/cli.md diff --git a/docs/features/tools/configuration.md b/docs/features/tools/configuration.md new file mode 100644 index 00000000..fbbe41ff --- /dev/null +++ b/docs/features/tools/configuration.md @@ -0,0 +1,7 @@ +# Tools — Configuration + +The tools domain reads **no dedicated section of `.goga/config.yml`**. + +The `tools:` mapping of the project configuration — the version declarations consumed by `goga install` in bulk mode — belongs to the [Install](../install/configuration.md) domain (the command that reads it). A tool package's own behavior is configured by nothing in the project: it receives its inputs through the CLI invocation, the optional AST injection, and the hook contexts. + +The general configuration model of the product is covered in [Configuration](../../configuration/index.md). diff --git a/docs/features/tools/hooks.md b/docs/features/tools/hooks.md new file mode 100644 index 00000000..c65a9b39 --- /dev/null +++ b/docs/features/tools/hooks.md @@ -0,0 +1,18 @@ +# Tools — Hooks + +The tools domain is the **consumer side** of the hooks platform: a tool package declares the callbacks, the domains fire the actions. + +A tool package may expose two lifecycle callables in its facade, next to the required `main(argv)`: + +| Callable | Called by | Purpose | +|---|---|---| +| `register_hooks(hooks)` | a domain checkpoint, or `goga hooks` | Subscribe hooks to domain actions — `hooks.subscribe(domain, action, name, hook)`; registration is never cached, package edits apply from the next run | +| `install(user: str \| None = None)` | `goga install`, after a successful pip | The post-install lifecycle hook (see [Install — CLI, post-install hooks](../install/cli.md#post-install-hooks)) | + +```python +# inside the goga_tool_<tool> package +def register_hooks(hooks): + hooks.subscribe("statuses", "register_statuses", "published", register_published) +``` + +The full registration contract — the hook signature (`context` / `self`), the error classes, the diagnostics — is the [Hooks domain](../hooks/hooks.md); the declared actions are listed per domain (today: [History — Hooks](../history/hooks.md)). The `main` entry point and its optional AST injection are covered in [CLI](cli.md#optional-injections). diff --git a/docs/tools.md b/docs/features/tools/index.md similarity index 66% rename from docs/tools.md rename to docs/features/tools/index.md index 37208aa4..0ef202bf 100644 --- a/docs/tools.md +++ b/docs/features/tools/index.md @@ -1,6 +1,14 @@ # Tools -Tools extend goga with specialized capabilities. Each tool is a separate Python package that installs skills into your AI agent and provides CLI commands. +A **tool** is a pluggable capability package: a separate Python package under the `goga_tool_` prefix that installs skills into your AI agent, ships CLI commands, optionally carries pipeline-files, and can extend goga domains with hooks. + +The tools domain covers the ecosystem itself. Which tasks it solves: + +- **Use a tool** — `goga tool <name>` invokes a tool's CLI directly; the `/goga:tool <name>` slash command (or the dispatcher skill) invokes it from an agent session. +- **Package your own tool** — the standard layout (`skills/`, optional `pipelines/`, the `main(argv)` facade) and the naming rules that keep skills and pipelines collision-free. +- **Extend domains** — a tool may expose a `register_hooks` callback and subscribe to domain actions (see [Hooks](hooks.md) and the [Hooks](../hooks/index.md) domain). + +Installing and removing tool packages is the [Install](../install/index.md) domain; the built-in tools (`viewer`, `mkdocs`, `scriba`) ship out of the box. ## Installing a tool @@ -17,7 +25,7 @@ goga install <tool-name> --version 1.0.x goga install ``` -See [`goga install`](cli/install.md) for the version grammar and single / bulk / empty modes. +See [`goga install`](../install/cli.md) for the version grammar and single / bulk / empty modes. After installing, connect the tool to your agent: @@ -27,7 +35,7 @@ goga connect <agent> If you have already connected an agent, `goga install` automatically re-syncs every connected agent after a successful pip, so the new tool's skills and pipelines appear immediately — no separate `goga connect` call is needed. `goga connect` is only required the first time (or to connect a new agent); pass `goga install --no-connect` to opt out of activation. -`goga connect` auto-discovers all installed `goga_tool_*` packages and installs their skills centrally into `~/.goga/skills/`, then symlinks them into each connected agent's skills directory. If the package ships any pipeline `*.yml` files under `pipelines/`, those are installed into `~/.goga/pipelines/` in the same step, **namespaced as `<tool>:<name>.yml`** so they are addressable as `goga pipeline <tool>:<name>` (internal pipelines stay un-prefixed) — see [Pipelines / Shipped Pipelines](pipelines/shipped.md) for the namespacing and residual-conflict rules. The tool becomes available both as an agent skill and as a CLI command. +`goga connect` auto-discovers all installed `goga_tool_*` packages and installs their skills centrally into `~/.goga/skills/`, then symlinks them into each connected agent's skills directory. If the package ships any pipeline `*.yml` files under `pipelines/`, those are installed into `~/.goga/pipelines/` in the same step, **namespaced as `<tool>:<name>.yml`** so they are addressable as `goga pipeline <tool>:<name>` (internal pipelines stay un-prefixed) — see [Pipelines / Shipped Pipelines](../pipelines/shipped.md) for the namespacing and residual-conflict rules. The tool becomes available both as an agent skill and as a CLI command. ## Removing a tool @@ -50,7 +58,7 @@ goga uninstall <tool-name> --user alice After a successful pip uninstall, every connected agent is re-synced: the removed tool's skills and pipelines disappear from `~/.goga/` and from each agent's symlink tree. A tool removed by hand with plain pip leaves those artifacts behind until the next re-sync. -See [`goga uninstall`](cli/uninstall.md) for the full confirmation, sudo/user, and exit-code semantics. +See [`goga uninstall`](../install/uninstall.md) for the full confirmation, sudo/user, and exit-code semantics. ## Built-in tools @@ -98,7 +106,7 @@ A valid tool must: A tool package may define three facade callbacks, separated by nature: `main` (the CLI call of the tool — execution), `install` (the post-install lifecycle hook), and `register_hooks` (the domain-extension registration, -see [Domain extensions](#domain-extensions)). `main` is required; the +see [Hooks](hooks.md)). `main` is required; the other two are optional. A tool **may** additionally expose an `install(user: str | None = None)` @@ -107,7 +115,7 @@ it after a successful pip, passing the initiating user (`SUDO_USER` when goga itself runs under sudo, else the current OS user) only when the parameter is declared keyword-capable; otherwise the hook is called with no arguments. A missing or non-callable `install` is skipped quietly. See -[`goga install` — Post-install hooks](cli/install.md#post-install-hooks). +[`goga install` — Post-install hooks](../install/cli.md#post-install-hooks). A `pipelines/` directory is **optional**. When present, `goga connect` copies its flat `*.yml` files into `~/.goga/pipelines/` **namespaced as @@ -118,55 +126,16 @@ internal-source pipelines. Namespacing structurally prevents collisions with int pipelines and between two tools shipping the same name; only a residual conflict on the namespaced destination is possible, resolved with the same `--force-overwrite` semantics used for tool-skill installation. See -[Pipelines / Shipped Pipelines](pipelines/shipped.md) for the full +[Pipelines / Shipped Pipelines](../pipelines/shipped.md) for the full installation algorithm. -## Domain extensions - -A tool **may** expose a `register_hooks(hooks)` callable in its facade package — the registration of domain hooks. goga calls it when a command first reaches a hook checkpoint of the run, or when you inspect the registry with [`goga hooks`](cli/hooks.md); commands that use no hooks never call it. Registration is never cached — package edits apply from the next run, without reinstall. - -```python -# inside the goga_tool_<tool> package -def register_hooks(hooks): - hooks.subscribe("statuses", "register_statuses", "published", register_published) - - -def register_published(context): - context.register("published", "mkdocs/published.md", after="planned") -``` - -`hooks.subscribe(domain, action, name, hook)` registers one hook: - -- `domain` + `action` — the action address: the semantic owner domain and the action name within it (`"statuses"` / `"register_statuses"` is the topic-status action). -- `name` — the hook name, unique per tool per address; registrations appear in the [`goga hooks`](cli/hooks.md) tree under their tool line. -- `hook` — the callable executed when the action fires. - -The tool identity is assigned by goga from the package name — a package never names itself, and identical hook names of different tools never collide. Enumeration is deterministic: packages in alphabetical order of top-level module name, subscriptions delivered in enumeration order. - -### The hook signature - -A hook receives values only for the parameters it declares by the fixed offered names — `context` and `self`: - -- `context` — the delivered object of the action. Read attributes and call methods freely; attribute assignment is blocked. What the object carries is fixed by the owner domain's contract — for `register_statuses` it is the status registration surface (`register(name, filepath, before=..., after=...)`, names stored qualified `<tool>.<name>`; see [Topics](cli/topics.md) for the scale rules). -- `self` — the isolated context of your tool. One instance links all its hook invocations of a run; freely mutable by your tool, invisible to the domains. - -The declaration order does not matter; names you did not declare receive nothing. - -### Error classes and diagnostics - -Each action in the catalog fixes how a failing hook is treated. The topic-status action is **soft**: a failing hook is skipped with a stderr warning naming the tool, the action, and the reason, and the command continues. A **hard** action stops the command at the first failing hook with a clean error — the class is chosen by the owner domain when it declares the action. - -At registration: a wrong address, an empty name, or a repeated name on the same address is refused with a stderr warning naming the tool, the action, and the reason — the registration is skipped, the rest apply. A crashing callback is a warning; the registrations made before the crash survive. A broken package import is the only fatal case: a clean error naming the package. - -> **Migration note.** The old `register_topic_statuses(statuses)` callback is gone. After a goga update, a package still carrying it loses its statuses **without any diagnostic** — they silently disappear from the scale. Moving to `register_hooks` is the package author's responsibility. Qualified names of packages with underscores in their name change too: the qualifier is the canonical hyphen identity, so `goga_tool_hello_world` now registers `hello-world.published` where it used to register `hello_world.published` — existing `goga history status -s <tool>.<name>` filters must use the hyphen form. - ## Optional injections `main` may optionally declare a keyword-capable `ast` parameter to receive the project AST (loaded lazily from the current project root, only when declared). A tool that does not need the AST keeps the minimal `main(argv)` form and the AST is never built. Validation errors in the loaded tree pass through to the -tool unchanged. See [goga tool — Optional injections](cli/tool.md#optional-injections) +tool unchanged. See [goga tool — Optional injections](cli.md#optional-injections) for the entry-point forms and opt-in rules. ## Skill naming @@ -205,4 +174,11 @@ The `<tool>` prefix is the canonical hyphenated tool name — the package name w - Name pipeline files with lowercase and hyphens as separators - Use flat `*.yml` files directly under `pipelines/` — no subdirectories - Rely on the automatic `<tool>:` prefix for namespacing; never bake the tool name into the filename yourself -- A residual conflict on the namespaced destination (the same `<tool>:<name>.yml` already exists) is resolved with `goga connect --force-overwrite` — see [Pipelines / Shipped Pipelines](pipelines/shipped.md) \ No newline at end of file +- A residual conflict on the namespaced destination (the same `<tool>:<name>.yml` already exists) is resolved with `goga connect --force-overwrite` — see [Pipelines / Shipped Pipelines](../pipelines/shipped.md) + +## In this directory + +- [CLI](cli.md) — the `goga tool` command reference +- [Configuration](configuration.md) — the `tools:` section of `.goga/config.yml` +- [Hooks](hooks.md) — the tool-package side of domain extension +- [API](api.md) — the `goga.commands.tool` package facade diff --git a/docs/features/topics/api.md b/docs/features/topics/api.md new file mode 100644 index 00000000..86ac900a --- /dev/null +++ b/docs/features/topics/api.md @@ -0,0 +1,86 @@ +# Topics — API + +The facade of the domain package **`goga.topics`** — the work-tracker view of the history tree. Git access lives in the nested leaf cell `goga.topics.git`, the interactive todo entry in `goga.topics.editor`; both surface through this facade's routines. Identity, addressing, and statuses come from `goga.history` (see [History — API](../history/api.md)). + +The signatures below are the CODEMANIFEST contract of the cell. + +## Board + +```python +collect_topic_board(year: str | None = None, remote: bool = False) -> list[BoardRecord] +``` + +Collect the cross-branch topic inventory of one year — every topic with its hosting branch, statuses, and todo summary. `year` as four digits (`None` — the current year); `remote=True` reads remote-tracking refs instead of local branches. Records sort by scale order of the first maximal status, then alphabetically by topic. + +```python +BoardRecord(topic: str, branch: str, statuses: list[str], current: bool, + remote: bool, todo: str | None = None) +``` + +One row of the board. `topic` — the slug; `branch` — the display name of the hosting branch; `statuses` — the qualified names of the maximal present statuses in scale order; `current` — the row hosts the current working branch; `remote` — the hosting ref is remote-tracking; `todo` — the todo summary (the first non-empty line of `todo.md` after `#` markers are stripped) or `None`. + +## Switching and ensuring + +```python +resolve_switch_candidates(identifier: str, year: str | None = None) -> list[SwitchCandidate] +switch_topic(identifier: str, todo: bool = False, year: str | None = None) -> str +ensure_topic(identifier: str, todo: bool = False, year: str | None = None) -> str +``` + +`resolve_switch_candidates` resolves an identifier through the three tiers — exact branch name, exact topic slug (local before remote), prefix — and returns the candidates. `switch_topic` brings the repository onto the hosting branch (a `ValueError` carries ambiguity and clean-failure reasons). `ensure_topic` is the combined orchestration: it switches onto hosted work and creates fresh work from the current HEAD when nothing hosts the identifier — the routine behind `goga pipeline <name> -t`. With `todo=True` the external editor opens the topic's `todo.md` after the switch. Each returns its single result line. + +```python +SwitchCandidate(branch: str, topic: str | None, statuses: list[str], + current: bool, remote: bool) +``` + +One resolution candidate — `topic` is `None` for a branch hosting no topic. + +## Creation + +```python +create_topic(branch_name: str, base_ref: str, todo: str | None = None, + publish: bool = False, commit_message: str | None = None, + year: str | None = None, switch: bool = False) -> str +``` + +Create fresh work — a branch named verbatim at `base_ref` with the topic of the year. The default path plants one quarantined commit carrying the topic's `todo.md` (git plumbing, the working copy untouched) — the todo is required there. `switch=True` checks the branch out instead (the topic directory lands uncommitted, the todo optional). `publish=True` builds the same one-commit branch and pushes it to `origin` without switching; `commit_message` is the publication-only commit template. Returns the result line. + +```python +enter_topic_todo(topic: str, year: str | None = None) -> bool +publish_topic(branch_name: str, todo: str, base_ref: str, + commit_message: str | None = None, year: str | None = None) -> str +``` + +`enter_topic_todo` opens the external editor on the topic's `todo.md` (`True` — saved, `False` — cancelled). `publish_topic` is the fast creation-and-publication cycle. + +## Occupancy oracles + +```python +check_branch_occupancy(branch_name: str, slug: str, year: str | None = None) -> str | None +check_slug_occupancy(slug: str, year: str | None = None) -> str | None +``` + +Read-only probes: the first returns the conflict reason when the branch name or the topic is already occupied, the second when the slug's topic directory or hosted branch exists. `None` — free. + +## Deletion + +```python +resolve_delete_targets(identifiers: list[str], year: str | None = None) -> list[DeleteTarget] +delete_topics(targets: list[DeleteTarget], year: str | None = None) -> str +``` + +`resolve_delete_targets` resolves every identifier first (all-or-nothing; a `ValueError` carries ambiguity, merged work, and current-branch reasons). `delete_topics` removes each target's local branch, `origin` twin, and topic directory; a rejected remote deletion restores the failing target's local branch. `DeleteTarget(topic, branch, remote, has_dir)` carries the resolved target. + +## Example + +```python +from goga.topics import collect_topic_board, ensure_topic + +for record in collect_topic_board("2026"): + print(record.topic, record.branch, record.statuses) + +# onto the branch hosting feat-x — or fresh work when nothing hosts it +line = ensure_topic("feat-x") +print(line) # e.g. "Switched to branch feat/x" +``` diff --git a/docs/cli/topics.md b/docs/features/topics/cli.md similarity index 98% rename from docs/cli/topics.md rename to docs/features/topics/cli.md index 36350df2..d23eeb34 100644 --- a/docs/cli/topics.md +++ b/docs/features/topics/cli.md @@ -40,7 +40,7 @@ Prints the board — the cross-branch topic inventory of the scoped year — as - The statuses column wraps onto continuation lines when the segments overflow the terminal width; the table never exceeds the width except on terminals below the narrow threshold of the active column rule — 33 columns for the three-column table, 44 with `--info` — where every column keeps a minimum of 8. - An empty board prints nothing and exits 0 — a year without topics is not an error. -The statuses are the topic's **maximal present statuses** in scale order — `empty, todo, defined, discovered, backlog, designed, specified, planned, done`, deepening as `todo.md`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. Tool packages can add their own statuses, shown qualified (`mkdocs.published`); see [Tools](../tools.md). +The statuses are the topic's **maximal present statuses** in scale order — `empty, todo, defined, discovered, backlog, designed, specified, planned, done`, deepening as `todo.md`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. Tool packages can add their own statuses, shown qualified (`mkdocs.published`); see [Tools](../tools/index.md). ## `goga topics create` @@ -59,7 +59,7 @@ goga topics create Feature/Foo_Bar --base-ref origin/main --switch ``` - The branch name is taken verbatim; git itself rejects invalid names. The default path builds one quarantined commit carrying the topic's `todo.md` on top of the resolved base commit — git plumbing that never touches the working copy, so a dirty tree and a detached HEAD do not interfere — and plants the branch at it (`git update-ref --stdin`); no switch happens, and `goga topics switch <name>` brings you onto the work later. `-s`/`--switch` plants the branch at the base and checks it out instead (`git switch`) — a failed checkout rolls the planted branch back so the name never strands — with the topic directory created in the working copy, uncommitted. -- The base resolves as `--base-ref` > `topics.base_ref` in `.goga/config.yml` > the current HEAD under `--from-current` > clean error. With nothing set, exit 1 with a message naming the flag, the flag alternative, and the configuration line, including a two-line YAML example (see [Project Configuration](../configuration/project.md#topics)). +- The base resolves as `--base-ref` > `topics.base_ref` in `.goga/config.yml` > the current HEAD under `--from-current` > clean error. With nothing set, exit 1 with a message naming the flag, the flag alternative, and the configuration line, including a two-line YAML example (see [Configuration](configuration.md)). - The topic directory is `.goga/history/<YYYY>/<slug>/`, where the slug is the normalized name (lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed: `Feature/Foo_Bar` → `feature-foo-bar`). The default path carries the directory as the committed `todo.md`; `--switch` creates it on disk. No artifact file is written unless a todo resolves. - `-t`/`--todo` takes the todo value on the command line — the multi-line text as entered plus one trailing newline, UTF-8 — which marks the topic `todo` on the status scale and feeds the `--info` column of the board. An empty value — `--todo ""`, `--todo=`, `-t ""` — counts as absent: no `todo.md` is ever created empty. - The todo is **required** on the default path — git keeps no empty directories, so the work exists only through its committed `todo.md`. A cancelled editor entry or a missing todo exits 1 with `the local creation needs a todo — the board reads the topic through todo.md; pass --todo/-t or --switch/-s to create on the spot without one`. Under `--switch` the todo is optional. @@ -136,7 +136,7 @@ With `--todo` the external editor opens with the switched topic's `todo.md` afte - The chosen candidate must host a topic — `branch '<name>' hosts no topic — switching creates nothing` (exit 1); switching never creates anything. - Already sitting on the hosting branch still enters the todo — the idempotent switch carries the entry. -The same resolution backs the switch half of `goga pipeline <name> -t <identifier>` — there, an identifier nothing hosts creates fresh work instead of failing, and a sibling `--todo` flag opens the same entry (see [pipeline](pipeline.md#topic-switch)). +The same resolution backs the switch half of `goga pipeline <name> -t <identifier>` — there, an identifier nothing hosts creates fresh work instead of failing, and a sibling `--todo` flag opens the same entry (see [pipeline](../pipelines/cli.md#topic-switch)). ## `goga topics delete` @@ -172,4 +172,4 @@ Every IDENTIFIER resolves first — a branch name, a topic slug, or their prefix ## Notes - Every mutation is local except the two `origin` pushes — the `--publish` push and the delete push; no fetch ever happens. -- `goga history status` shows the same statuses scoped to the working copy of one year (see [history](history.md)). +- `goga history status` shows the same statuses scoped to the working copy of one year (see [history](../history/cli.md)). diff --git a/docs/features/topics/configuration.md b/docs/features/topics/configuration.md new file mode 100644 index 00000000..766ee3c8 --- /dev/null +++ b/docs/features/topics/configuration.md @@ -0,0 +1,18 @@ +# Topics — Configuration + +The topics domain reads one optional section of `.goga/config.yml` — `topics`, consumed by [`goga topics create`](cli.md). The section is read lazily: only when a value no CLI flag provided has to come from it. + +```yaml +topics: + base_ref: origin/main # default base of created topic branches + publish_commit: "feat: {slug} todo" # commit template of the published todo commit +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `topics.base_ref` | `string` | No | Base revision of a created topic branch — any revision string (branch, remote-tracking ref, tag, hash), stored verbatim with no resolvability check. Absent/YAML-null/empty/whitespace resolves to `None`; a non-string raises `ValueError`. Overridden by the `--base-ref` CLI option; the base resolves as `--base-ref` > `topics.base_ref` > the current HEAD under `--from-current` — a creation with none of the three exits 1 | +| `topics.publish_commit` | `string` | No | Commit message template of the published todo commit; the optional `{slug}` placeholder is replaced with the topic slug, and a template without it is used verbatim. Same normalization and typing rules as `base_ref`. Overridden by the `--commit`/`-c` CLI option (publication-only); the built-in default is `goga: create topic {slug}` | + +When `topics` is absent, the configuration is "everything unset". Unknown keys inside the mapping are ignored — the same stance as `lint` and `codemanifest`. A non-mapping `topics` value, or a non-string field, raises `ValueError` at load time (see [Configuration — validation errors](../../configuration/project.md#validation-errors)). + +The general file location, loading rules, and the shared example live in [Project Configuration](../../configuration/project.md). diff --git a/docs/features/topics/hooks.md b/docs/features/topics/hooks.md new file mode 100644 index 00000000..ab068060 --- /dev/null +++ b/docs/features/topics/hooks.md @@ -0,0 +1,5 @@ +# Topics — Hooks + +The topics domain exposes **no hook actions** for tool packages today. + +Topic identity, addressing, and statuses belong to the [History](../history/index.md) domain — the one hook action of that surface (the status-scale registration) is declared there: see [History — Hooks](../history/hooks.md). The platform mechanism behind every action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/topics/index.md b/docs/features/topics/index.md new file mode 100644 index 00000000..78b67097 --- /dev/null +++ b/docs/features/topics/index.md @@ -0,0 +1,25 @@ +# Topics + +Organize work as **topics** — one directory per piece of work under `.goga/history/<year>/<topic>/`, each usually living on its own git branch. + +The topics domain is the work-tracker view of the history tree: it answers *what is being worked on, where it lives, and how to get onto it*. Which tasks it solves: + +- **See the work** — the board (`goga topics board`) is the cross-branch inventory of one year: every topic with its hosting branch, its statuses, and (with `--info`) its todo summary. It reads branch trees without checkout, so the board sees committed work on every branch — and the working copy of the current one. +- **Start work** — creation (`goga topics create`) plants a branch with the topic's first artifact (the committed `todo.md`) at an explicit base, or publishes it to `origin` in one step. By default you stay on your branch — the quarantine commit never touches your working copy. +- **Enter the intent** — the todo is the multi-line statement of the work, entered on the command line (`--todo`) or in the external editor; it feeds the `todo` status and the board's `--info` column. +- **Resume work** — switching (`goga topics switch`) resolves a branch name, a topic slug, or their prefix onto the hosting branch; `goga pipeline <name> -t <identifier>` runs the same resolution before a pipeline launch (see [Pipelines](../pipelines/cli.md#topic-switch)). +- **Finish work** — deletion (`goga topics delete`) removes a topic's local branch, its `origin` twin, and its directory in one confirmed step. + +## Model + +- A topic is identified by its **slug** — the normalized name (lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed: `Feature/Foo_Bar` → `feature-foo-bar`). +- The topic directory is `.goga/history/<YYYY>/<slug>/`; its artifacts (`todo.md`, `prd.md`, …) carry the topic's statuses (see [History](../history/index.md)). +- Every mutation is local except the two `origin` pushes — the `--publish` push and the delete push; no fetch ever happens. +- Domain errors are clean one-line errors (exit 1, no traceback). + +## In this directory + +- [CLI](cli.md) — the full `goga topics` command reference +- [Configuration](configuration.md) — the `topics:` section of `.goga/config.yml` +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.topics` package facade diff --git a/docs/features/upgrade/api.md b/docs/features/upgrade/api.md new file mode 100644 index 00000000..cfea2091 --- /dev/null +++ b/docs/features/upgrade/api.md @@ -0,0 +1,24 @@ +# Upgrade — API + +The facade of the domain package **`goga.commands.upgrade`** — the pip upgrade of goga (and tools) with the agent re-sync. + +The signature below is the CODEMANIFEST contract of the cell. + +```python +upgrade(ctx: click.Context, sudo: bool, user: str | None, tools: bool, + patch: bool, minor: bool) -> int +``` + +Upgrade the goga package in the running interpreter's pip and re-sync every agent recorded in the home `connect.yml`. `sudo` — run pip with `sudo --preserve-env=HOME` (a system-Python install); `user` — re-sync another user's goga installation (`SUDO_USER` resolution happens here); `tools` — additionally upgrade every installed `goga_tool_*` package; `patch` / `minor` — constrain the version line (`--patch`: the latest patch of the installed minor; `--minor`: the latest release of the installed major; neither: the latest release). Returns the exit code. + +## Example + +```python +import click +from goga.commands.upgrade import upgrade + +@click.command() +def cmd(): + raise SystemExit(upgrade(click.get_current_context(), sudo=False, user=None, + tools=True, patch=True, minor=False)) +``` diff --git a/docs/cli/upgrade.md b/docs/features/upgrade/cli.md similarity index 93% rename from docs/cli/upgrade.md rename to docs/features/upgrade/cli.md index e264c09f..e5262057 100644 --- a/docs/cli/upgrade.md +++ b/docs/features/upgrade/cli.md @@ -12,7 +12,7 @@ goga upgrade [--sudo] [--user NAME] [--tools] [--patch | --minor] `goga upgrade` runs `pip install goga -U` on the current Python interpreter, then re-syncs every agent recorded in `~/.goga/connect.yml` using each agent's persisted `force_overwrite` setting. -This is the supported way to move to a new goga release: because `goga connect` installs assets centrally into `~/.goga/` and symlinks them into each agent directory (see [`goga connect`](connect.md)), an upgrade must re-run that install and refresh the symlinks. `goga upgrade` does both in one command, driven by the registry that `goga connect` wrote when you first connected your agents. +This is the supported way to move to a new goga release: because `goga connect` installs assets centrally into `~/.goga/` and symlinks them into each agent directory (see [`goga connect`](../connect/cli.md)), an upgrade must re-run that install and refresh the symlinks. `goga upgrade` does both in one command, driven by the registry that `goga connect` wrote when you first connected your agents. ## Options @@ -54,7 +54,7 @@ By default `goga upgrade` installs the latest released goga (`pip install goga - ## The connect.yml registry -`~/.goga/connect.yml` is written by `goga connect` and read by `goga install`, `goga upgrade`, and [`goga uninstall`](uninstall.md) (via the shared re-sync routine): +`~/.goga/connect.yml` is written by `goga connect` and read by `goga install`, `goga upgrade`, and [`goga uninstall`](../install/uninstall.md) (via the shared re-sync routine): ```yaml agents: diff --git a/docs/features/upgrade/configuration.md b/docs/features/upgrade/configuration.md new file mode 100644 index 00000000..bfaa3497 --- /dev/null +++ b/docs/features/upgrade/configuration.md @@ -0,0 +1,5 @@ +# Upgrade — Configuration + +The upgrade domain reads **no section of `.goga/config.yml`** — the version line is derived from the installed package, and the agents to re-sync come from the home state (`~/.goga/connect.yml`, see [Configuration — Home](../../configuration/home.md)). + +The general configuration model of the product is covered in [Configuration](../../configuration/index.md). diff --git a/docs/features/upgrade/hooks.md b/docs/features/upgrade/hooks.md new file mode 100644 index 00000000..6ef3de3f --- /dev/null +++ b/docs/features/upgrade/hooks.md @@ -0,0 +1,5 @@ +# Upgrade — Hooks + +The upgrade domain exposes **no hook actions** for tool packages today. + +Its reach into the tool ecosystem is direct pip: `--tools` upgrades every installed `goga_tool_*` package and the run re-syncs the connected agents (see [Overview](index.md)). The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/upgrade/index.md b/docs/features/upgrade/index.md new file mode 100644 index 00000000..bc9688c7 --- /dev/null +++ b/docs/features/upgrade/index.md @@ -0,0 +1,17 @@ +# Upgrade + +Upgrade the goga package — and optionally the installed tool packages — then re-sync every connected agent. + +The upgrade domain keeps the whole installation in step. Which tasks it solves: + +- **Stay current** — `goga upgrade` upgrades goga in the running interpreter's pip; `--tools` additionally upgrades every installed `goga_tool_*` package. +- **Stay on a line** — `--patch` upgrades to the latest patch of the installed minor line; `--minor` to the latest release of the installed major line; the default takes the latest release. +- **Keep agents in step** — after a successful pip, every agent recorded in `~/.goga/connect.yml` is re-synced (through the [Connect](../connect/index.md) domain), so the refreshed skills and commands appear in each agent immediately. +- **System installs** — `--sudo` runs the pip with `sudo --preserve-env=HOME`; `--user NAME` re-syncs another user's goga installation. + +## In this directory + +- [CLI](cli.md) — the full `goga upgrade` command reference +- [Configuration](configuration.md) — the domain reads no configuration section +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.commands.upgrade` package facade diff --git a/docs/features/usages/api.md b/docs/features/usages/api.md new file mode 100644 index 00000000..de250a1b --- /dev/null +++ b/docs/features/usages/api.md @@ -0,0 +1,47 @@ +# Usages — API + +The facade of the domain package **`goga.usages`** — a re-export facade embedding `sync` and `status` (with the status result types) so consumers import a single entry point. It owns no behavior — the logic lives in the child cells `goga.usages.sync` and `goga.usages.status`. + +The signatures below are the CODEMANIFEST contract of the cells. + +## Sync + +```python +sync(force: bool = False, group: str | None = None, dep: str | None = None) -> int +clean_usages_dir(usages_root: Path) -> int +clone_repository(git: str, ref: str | None) -> Path +deploy_usages(source_repo: Path, target_dir: Path, root: str | None = None) -> int +``` + +`sync` materializes and refreshes the usage files of every declared dependency (or one `group`/`dep` slice) into `.goga/usages/` — clone each remote at its `ref`, discover `.usages` folders under `root`, copy the files; `force` overwrites local modifications. Returns the exit code. The helpers behind it: the idempotent wipe of the target tree, the clone, and the deployment of one dependency's files. + +## Status + +```python +status(group: str | None = None, dep: str | None = None) -> UsageStatusReport +hash_tree(root: Path) -> dict[str, str] +compute_dep_status(group: str, dep: str, depcfg: DepConfig, target: Path) -> DepStatus +``` + +`status` checks the synchronized files against each dependency's current remote state — clone at the remote tip, hash both trees, compare — and returns the report. The helpers: the tree hashing and one dependency's comparison. + +## The result types + +```python +UsageStatusReport(deps: list[DepStatus]) +DepStatus(group: str, dep: str, state: UsageState, entries: list[EntryStatus], error: str | None = None) +EntryStatus(path: str, kind: EntryKind, change: EntryChange) +``` + +One `DepStatus` per dependency: its `state` (`UsageState`), its per-entry statuses, and an `error` when the remote could not be reached. Each `EntryStatus` carries the file's `path`, its `kind` (local/remote), and the `change` class. + +## Example + +```python +from goga.usages import status, sync + +sync() # materialize every declared dependency +report = status() +for dep in report.deps: + print(dep.group, dep.dep, dep.state) +``` diff --git a/docs/cli/usages.md b/docs/features/usages/cli.md similarity index 100% rename from docs/cli/usages.md rename to docs/features/usages/cli.md diff --git a/docs/features/usages/configuration.md b/docs/features/usages/configuration.md new file mode 100644 index 00000000..babc696e --- /dev/null +++ b/docs/features/usages/configuration.md @@ -0,0 +1,26 @@ +# Usages — Configuration + +The usages domain reads one optional section of `.goga/config.yml` — `usages`, the git dependencies whose cell-level `.usages/` files are synced by [`goga usages sync`](cli.md) and checked by [`goga usages status`](cli.md). + +```yaml +usages: + cooks: # group — a subdirectory of .goga/usages/ + goga: # dependency — a subdirectory of the group + git: https://github.com/qarium/goga.git + ref: 1.3.x # optional — branch, tag, or commit + root: docs # optional — subpath to discover .usages from +``` + +A two-level mapping: `<group>` → `<dep>` → fields. + +| Field | Type | Required | Description | +|---|---|---|---| +| `usages.<group>` | mapping | Yes when `usages` present | Group bucket. The key becomes a top-level subdirectory of `.goga/usages/`. Validated as a path segment (no empty / `.` / `..` / `/` / `\`) | +| `usages.<group>.<dep>` | mapping | Yes when `<group>` present | Dependency entry. The key becomes a subdirectory under the group. Same path-segment validation | +| `usages.<group>.<dep>.git` | `string` | Yes | Git URL of the source repository. Must be non-empty | +| `usages.<group>.<dep>.ref` | `string` | No | Git ref — branch, tag, or commit. `None` (omitted) clones the default branch | +| `usages.<group>.<dep>.root` | `string` | No | Subpath inside the clone to discover `.usages` folders from. Absent (or an empty string) → clone root. Must be relative; no `..` or absolute paths | + +`usages` defaults to `None` (absent), which makes `goga usages sync` a no-op (exit 0); an empty mapping is `{}`. + +The general file location, loading rules, and the shared example live in [Project Configuration](../../configuration/project.md). diff --git a/docs/features/usages/hooks.md b/docs/features/usages/hooks.md new file mode 100644 index 00000000..cbba3306 --- /dev/null +++ b/docs/features/usages/hooks.md @@ -0,0 +1,5 @@ +# Usages — Hooks + +The usages domain exposes **no hook actions** for tool packages today. + +The sync is driven purely by the configuration declarations (see [Configuration](configuration.md)). The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/usages/index.md b/docs/features/usages/index.md new file mode 100644 index 00000000..d610277f --- /dev/null +++ b/docs/features/usages/index.md @@ -0,0 +1,18 @@ +# Usages + +Sync cell-level usages from declared git dependencies — and check them for drift. + +The usages domain keeps imported practices current. Which tasks it solves: + +- **Declare dependencies** — the `usages:` section of `.goga/config.yml` maps `<group>` → `<dep>` → `{git, ref, root}`: the repositories whose cell-level `.usages/*.md` practices your project consumes. +- **Materialize** — `goga usages sync` clones each dependency's remote and copies its usage files into your project's `.goga/usages/<group>/<dep>/` tree — the practices your CODEMANIFEST files reference through `Usages` paths. +- **Detect drift** — `goga usages status` hashes the synchronized files against each dependency's current remote state and reports per-entry status — up to date, behind, or locally modified — without modifying anything. + +Together with the `Imports` mechanism of the DSL (see [Cell — Usages](../../cell/usages.md)), this makes project know-how travel between repositories with the code. + +## In this directory + +- [CLI](cli.md) — the full `goga usages` command reference +- [Configuration](configuration.md) — the `usages:` section of `.goga/config.yml` +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.usages` package facade diff --git a/docs/getting-started.md b/docs/getting-started.md index 499b0c91..36c97a29 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -27,7 +27,7 @@ Move to a new goga release and re-sync every connected agent in one step — no goga upgrade ``` -To stay within your current version line, add `--patch` (latest patch of the installed minor line) or `--minor` (latest release of the installed major line). See [`goga upgrade`](cli/upgrade.md) for details. +To stay within your current version line, add `--patch` (latest patch of the installed minor line) or `--minor` (latest release of the installed major line). See [`goga upgrade`](features/upgrade/cli.md) for details. ## Initialize a project @@ -83,7 +83,7 @@ goga init --upgrade # re-apply at the recorded ref goga init --upgrade --ref v2.0 # migrate to a specific ref ``` -`<tpl>` and `--upgrade` are mutually exclusive; `--ref` requires one of them. See [`goga init`](cli/init.md) for details. +`<tpl>` and `--upgrade` are mutually exclusive; `--ref` requires one of them. See [`goga init`](features/init/cli.md) for details. ## Develop your first feature @@ -127,7 +127,7 @@ goga pipeline review # scoped review of code, contracts, docs, then lint/for goga pipeline sync # sync specifications and tests with the implementation ``` -See [Pipelines](pipelines/index.md) for the full functional model, and [Shipped Pipelines](pipelines/shipped.md) for the per-pipeline walkthrough. +See [Pipelines](features/pipelines/index.md) for the full functional model, and [Shipped Pipelines](features/pipelines/shipped.md) for the per-pipeline walkthrough. ### Manual cycle @@ -137,7 +137,7 @@ If you want explicit control over each step instead of running the whole cycle a /goga:propose <what you want to build> ``` -> The slash-command form `/goga:<command>` works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](cli/connect.md)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-propose` (Codex uses the `$` prefix — `$goga-propose`). +> The slash-command form `/goga:<command>` works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](features/connect/cli.md)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-propose` (Codex uses the `$` prefix — `$goga-propose`). The agent walks you through an interactive dialogue, then produces `.goga/history/<year>/<topic>/task.md`. From there, each subsequent command takes the previous artifact as input and produces the next one. See the [Workflow](workflow/index.md) section for the full algorithm of each step in both workrounds — refinement and development — including shortcut paths for smaller changes. diff --git a/docs/index.md b/docs/index.md index b6478a02..73473dbd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,7 +55,7 @@ Upgrade goga later and re-sync every connected agent — no need to call pip dir goga upgrade ``` -Line-constrained upgrades (`--patch` / `--minor`) and the other options are covered in [`goga upgrade`](cli/upgrade.md). +Line-constrained upgrades (`--patch` / `--minor`) and the other options are covered in [`goga upgrade`](features/upgrade/cli.md). Initialize a project — the interactive wizard sets up `.goga/config.yml`, language conventions, and (optionally) a `Dockerfile`: @@ -100,7 +100,7 @@ goga pipeline review # scoped review of code, contracts, docs, then lint/for goga pipeline sync # sync specifications and tests with the implementation ``` -A pipeline-file answers **what** the pipeline does. An optional [workflow](pipelines/workflows.md) file answers **how the same pipeline should behave in this project** — per-stage agent, extra prompt context, loop expansion, stage skipping — without forking the base file. See [Pipelines](pipelines/index.md) for the full functional model. +A pipeline-file answers **what** the pipeline does. An optional [workflow](features/pipelines/workflows.md) file answers **how the same pipeline should behave in this project** — per-stage agent, extra prompt context, loop expansion, stage skipping — without forking the base file. See [Pipelines](features/pipelines/index.md) for the full functional model. ### Drive the cycle by hand @@ -110,11 +110,11 @@ If you want explicit control over each step instead of running the whole cycle a /goga:propose <what you want to create> ``` -> The slash-command form `/goga:<command>` works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](cli/connect.md)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-propose` (Codex uses the `$` prefix — `$goga-propose`). Each subsequent command takes the previous artifact as input and produces the next one. See [Workflow](workflow/index.md) for the two workrounds — refinement and development — and the entry depths each supports. +> The slash-command form `/goga:<command>` works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](features/connect/cli.md)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-propose` (Codex uses the `$` prefix — `$goga-propose`). Each subsequent command takes the previous artifact as input and produces the next one. See [Workflow](workflow/index.md) for the two workrounds — refinement and development — and the entry depths each supports. ## Next steps -- [Pipelines](pipelines/index.md) — Run the agent-driven cycle automatically with `goga pipeline` +- [Pipelines](features/pipelines/index.md) — Run the agent-driven cycle automatically with `goga pipeline` - [Getting Started](getting-started.md) — Initialize your first goga project - [Workflow](workflow/index.md) — The agent-driven feature development cycle - [Cell](cell/index.md) — Cell structure, usages, and CODEMANIFEST DSL reference diff --git a/docs/workflow/build.md b/docs/workflow/build.md index 987e95d6..2ff34294 100644 --- a/docs/workflow/build.md +++ b/docs/workflow/build.md @@ -100,7 +100,7 @@ goga build .goga/history/<year>/json-export/plan.md # second run reuses .ralphe | Code | Meaning | |---|---| | `0` | Build completed successfully | -| `1` | Build failed (Docker not found, config error, `build` section missing, top-level `image` unset, a missing `build.task_executor.agent`, uncommitted `CODEMANIFEST` files, invalid review configuration, two-pass review combined with worktree, a missing `ralphex` binary or a rejected ralphex launch, a fatal `docker build` under `--update`, a ralphex error, or a refused pre-launch version check — a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version; see [`goga build`](../cli/build.md)) | +| `1` | Build failed (Docker not found, config error, `build` section missing, top-level `image` unset, a missing `build.task_executor.agent`, uncommitted `CODEMANIFEST` files, invalid review configuration, two-pass review combined with worktree, a missing `ralphex` binary or a rejected ralphex launch, a fatal `docker build` under `--update`, a ralphex error, or a refused pre-launch version check — a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version; see [`goga build`](../features/build/cli.md)) | ## What happens next @@ -109,4 +109,4 @@ goga build .goga/history/<year>/json-export/plan.md # second run reuses .ralphe - If bugs or defects are found — fix them with [`change`](change.md). - Once the implementation is stable — run [`accept`](accept.md) for final sign-off. -See the full CLI reference: [`goga build`](../cli/build.md). +See the full CLI reference: [`goga build`](../features/build/cli.md). diff --git a/docs/workflow/index.md b/docs/workflow/index.md index bf82e373..73f7510c 100644 --- a/docs/workflow/index.md +++ b/docs/workflow/index.md @@ -11,7 +11,7 @@ Goga organizes feature development as two global workrounds — **refinement** a The refinement workround ends with a task review: once the task in `.goga/history/<year>/<topic>/task.md` is verified, the product side is settled and development can start. The development workround picks up the verified task and takes it all the way to an acceptance report. -> Command examples in this section use the slash-command form `/goga:<command>`. This form works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](../cli/connect.md)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-<command>` (Codex uses the `$` prefix — for example, `$goga-propose`). +> Command examples in this section use the slash-command form `/goga:<command>`. This form works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](../features/connect/cli.md)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-<command>` (Codex uses the `$` prefix — for example, `$goga-propose`). ### Refinement diff --git a/mkdocs.yml b/mkdocs.yml index 695f5da0..927445a4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,39 +50,108 @@ nav: - Build: workflow/build.md - Change: workflow/change.md - Accept: workflow/accept.md - - Pipelines: - - pipelines/index.md - - Pipeline File: pipelines/pipeline-file.md - - Workflows: pipelines/workflows.md - - Shipped Pipelines: pipelines/shipped.md - - Tools: tools.md + - Features: + - features/index.md + - Topics: + - Overview: features/topics/index.md + - CLI: features/topics/cli.md + - Configuration: features/topics/configuration.md + - Hooks: features/topics/hooks.md + - API: features/topics/api.md + - History: + - Overview: features/history/index.md + - CLI: features/history/cli.md + - Configuration: features/history/configuration.md + - Hooks: features/history/hooks.md + - API: features/history/api.md + - Pipelines: + - Overview: features/pipelines/index.md + - CLI: features/pipelines/cli.md + - Configuration: features/pipelines/configuration.md + - Hooks: features/pipelines/hooks.md + - API: features/pipelines/api.md + - Pipeline File: features/pipelines/pipeline-file.md + - Workflows: features/pipelines/workflows.md + - Shipped Pipelines: features/pipelines/shipped.md + - Build: + - Overview: features/build/index.md + - CLI: features/build/cli.md + - Configuration: features/build/configuration.md + - Hooks: features/build/hooks.md + - API: features/build/api.md + - Tools: + - Overview: features/tools/index.md + - CLI: features/tools/cli.md + - Configuration: features/tools/configuration.md + - Hooks: features/tools/hooks.md + - API: features/tools/api.md + - Connect: + - Overview: features/connect/index.md + - CLI: features/connect/cli.md + - Configuration: features/connect/configuration.md + - Hooks: features/connect/hooks.md + - API: features/connect/api.md + - Upgrade: + - Overview: features/upgrade/index.md + - CLI: features/upgrade/cli.md + - Configuration: features/upgrade/configuration.md + - Hooks: features/upgrade/hooks.md + - API: features/upgrade/api.md + - Install: + - Overview: features/install/index.md + - CLI: features/install/cli.md + - Configuration: features/install/configuration.md + - Hooks: features/install/hooks.md + - API: features/install/api.md + - Uninstall: features/install/uninstall.md + - Init: + - Overview: features/init/index.md + - CLI: features/init/cli.md + - Configuration: features/init/configuration.md + - Hooks: features/init/hooks.md + - API: features/init/api.md + - Usages: + - Overview: features/usages/index.md + - CLI: features/usages/cli.md + - Configuration: features/usages/configuration.md + - Hooks: features/usages/hooks.md + - API: features/usages/api.md + - Schema: + - Overview: features/schema/index.md + - CLI: features/schema/cli.md + - Configuration: features/schema/configuration.md + - Hooks: features/schema/hooks.md + - API: features/schema/api.md + - Contract: + - Overview: features/contract/index.md + - CLI: features/contract/cli.md + - Configuration: features/contract/configuration.md + - Hooks: features/contract/hooks.md + - API: features/contract/api.md + - Hooks: + - Overview: features/hooks/index.md + - CLI: features/hooks/cli.md + - Configuration: features/hooks/configuration.md + - Hooks: features/hooks/hooks.md + - API: features/hooks/api.md + - Lint: + - Overview: features/lint/index.md + - CLI: features/lint/cli.md + - Configuration: features/lint/configuration.md + - Hooks: features/lint/hooks.md + - API: features/lint/api.md + - Errors: features/lint/errors.md - Configuration: - configuration/index.md - Project: configuration/project.md - Home: configuration/home.md - Agents: configuration/agents.md + - CLI: configuration/cli.md - Cell: - cell/index.md - CODEMANIFEST: cell/codemanifest.md - Usages: cell/usages.md - - CLI: - - cli/index.md - - Init: cli/init.md - - Install: cli/install.md - - Uninstall: cli/uninstall.md - - Lint: cli/lint.md - - Build: cli/build.md - - Contract: cli/contract.md - - Config: cli/config.md - - Schema: cli/schema.md - - Connect: cli/connect.md - - Upgrade: cli/upgrade.md - - Usages: cli/usages.md - - Pipeline: cli/pipeline.md - - History: cli/history.md - - Topics: cli/topics.md - - Tool: cli/tool.md - - Hooks: cli/hooks.md + - CLI: cli/index.md - Architecture: - architecture/index.md - AST Nodes: architecture/ast-nodes.md From 6607a32221f0d29d0ae7ad29bc14c230ac94e551 Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 4 Sep 2026 13:32:04 +0000 Subject: [PATCH 228/229] fix(contracts): drop references to previous functionality Contracts-review fixes (approved via file dialog): - config/project: rephrase the review-patience loading constraint into positive form instead of referencing the removed build.review_patience key - build-usage, project-configuration: drop the sentences pointing at the unparsed build.review_patience config key - history/git: drop the annotation sentence referencing the goga/history facade (cross-cell reference in annotations) --- goga/build/.usages/build-usage.md | 3 +-- goga/config/.usages/project-configuration.md | 3 --- goga/config/project/CODEMANIFEST | 5 ++--- goga/history/git/CODEMANIFEST | 1 - 4 files changed, 3 insertions(+), 9 deletions(-) diff --git a/goga/build/.usages/build-usage.md b/goga/build/.usages/build-usage.md index 0c2b47e3..30b276ca 100644 --- a/goga/build/.usages/build-usage.md +++ b/goga/build/.usages/build-usage.md @@ -77,8 +77,7 @@ holds for the review_patience cli_options key / build.review_executor.patience → --review-patience. When neither source sets them, the keys stay absent and the assembled -ralphex command carries no extra flags. The build.review_patience config -key is not parsed — declare build.review_executor.patience instead. +ralphex command carries no extra flags. ## Review-pass environment diff --git a/goga/config/.usages/project-configuration.md b/goga/config/.usages/project-configuration.md index 8ceaa1ff..83c6039c 100644 --- a/goga/config/.usages/project-configuration.md +++ b/goga/config/.usages/project-configuration.md @@ -322,9 +322,6 @@ topics: The default template and the `{slug}` substitution belong to the consuming command (the create command). -The `build.review_patience` key is not parsed — declare review patience as -`build.review_executor.patience`. - ### `tools` accessor — no-validation contract `config.tools` exposes the raw mapping from `.goga/config.yml`. The loader diff --git a/goga/config/project/CODEMANIFEST b/goga/config/project/CODEMANIFEST index 720d33ae..c892f24f 100644 --- a/goga/config/project/CODEMANIFEST +++ b/goga/config/project/CODEMANIFEST @@ -218,9 +218,8 @@ Annotations: | topics.publish_commit template semantics at the loader level — structural typing only; the consumer applies the default template - The final `ProjectConfig` assembly MUST include topics - - Do NOT parse the build.review_patience key — the review patience field - is build.review_executor.patience; a config declaring the unparsed key - is silently ignored + - The review patience field is read only from + build.review_executor.patience - The final `ProjectConfig` assembly MUST include lint "ProjectConfig(lang: str, image: str | None, dockerfile: str | None, build: BuildConfig | None, pipeline: PipelineConfig | None, commands: dict, codemanifest: CodemanifestConfig | None, tools: dict[str, str] | None, usages: dict[str, dict[str, DepConfig]] | None = None, lint: LintConfig | None = None, topics: TopicsConfig | None = None)": diff --git a/goga/history/git/CODEMANIFEST b/goga/history/git/CODEMANIFEST index ac58e2fe..1791eb9a 100644 --- a/goga/history/git/CODEMANIFEST +++ b/goga/history/git/CODEMANIFEST @@ -20,7 +20,6 @@ Annotations: | is environment access, not history-path or topic logic — every decision belongs to the caller. All git access flows through the `git` practice; mock the subprocess call in tests per `convention`. Use relative imports. - Re-exported on the goga/history facade via embedding. --- From 30ae41648ba8445312822c89775b57c1ba74fd0a Mon Sep 17 00:00:00 2001 From: trifonovmixail <trifonov.net@gmail.com> Date: Fri, 4 Sep 2026 15:43:23 +0000 Subject: [PATCH 229/229] fix: apply review fixes across code, contracts, docs, and tests - route hook and status-registration warnings through logging instead of stderr prints (hooks dispatch/registry/tools, history statuses) - sync CODEMANIFEST and .usages wording with the logging change and drop the invalid Returns/Raises sections from TopicsConfig docstring - fix README topics create examples and docs/features pages (option order, CELLS optionality, branch-name return type, agent list) - move cross-entity tests into tests/integration/, rename test_topics_command.py to test_topics.py, extend negative-path coverage, and apply ruff formatting across sources and tests - add the update-docs extend stage to the review workflow --- .goga/workflows/review.yml | 22 +++++ README.md | 6 +- docs/features/build/cli.md | 2 +- docs/features/contract/cli.md | 2 +- docs/features/history/api.md | 2 +- docs/features/init/cli.md | 4 +- docs/features/lint/errors.md | 12 +-- docs/features/pipelines/cli.md | 4 +- docs/features/schema/api.md | 2 +- docs/features/upgrade/cli.md | 2 +- docs/features/usages/api.md | 2 +- goga/commands/history/history.py | 2 + goga/config/project/config.py | 7 -- goga/history/.usages/registering-statuses.md | 2 +- goga/history/status.py | 3 + goga/history/statuses/CODEMANIFEST | 2 +- goga/history/statuses/assembly.py | 10 +- goga/history/statuses/scale.py | 3 + goga/history/tree.py | 3 + goga/hooks/.usages/declaring-actions.md | 2 +- goga/hooks/.usages/registering-hooks.md | 2 +- goga/hooks/dispatch/CODEMANIFEST | 4 +- goga/hooks/dispatch/emit.py | 18 ++-- goga/hooks/registry/CODEMANIFEST | 2 +- goga/hooks/registry/state.py | 10 +- goga/hooks/tools/CODEMANIFEST | 2 +- goga/hooks/tools/registration.py | 12 ++- goga/pipeline/compiler/serialize_flow.py | 1 + goga/topics/deletion.py | 3 + tests/commands/history/test_history.py | 33 ++++--- tests/commands/hooks/test_hooks.py | 2 +- tests/commands/hooks/test_render.py | 2 +- tests/commands/install/test_install.py | 15 +-- tests/commands/install/test_uninstall.py | 10 +- ...{test_topics_command.py => test_topics.py} | 34 +++++-- tests/conftest.py | 6 ++ tests/history/statuses/test_assembly.py | 38 ++++---- tests/history/statuses/test_scale.py | 52 ++++++----- tests/hooks/dispatch/test_emit.py | 92 ++++++++----------- tests/hooks/registry/test_state.py | 32 ++----- tests/hooks/tools/test_registration.py | 15 ++- .../test_compile_flow_memory_integration.py | 0 .../test_history_command.py | 2 +- tests/topics/editor/test_entry.py | 19 +++- tests/topics/test_creation.py | 33 ++++--- tests/topics/test_switching.py | 15 ++- 46 files changed, 306 insertions(+), 242 deletions(-) rename tests/commands/topics/{test_topics_command.py => test_topics.py} (97%) rename tests/{pipeline/compiler => integration}/test_compile_flow_memory_integration.py (100%) rename tests/{commands/history => integration}/test_history_command.py (99%) diff --git a/.goga/workflows/review.yml b/.goga/workflows/review.yml index c74b57c3..4dd6066c 100644 --- a/.goga/workflows/review.yml +++ b/.goga/workflows/review.yml @@ -15,3 +15,25 @@ stages: Requirements: - Create venv outside the project, in /opt/goga + +extend: + update-docs: + communication: true + before: + - code-review + - contracts-review + - documentation-review + after: + - discovery-scope + skills: + - goga-tool + prompt: | + ARGUMENTS="mkdocs" + + Requirements: + - Use `/tmp/changes.txt` for discover changelog + - Commit changes after update docs + - Ask the user if the decision to create a new documentation section or change structure is ambiguous + + Constraints: + - Capture only the current state of the product, do not capture changes in the changelog format diff --git a/README.md b/README.md index dea432c9..3d25e996 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,9 @@ goga topics board # the board: every topic of the year across bran goga topics board --remote # same board over remote-tracking refs goga topics board --info # the board with the todo column (the todo summary of todo.md) goga topics create feat/x --from-current # fresh work off the current HEAD: the branch verbatim + its topic committed, you stay on your branch -goga topics create feat/x -t "Payment retry" # same (--from-current implied); the todo becomes the branch's todo.md commit (status: todo) -goga topics create feat/x -s # same, but switch to the fresh branch; on a terminal the todo entry opens in your $EDITOR -goga topics create feat/x -p -t "Payment retry" # same as the default, plus pushed to origin +goga topics create feat/x --from-current -t "Payment retry" # same; the todo becomes the branch's todo.md commit (status: todo) +goga topics create feat/x --from-current -s # same, but switch to the fresh branch; on a terminal the todo entry opens in your $EDITOR +goga topics create feat/x --from-current -p -t "Payment retry" # same as the default, plus pushed to origin goga topics switch feat-x # onto the branch hosting that work (branch, slug, or prefix) goga topics switch feat-x --todo # same, then edit the topic's todo.md in your $EDITOR goga topics delete feat-x # delete the branch, its origin twin, and the directory diff --git a/docs/features/build/cli.md b/docs/features/build/cli.md index 4025525b..50b61694 100644 --- a/docs/features/build/cli.md +++ b/docs/features/build/cli.md @@ -46,7 +46,7 @@ The build pipeline performs these steps: | `-e`, `--env` | string (repeatable) | -- | Additional environment variable (`KEY=VALUE`, repeatable) | | `--proxy` | string | config | HTTP/HTTPS proxy URL; overrides `build.proxy`. Adds `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` to the container env-file | | `--add-host` | string (repeatable) | -- | Add a `docker run --add-host HOST:IP` entry; merges on top of `build.hosts` (CLI wins on key conflict) | -| `--update`, `-u` | flag | off | Refresh the image before launch (build if a project Dockerfile is declared, else pull). Default skips the refresh | +| `-u`, `--update` | flag | off | Refresh the image before launch (build if a project Dockerfile is declared, else pull). Default skips the refresh | | `-c`, `--clean` | flag | off | Wipe the persistent ralph-loop runtime host directory before launch (default preserves state across runs) | Timeout and iteration options fall back to values in `.goga/config.yml` when not provided on the command line. diff --git a/docs/features/contract/cli.md b/docs/features/contract/cli.md index 7def3dd3..e3570b58 100644 --- a/docs/features/contract/cli.md +++ b/docs/features/contract/cli.md @@ -18,7 +18,7 @@ This command is useful for detecting drift between what is declared in CODEMANIF | Argument | Required | Description | |---|---|---| -| `CELLS` | yes | One or more cell paths to compare (variadic). | +| `CELLS` | no | Zero or more cell paths to compare (variadic). When omitted, no cell is compared and the output is an empty JSON object. | ## Options diff --git a/docs/features/history/api.md b/docs/features/history/api.md index 140776b6..09eb2a66 100644 --- a/docs/features/history/api.md +++ b/docs/features/history/api.md @@ -56,7 +56,7 @@ prune_topics(year: str | None = None, dry_run: bool = False) -> list[str] ## Git embedding ```python -resolve_current_branch_name() -> str +resolve_current_branch_name() -> str | None list_branch_refs() -> list[BranchRef] BranchRef(name: str, ...) ``` diff --git a/docs/features/init/cli.md b/docs/features/init/cli.md index 1be29f11..1046fc78 100644 --- a/docs/features/init/cli.md +++ b/docs/features/init/cli.md @@ -48,7 +48,7 @@ The wizard proceeds through the following steps in order. **The entire survey is 4. **Codemanifest Annotations** -- Add custom annotations (global directives for the AI agent) that will be stored in the configuration. -5. **Build Agent** -- Confirm-gated (defaults to **No**). Decline to skip configuring a build agent (the `agent` key is then omitted from the generated config; `goga build` raises a clean `ClickException` if it later needs one). Accept to select an AI executor: `claude`, `codex`. +5. **Build Agent** -- Confirm-gated (defaults to **No**). Decline to skip configuring a build agent (the `agent` key is then omitted from the generated config; `goga build` raises a clean `ClickException` if it later needs one). Accept to select an AI executor: `claude`, `codex`, `cursor`, `opencode`, or `qwen`. 6. **Custom Dockerfile** -- Optionally create a custom Dockerfile. When accepted, the suggested path is `.goga/Dockerfile` (saved inside the project-scoped `.goga/` directory); press Enter to accept it or type a different path. The Dockerfile decision drives the next step (image semantics differ). @@ -69,7 +69,7 @@ The wizard proceeds through the following steps in order. **The entire survey is 8. **Environment Variables** -- Configure environment variables for the build. Suggested keys are offered per agent (e.g., `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL` for Claude; `CODEX_MODEL` for Codex). You can also add arbitrary custom variables. -9. **Pipeline Agent** -- Confirm-gated (defaults to **No**). Decline to skip configuring a pipeline agent (the `pipeline.agent` key is omitted; a per-stage workflow agent or afm's own default then covers the absent global agent). Accept to select an AI executor: `claude`, `codex`. Does **not** inherit the build agent from step 5 — build and pipeline are collected via independent confirm-gates, so they can diverge or both be left unset. +9. **Pipeline Agent** -- Confirm-gated (defaults to **No**). Decline to skip configuring a pipeline agent (the `pipeline.agent` key is omitted; a per-stage workflow agent or afm's own default then covers the absent global agent). Accept to select an AI executor: `claude`, `codex`, `cursor`, `opencode`, or `qwen`. Does **not** inherit the build agent from step 5 — build and pipeline are collected via independent confirm-gates, so they can diverge or both be left unset. 10. **Pipeline Environment Variables** -- Configure environment variables for the pipeline container. Suggested keys are offered per agent (same shape as step 8). You can also add arbitrary `KEY=VALUE` variables. Omitted entirely when nothing is collected. diff --git a/docs/features/lint/errors.md b/docs/features/lint/errors.md index 9cb5266c..797ffdee 100644 --- a/docs/features/lint/errors.md +++ b/docs/features/lint/errors.md @@ -20,7 +20,7 @@ goga lint cells: N errors: M ``` -Structural failures that are not rule violations surface as parse errors: a missing `CODEMANIFEST` (`DocumentNotFoundError`), a document that is not valid YAML or violates the document shape (`DocumentParseError`) — see [Architecture — Error Handling](../../architecture/ast-errors.md). +Structural failures that are not rule violations surface as parse errors: a document that is not valid YAML or violates the document shape (`DocumentParseError`) — see [Architecture — Error Handling](../../architecture/ast-errors.md). ## Import errors (8) @@ -28,9 +28,9 @@ The `Imports` section of the header. | Rule | Scope | The error means | |---|---|---| -| `ImportsCanNotBeEmpty` | Document | The document has no import block — every document must carry one (an empty `Imports: []` where nothing is imported is still declared) | +| `ImportsCanNotBeEmpty` | Document | The `Imports` block is declared but empty — no Types and no Usages listed (a document without an `Imports` block is not flagged) | | `ImportsHasOnlyValidKeys` | Document | An import item carries a key other than `Types`, `Usages`, `From` | -| `ImportItemIsValid` | Document | An import item is malformed — a non-mapping item, or a missing/invalid `From` | +| `ImportItemIsValid` | Document | An import item lists no Types or no Usages | | `ImportHasNotDuplicate` | Document | The same import entry appears twice in the list | | `ImportHasValidFromPath` | Document | The `From` path is not a valid source path (escapes the project, absolute, malformed) | | `ImportUsageExists` | Document | A usage file referenced in imports does not exist at `{From}/.usages/<name>.md` | @@ -45,7 +45,7 @@ The `Usages` section of the header. |---|---|---| | `AllUsagesIsUsed` | Document | A declared usage is never referenced in any annotation | | `UsageFilepathExists` | Document | A usage declared by file path does not exist on disk (project-level practices must reside in `.goga/usages/`) | -| `UsageUrlIsAccessible` | Document | A usage declared by URL is not reachable (results are cached between runs) | +| `UsageUrlIsAccessible` | Document | A usage declared by URL is not reachable (duplicate URLs are checked once per run) | | `UsageLinksHasNotConflicts` | Document | Two usage links resolve to the same name — an import collides with a local `Usages` key | ## Structure errors (6) @@ -54,11 +54,11 @@ The body — entities, routines, signatures, locations. | Rule | Scope | The error means | |---|---|---| -| `EntitiesAndRoutinesHasNotConflicts` | Document | An entity and a routine collide by name in one document | +| `EntitiesAndRoutinesHasNotConflicts` | Document | An entity or routine has the same name as an imported name — use an alias in Imports | | `EntityHasOnlyValidKeys` | Document | An entity declaration carries a key other than `location`, `annotations`, `methods`, `properties` | | `RoutineHasOnlyValidKeys` | Document | A routine declaration carries a key other than `location`, `annotations` | | `SignatureIsValid` | Document | A type signature does not follow the expected format | -| `LocationIsRequired` | Document | An entity or routine has no `location` — the expected file placement | +| `LocationIsRequired` | Document | An entity or routine has no `location`, or its `location` carries a directory path or lacks a file extension | | `ReturnTypeHasLink` | Document | A return type in a signature has no paired semantic label (`-> value:Type`, not `-> Type`) | ## Mutation errors (3) diff --git a/docs/features/pipelines/cli.md b/docs/features/pipelines/cli.md index b5ede081..e4be5238 100644 --- a/docs/features/pipelines/cli.md +++ b/docs/features/pipelines/cli.md @@ -197,7 +197,7 @@ stages: | `--proxy` | string | config | HTTP/HTTPS proxy URL; overrides `pipeline.proxy`. Adds `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` to the container env-file. Run form only | | `--add-host` | string (repeatable) | -- | Add a `docker run --add-host HOST:IP` entry; merges on top of `pipeline.hosts` (CLI wins on key conflict). Run form only — the info forms receive the configured `pipeline.hosts` only | | `-c`, `--clean` | flag | off | Wipe the persistent afm state directory before launch. Run form only | -| `--update`, `-u` | flag | off | Refresh the image before launch (build if a project Dockerfile is declared, else pull). Effective in the run and flat-list forms; a deliberate no-op in the `--info` forms | +| `-u`, `--update` | flag | off | Refresh the image before launch (build if a project Dockerfile is declared, else pull). Effective in the run and flat-list forms; a deliberate no-op in the `--info` forms | | `-w`, `--workflow` | string | — | Apply an explicit workflow at `<cwd>/.goga/workflows/<name>.yml`. The file must exist on the host (exit 1 if missing). Mutually exclusive with `--no-workflow`. Honored by the run and card forms | | `--no-workflow` | flag | off | Disable workflow application entirely (a run writes `GOGA_WORKFLOW_DISABLED=1` into the container env-file). Mutually exclusive with `--workflow`. Honored by the run and card forms | | `-s`, `--skip` | string (repeatable) | — | Exclude a stage from the compiled pipeline (one name per invocation). The stage is removed and its dependents' `depends_on` are reconnected. Forwarded into the container env-file as `GOGA_SKIP_STAGES=<name>,...`. Not mutually exclusive with `--workflow`/`--no-workflow`. Run form only; the host performs no name validation — unknown names surface in-container as a structural error. The card does not read it (the card answers "what is this pipeline?", not "what would this particular run skip?") | @@ -284,7 +284,7 @@ Host side (all forms): |------|---------| | `0` | The operation completed (container exit 0) | | `1` | A `ClickException`: a form error (bare invocation, `--list` + name, `--workflow` + `--no-workflow`, `--todo` without `--topic` in the run form), the `pipeline` section missing in `.goga/config.yml`, an explicit `--workflow <name>` naming a file that does not exist or escaping the workflows dir, a topic-procedure failure (several candidates without a terminal, a dirty working tree on a switch, an unusable — empty-slug or occupied — name, `--todo` without a terminal, a failed `git switch` or ref listing, or a missing git binary — see [Topic switch](#topic-switch)), or a fatal image build/refresh. Or the pre-launch version check refusing the launch (a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version — a stderr message plus `SystemExit`, see [Pre-launch version check](#pre-launch-version-check)) | -| other| The container's exit code, propagated unchanged (including the run-mode codes below) | +| other | The container's exit code, propagated unchanged (including the run-mode codes below) | Container side, run form: diff --git a/docs/features/schema/api.md b/docs/features/schema/api.md index 31c90a4d..9b372e3f 100644 --- a/docs/features/schema/api.md +++ b/docs/features/schema/api.md @@ -5,7 +5,7 @@ The facade of the domain package **`goga.schema`** — the JSON schema tree gene The signature below is the CODEMANIFEST contract of the cell. ```python -schema(cells: list[str], max_depth: int = None, depends_on: list[str] = []) -> str +schema(cells: list[str], max_depth: int | None, depends_on: list[str]) -> str ``` Walk the project cells and emit the JSON schema tree. `cells` — the positional scope (the named cells only); `max_depth` — the bound on the import expansion depth; `depends_on` — keep only the cells connected to the named ones. The returned string is the JSON document the `goga schema` command prints: every declared entity and routine with its signature, location, annotations, methods, and properties. diff --git a/docs/features/upgrade/cli.md b/docs/features/upgrade/cli.md index e5262057..adfa75bc 100644 --- a/docs/features/upgrade/cli.md +++ b/docs/features/upgrade/cli.md @@ -48,7 +48,7 @@ By default `goga upgrade` installs the latest released goga (`pip install goga - - The line base is read from the current interpreter's `importlib.metadata` — not from the container image tag and not from the working directory. Whatever goga the invoked interpreter has installed defines the line. - Rich version bases are truncated to their release segments: an installed `1.2.1.dev0` (likewise `1.2.0rc1`, `1.2.0.post1`, `1.2.0+local`) still resolves to the 1.2 line — `goga~=1.2.0` under `--patch`, `goga~=1.0` under `--minor`. -- `--patch` requires the installed version to carry a minor segment: an installed major-only version (e.g. `2`) exits 1 with `cannot resolve the version line` — no `.0` minor is invented. `--minor` works from a major-only base (`2` → `goga~=2.0`). +- `--patch` requires the installed version to carry a minor segment: an installed major-only version (e.g. `2`) exits 1 with `cannot determine version line from <version>` — no `.0` minor is invented. `--minor` works from a major-only base (`2` → `goga~=2.0`). - Both flags are validated before pip runs: combining `--patch --minor` exits 1 with a mutual-exclusion error, and an installed version that cannot be read in this interpreter exits 1 — there is no fallback to latest. - With `--tools`, the constraint applies only to the goga identifier; discovered `goga_tool_*` packages are upgraded unconstrained in the same pip invocation. diff --git a/docs/features/usages/api.md b/docs/features/usages/api.md index de250a1b..caef6a54 100644 --- a/docs/features/usages/api.md +++ b/docs/features/usages/api.md @@ -33,7 +33,7 @@ DepStatus(group: str, dep: str, state: UsageState, entries: list[EntryStatus], e EntryStatus(path: str, kind: EntryKind, change: EntryChange) ``` -One `DepStatus` per dependency: its `state` (`UsageState`), its per-entry statuses, and an `error` when the remote could not be reached. Each `EntryStatus` carries the file's `path`, its `kind` (local/remote), and the `change` class. +One `DepStatus` per dependency: its `state` (`UsageState`), its per-entry statuses, and an `error` when the remote could not be reached. Each `EntryStatus` carries the file's `path`, its `kind` (file/dir), and the `change` class. ## Example diff --git a/goga/commands/history/history.py b/goga/commands/history/history.py index 0632a112..ab0d96e9 100644 --- a/goga/commands/history/history.py +++ b/goga/commands/history/history.py @@ -113,6 +113,7 @@ def status(scope: _HistoryScope, topic: str | None = None, statuses: tuple[str, scale = assemble_status_scale() except (ValueError, ImportError) as exc: raise click.ClickException(str(exc)) from exc + for name in statuses: try: scale.resolve_status(name) @@ -228,6 +229,7 @@ def prune(scope: _HistoryScope, dry_run: bool = False) -> None: # FileNotFoundError is matched above — the git-less binary never # lands here; this wraps the rmtree failures of the deletion. raise click.ClickException(f"cannot delete topic directory: {exc}") from exc + for slug in removed: click.echo(slug) click.get_current_context().exit(0) diff --git a/goga/config/project/config.py b/goga/config/project/config.py index 9906d416..d895b56c 100644 --- a/goga/config/project/config.py +++ b/goga/config/project/config.py @@ -133,13 +133,6 @@ class TopicsConfig: from `.goga/config.yml`; None when absent/YAML-null/empty. publish_commit: The commit message template of the publication, verbatim from `.goga/config.yml`; None when absent/YAML-null/empty. - - Returns: - A frozen value-object; construction performs no validation. - - Raises: - Nothing — structural typing is enforced by `load_project_config`, - and semantics belong to the consumer. """ base_ref: str | None diff --git a/goga/history/.usages/registering-statuses.md b/goga/history/.usages/registering-statuses.md index c3ba598d..9e48dac2 100644 --- a/goga/history/.usages/registering-statuses.md +++ b/goga/history/.usages/registering-statuses.md @@ -34,7 +34,7 @@ run, freely mutable, invisible to the domains. precedes or follows; at least one anchor is required, both given define a placement range. - A registration missing an anchor, carrying empty values, an - unresolvable anchor, or an invalid range is skipped with a stderr + unresolvable anchor, or an invalid range is skipped with a log warning; it never aborts the command and never cancels other registrations. - Two tools may reference the same artifact path — both statuses apply diff --git a/goga/history/status.py b/goga/history/status.py index 322bb218..a15fe391 100644 --- a/goga/history/status.py +++ b/goga/history/status.py @@ -93,9 +93,12 @@ def collect_topic_statuses(year: str | None = None, scale: StatusScale | None = resolved_scale = scale or assemble_status_scale() resolved_year = year or current_year() year_dir = _history_root() / resolved_year + if not year_dir.is_dir(): return [] + topics = sorted(path.name for path in year_dir.iterdir() if path.is_dir()) + return [ TopicRecord(topic=topic, statuses=resolve_topic_status(year_dir / topic, resolved_scale)) for topic in topics ] diff --git a/goga/history/statuses/CODEMANIFEST b/goga/history/statuses/CODEMANIFEST index be464532..6ce67bd0 100644 --- a/goga/history/statuses/CODEMANIFEST +++ b/goga/history/statuses/CODEMANIFEST @@ -218,7 +218,7 @@ Annotations: | assembled by the moment the entry is processed — the built-in axis plus the entries of the earlier tools and the earlier entries of the current one; an anchor naming anything else, or an invalid - placement range, skips the registration with a warning to stderr + placement range, skips the registration with a warning in the log 6. Assemble and return the scale Requirements: diff --git a/goga/history/statuses/assembly.py b/goga/history/statuses/assembly.py index 177ed161..d258a68e 100644 --- a/goga/history/statuses/assembly.py +++ b/goga/history/statuses/assembly.py @@ -11,12 +11,14 @@ from __future__ import annotations -import sys +import logging from ...hooks import HookRegistry, emit_hook_event from .registry import StatusRegistry from .scale import Stage, StatusScale +logger = logging.getLogger(__name__) + _BUILTIN_AXIS: list[Stage] = [ Stage(name="empty", filepath=""), Stage(name="todo", filepath="todo.md"), @@ -37,7 +39,7 @@ def assemble_status_scale() -> StatusScale: """Assemble the full status scale — the built-in axis extended by every subscribed tool. Returns: - scale: The assembled scale. + The assembled scale. Algorithm: 1. Build the built-in axis of nine entries @@ -51,7 +53,7 @@ def assemble_status_scale() -> StatusScale: assembled by the moment the entry is processed — the built-in axis plus the entries of the earlier tools and the earlier entries of the current one; an unresolvable anchor or an invalid - range skips the registration with a warning to stderr + range skips the registration with a warning in the log 6. Assemble and return the scale Requirements: @@ -91,7 +93,7 @@ def context_for(tool: str) -> StatusRegistry: try: index = _placement_index(stages, entry) except ValueError as exc: - print(f"Warning: skipping status registration {entry.name}: {exc}", file=sys.stderr) + logger.warning("skipping status registration %s: %s", entry.name, exc) continue stages.insert(index, entry) diff --git a/goga/history/statuses/scale.py b/goga/history/statuses/scale.py index 400b8f05..ae80db08 100644 --- a/goga/history/statuses/scale.py +++ b/goga/history/statuses/scale.py @@ -94,11 +94,14 @@ def maximal_present(self, paths: list[str]) -> list[str]: """ present = set(paths) marked = [stage for stage in self.stages if stage.filepath and stage.filepath in present] + if not marked: return ["empty"] + above = self._strictly_above() marked_names = {stage.name for stage in marked} maximal = [stage for stage in marked if not above[stage.name] & marked_names] + return [stage.name for stage in maximal] def resolve_status(self, name: str) -> Stage: diff --git a/goga/history/tree.py b/goga/history/tree.py index 94ec4e6a..e12c6376 100644 --- a/goga/history/tree.py +++ b/goga/history/tree.py @@ -49,8 +49,10 @@ def collect_history_tree(year: str | None = None) -> list[HistoryYear]: and no status is computed: the tree carries topic names only. """ root = _history_root() + if not root.is_dir(): return [] + selected = year or None years = sorted( path.name @@ -61,6 +63,7 @@ def collect_history_tree(year: str | None = None) -> list[HistoryYear]: and path.name.isdigit() and (selected is None or path.name == selected) ) + return [ HistoryYear( year=year_name, diff --git a/goga/hooks/.usages/declaring-actions.md b/goga/hooks/.usages/declaring-actions.md index c891fb4e..50cd681e 100644 --- a/goga/hooks/.usages/declaring-actions.md +++ b/goga/hooks/.usages/declaring-actions.md @@ -8,7 +8,7 @@ packages. For domain maintainers inside goga. Add one record to the action catalog — the domain, the action name, and the error class: -- `soft` — a failing hook is skipped with a stderr warning; the command +- `soft` — a failing hook is skipped with a log warning; the command continues. - `hard` — the first failing hook stops the command with a clean error. diff --git a/goga/hooks/.usages/registering-hooks.md b/goga/hooks/.usages/registering-hooks.md index 1ed4c025..b4e1d966 100644 --- a/goga/hooks/.usages/registering-hooks.md +++ b/goga/hooks/.usages/registering-hooks.md @@ -53,7 +53,7 @@ never cached — package edits apply from the next run, without reinstall. ## Failure behavior - A wrong address, an empty name, or a repeated name on the same address — - a stderr warning naming your tool, the action, and the reason; the + a log warning naming your tool, the action, and the reason; the registration is skipped, the rest apply. - A crashing callback — a warning; the registrations made before the crash survive. diff --git a/goga/hooks/dispatch/CODEMANIFEST b/goga/hooks/dispatch/CODEMANIFEST index fda721da..d0ac5402 100644 --- a/goga/hooks/dispatch/CODEMANIFEST +++ b/goga/hooks/dispatch/CODEMANIFEST @@ -112,8 +112,8 @@ Annotations: | by its remaining subscriptions; wrap it via `wrap_context`, project the call arguments via `build_hook_arguments` with the tool's own context from the registry, and call the hook - 5. Treat a failure per the action's error class: soft — a warning on - stderr naming the tool, the action, and the reason, the hook is + 5. Treat a failure per the action's error class: soft — a warning in + the log naming the tool, the action, and the reason, the hook is skipped, the sequence continues; hard — a clean error naming the tool and the reason, the sequence stops at the first failure 6. An address without subscriptions emits nothing diff --git a/goga/hooks/dispatch/emit.py b/goga/hooks/dispatch/emit.py index c01531af..4a9aee23 100644 --- a/goga/hooks/dispatch/emit.py +++ b/goga/hooks/dispatch/emit.py @@ -13,13 +13,15 @@ from __future__ import annotations -import sys +import logging from collections.abc import Callable from ..catalog import declared_actions from ..registry import HookRegistry from .delivery import build_hook_arguments, wrap_context +logger = logging.getLogger(__name__) + def emit_hook_event( registry: HookRegistry, @@ -48,8 +50,8 @@ def emit_hook_event( 4. Wrap the view via ``wrap_context``, project the call arguments via ``build_hook_arguments`` with the tool's own context from the registry, and call the hook - 5. Treat a failure per the action's error class: soft — a warning on - stderr naming the tool, the action, and the reason, the hook is + 5. Treat a failure per the action's error class: soft — a warning in + the log naming the tool, the action, and the reason, the hook is skipped, the sequence continues; hard — a clean error naming the tool and the reason, the sequence stops at the first failure @@ -96,7 +98,11 @@ def emit_hook_event( f"hook {subscription.name} of tool {subscription.tool} failed on {domain}.{action}: {exc}" ) from exc - print( - f"Warning: hook {subscription.name} of tool {subscription.tool} failed on {domain}.{action}: {exc}", - file=sys.stderr, + logger.warning( + "hook %s of tool %s failed on %s.%s: %s", + subscription.name, + subscription.tool, + domain, + action, + exc, ) diff --git a/goga/hooks/registry/CODEMANIFEST b/goga/hooks/registry/CODEMANIFEST index 97471238..a8aa9499 100644 --- a/goga/hooks/registry/CODEMANIFEST +++ b/goga/hooks/registry/CODEMANIFEST @@ -63,7 +63,7 @@ Annotations: | callback via `call_register_hooks` 4. A package without the callback is skipped quietly 5. An exception raised by a callback ends that callback's - registration: a warning on stderr naming the tool and the reason, + registration: a warning in the log naming the tool and the reason, the registrations made before the failure survive, the next package is processed 6. A broken package import is a clean error naming the package — the diff --git a/goga/hooks/registry/state.py b/goga/hooks/registry/state.py index bfa253ac..c3087cc3 100644 --- a/goga/hooks/registry/state.py +++ b/goga/hooks/registry/state.py @@ -12,7 +12,7 @@ from __future__ import annotations -import sys +import logging from dataclasses import dataclass, field from ..tools import ( @@ -23,6 +23,8 @@ enumerate_tool_packages, ) +logger = logging.getLogger(__name__) + @dataclass(kw_only=True) class HookRegistry: @@ -62,7 +64,7 @@ def build_once(self) -> None: to the package identity and run its callback via ``call_register_hooks`` 4. An exception of a callback ends that callback's registration - only: a warning on stderr naming the tool and the reason, the + only: a warning in the log naming the tool and the reason, the registrations made before the failure survive, the next package is processed 5. A broken package import — the platform-wrapped ``ImportError`` @@ -97,10 +99,10 @@ def build_once(self) -> None: if str(exc).startswith(f"package {package.facade} failed to import:"): raise - print(f"Warning: skipping hook registration of tool {package.tool}: {exc}", file=sys.stderr) + logger.warning("skipping hook registration of tool %s: %s", package.tool, exc) except Exception as exc: - print(f"Warning: skipping hook registration of tool {package.tool}: {exc}", file=sys.stderr) + logger.warning("skipping hook registration of tool %s: %s", package.tool, exc) self._subscriptions.extend(registrar.subscriptions) self._rejections.extend(registrar.rejections) diff --git a/goga/hooks/tools/CODEMANIFEST b/goga/hooks/tools/CODEMANIFEST index bcc910a8..acdf34db 100644 --- a/goga/hooks/tools/CODEMANIFEST +++ b/goga/hooks/tools/CODEMANIFEST @@ -115,7 +115,7 @@ Annotations: | a violation is rejected 3. Reject a repeated registration of the same `name` on the same address by this tool - 4. Every rejection is recorded and announced as a warning on stderr + 4. Every rejection is recorded and announced as a warning in the log naming the tool, the action, and the reason 5. An accepted envelope appends one subscription diff --git a/goga/hooks/tools/registration.py b/goga/hooks/tools/registration.py index 1e8f6094..ffa09358 100644 --- a/goga/hooks/tools/registration.py +++ b/goga/hooks/tools/registration.py @@ -5,19 +5,21 @@ value records ``Subscription`` and ``RejectedRegistration``. This module is the only way a subscription enters the platform — an address is resolved against the catalog, the envelope is validated, and a refusal is recorded as -data with a stderr warning, never raised. The registrar never calls a hook +data with a log warning, never raised. The registrar never calls a hook and never resolves a tool identity: the identity is assigned by the caller that owns the package. """ from __future__ import annotations -import sys +import logging from collections.abc import Callable from dataclasses import dataclass, field from ..catalog import declared_actions +logger = logging.getLogger(__name__) + @dataclass(kw_only=True) class HookRegistrar: @@ -63,8 +65,8 @@ def subscribe(self, domain: str, action: str, name: str, hook: Callable[..., obj ``hook``; a violation is rejected 3. Reject a repeated registration of the same ``name`` on the same address by this tool - 4. Every rejection is recorded and announced as a warning on - stderr naming the tool, the action, and the reason + 4. Every rejection is recorded and announced as a warning in + the log naming the tool, the action, and the reason 5. An accepted envelope appends one subscription Requirements: @@ -92,7 +94,7 @@ def reject(reason: str) -> None: ) ) - print(f"Warning: rejected hook of tool {self.tool} on {domain}.{action}: {reason}", file=sys.stderr) + logger.warning("rejected hook of tool %s on %s.%s: %s", self.tool, domain, action, reason) known = any(record.domain == domain and record.name == action for record in declared_actions()) diff --git a/goga/pipeline/compiler/serialize_flow.py b/goga/pipeline/compiler/serialize_flow.py index b83222c4..b31ddb5d 100644 --- a/goga/pipeline/compiler/serialize_flow.py +++ b/goga/pipeline/compiler/serialize_flow.py @@ -207,6 +207,7 @@ def serialize_flow(doc: FlowDocument) -> str: ) if value is not None } + top["stages"] = [_build_stage_repr(stage) for stage in doc.stages] text = yaml.dump( diff --git a/goga/topics/deletion.py b/goga/topics/deletion.py index 3264f115..0e46107a 100644 --- a/goga/topics/deletion.py +++ b/goga/topics/deletion.py @@ -392,6 +392,7 @@ def _assemble_target(topic: str, refs: list[BranchRef], hosted: dict[str, set[st f"topic {topic!r} is hosted by {names} as merged work — " "remove it from the hosting branch's tree instead of deleting" ) + merged = [ref for ref in hosts if _normalized_name(ref) != topic] # Two local refs normalizing into one slug must never pick one of @@ -403,6 +404,7 @@ def _assemble_target(topic: str, refs: list[BranchRef], hosted: dict[str, set[st raise click.ClickException( f"several branches host topic {topic!r}: {names} — remove all but one of them before deleting" ) + branch = local_names[0] if local_names else None # The twin is the *origin* twin — the one remote the deletion push of # the git cell addresses. A tracking ref of another remote stays an @@ -415,6 +417,7 @@ def _assemble_target(topic: str, refs: list[BranchRef], hosted: dict[str, set[st None, ) has_dir = topic in disk and not merged + return DeleteTarget(topic=topic, branch=branch, remote=remote, has_dir=has_dir) diff --git a/tests/commands/history/test_history.py b/tests/commands/history/test_history.py index 31632e21..120f9f28 100644 --- a/tests/commands/history/test_history.py +++ b/tests/commands/history/test_history.py @@ -11,7 +11,7 @@ ``click.ClickException`` (stderr, exit 1, no traceback), while the removed year forms (a positional YEAR, a year option after the subcommand) are click's own usage errors (exit 2). The positive cross-entity scenarios -live in ``test_history_command.py``. +live in ``tests/integration/test_history_command.py``. """ from __future__ import annotations @@ -224,6 +224,21 @@ def test_history_status_empty_topic_filter_is_error(self) -> None: assert "empty topic slug" in result.stderr assert result.stdout == "" + @pytest.mark.parametrize( + ("argv", "stderr_fragment"), + [ + (["-y", "2026", "status", "-t", "Релиз"], "empty topic slug"), + (["path", "Релиз/Один", "-f", "plan.md"], "empty topic slug"), + (["ensure", "Релиз/Один"], "empty topic slug"), + ], + ) + def test_history_empty_slug_inputs_are_clean_errors(self, argv: list[str], stderr_fragment: str) -> None: + """Every subcommand converts the domain empty-slug error to exit 1.""" + result = CliRunner().invoke(history, argv) + assert result.exit_code == 1 + assert stderr_fragment in result.stderr + assert "Traceback" not in result.stderr + def test_history_path_no_branch_fails_cleanly(self) -> None: """path without a positional and without a determinable branch fails clean.""" runner = CliRunner() @@ -282,19 +297,3 @@ def test_history_status_year_option_after_subcommand_is_usage_error(self) -> Non assert result.exit_code == 2 assert "No such option" in result.stderr assert "Traceback" not in result.stderr - - -@pytest.mark.parametrize( - ("argv", "stderr_fragment"), - [ - (["-y", "2026", "status", "-t", "Релиз"], "empty topic slug"), - (["path", "Релиз/Один", "-f", "plan.md"], "empty topic slug"), - (["ensure", "Релиз/Один"], "empty topic slug"), - ], -) -def test_history_empty_slug_inputs_are_clean_errors(argv: list[str], stderr_fragment: str) -> None: - """Every subcommand converts the domain empty-slug error to exit 1.""" - result = CliRunner().invoke(history, argv) - assert result.exit_code == 1 - assert stderr_fragment in result.stderr - assert "Traceback" not in result.stderr diff --git a/tests/commands/hooks/test_hooks.py b/tests/commands/hooks/test_hooks.py index fc42131f..4201dafb 100644 --- a/tests/commands/hooks/test_hooks.py +++ b/tests/commands/hooks/test_hooks.py @@ -26,7 +26,7 @@ # The goga.commands facade re-exports the click command under the same name as # the cell package (``from .hooks import hooks``), so attribute access through # goga.commands gives the command for both the package and its module. Resolve -# the real objects via sys.modules (precedent: test_history_command.py). +# the real objects via sys.modules (precedent: tests/integration/test_history_command.py). facade = sys.modules["goga.commands.hooks"] _hooks_module = sys.modules["goga.commands.hooks.hooks"] diff --git a/tests/commands/hooks/test_render.py b/tests/commands/hooks/test_render.py index ce78ab60..7bbb7739 100644 --- a/tests/commands/hooks/test_render.py +++ b/tests/commands/hooks/test_render.py @@ -56,7 +56,7 @@ def test_render_hooks_tree_is_exported_by_the_cell_facade(self) -> None: # The goga.commands facade re-exports the click command as # goga.commands.hooks, shadowing the cell package on attribute access — # resolve the real package via sys.modules (precedent: - # test_history_command.py). + # tests/integration/test_history_command.py). facade = sys.modules["goga.commands.hooks"] assert facade.render_hooks_tree is render_hooks_tree diff --git a/tests/commands/install/test_install.py b/tests/commands/install/test_install.py index 85d11f3a..a3bab38c 100644 --- a/tests/commands/install/test_install.py +++ b/tests/commands/install/test_install.py @@ -54,12 +54,15 @@ def test_install_importable_from_facade(self) -> None: assert install is not None def test_install_facade_all(self) -> None: - # Access the package module directly to assert its own ``__all__`` - # (``import ... as`` would resolve to the Click command re-exported into - # ``goga.commands``, shadowing the submodule). The facade carries the - # five declared names — both lifecycle commands of this cell plus the - # three ``hook.py`` routines — pinned as the exact surface. - # ``resolve_version`` belongs to the ``goga/version`` domain cell. + """Assert the exact ``__all__`` surface of the package module itself. + + The package module is accessed directly (``import ... as`` would + resolve to the Click command re-exported into ``goga.commands``, + shadowing the submodule). The facade carries the five declared + names — both lifecycle commands of this cell plus the three + ``hook.py`` routines — pinned as the exact surface. + ``resolve_version`` belongs to the ``goga/version`` domain cell. + """ facade = importlib.import_module("goga.commands.install") assert facade.__all__ == _INSTALL_FACADE_ALL diff --git a/tests/commands/install/test_uninstall.py b/tests/commands/install/test_uninstall.py index e5da27b8..5d069571 100644 --- a/tests/commands/install/test_uninstall.py +++ b/tests/commands/install/test_uninstall.py @@ -27,10 +27,12 @@ def test_uninstall_importable_from_facade(self) -> None: assert uninstall is not None def test_uninstall_facade_all(self) -> None: - # The install cell facade carries the five declared names — both - # lifecycle commands plus the three ``hook.py`` routines — pinned as - # the exact surface. Uninstall runs no hooks; only the ``__all__`` - # surface it sits on grew in release 1.3.0. + """The install cell facade carries the five declared names. + + Both lifecycle commands plus the three ``hook.py`` routines — + pinned as the exact surface. Uninstall runs no hooks; only the + ``__all__`` surface it sits on grew in release 1.3.0. + """ facade = importlib.import_module("goga.commands.install") assert facade.__all__ == [ "call_install_hook", diff --git a/tests/commands/topics/test_topics_command.py b/tests/commands/topics/test_topics.py similarity index 97% rename from tests/commands/topics/test_topics_command.py rename to tests/commands/topics/test_topics.py index d77370c4..38b88123 100644 --- a/tests/commands/topics/test_topics_command.py +++ b/tests/commands/topics/test_topics.py @@ -461,8 +461,10 @@ def _write_config(tmp_path: Path, body: str) -> None: class TestTopicsCreateAndSwitch: - def test_create_echoes_the_domain_result_line(self) -> None: + def test_create_echoes_the_domain_result_line(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """create echoes the single result line and exits 0.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object( _topics_module, "create_topic", @@ -483,8 +485,12 @@ def test_topics_create_todo_option_reaches_domain(self, tmp_path: Path, monkeypa mock_create.assert_called_once_with("Feature/Foo_Bar", "HEAD", "Payment retry", False, None, None, False) assert result.output == "line\n" - def test_topics_create_todo_long_form_binds_the_same_value(self) -> None: + def test_topics_create_todo_long_form_binds_the_same_value( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """--todo behaves exactly like -t.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: result = CliRunner().invoke(topics, ["create", "feat-a", "--base-ref", "origin/main", "--todo", "T"]) assert result.exit_code == 0 @@ -495,8 +501,12 @@ def test_topics_create_todo_long_form_binds_the_same_value(self) -> None: "flag_form", [["--todo", "Payment retry"], ["--todo=Payment retry"], ["-t", "Payment retry"], ["-tPayment retry"]], ) - def test_create_flag_with_value_passes_todo(self, flag_form: list[str]) -> None: + def test_create_flag_with_value_passes_todo( + self, flag_form: list[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """Every flag form carrying a value hands the domain the todo verbatim.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: result = CliRunner().invoke(topics, ["create", "feat-a", "--base-ref", "origin/main", *flag_form]) assert result.exit_code == 0 @@ -573,8 +583,12 @@ def test_missing_positional_is_usage_error(self, argv: list[str], argument: str) (["switch", "x"], "switch_topic"), ], ) - def test_domain_error_surfaces_clean(self, argv: list[str], routine: str) -> None: + def test_domain_error_surfaces_clean( + self, argv: list[str], routine: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """A domain ClickException propagates as stderr + exit 1, no traceback.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object(_topics_module, routine, side_effect=click.ClickException("working tree is dirty")): result = CliRunner().invoke(topics, argv) assert result.exit_code == 1 @@ -658,8 +672,10 @@ def test_create_from_current_passes_head(self, tmp_path: Path, monkeypatch: pyte assert result.exit_code == 0 assert mock_create.call_args.args[1] == "HEAD" - def test_create_commit_without_publish_error(self) -> None: + def test_create_commit_without_publish_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """--commit without --publish is a clean error; --base-ref alone is not.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object(_topics_module, "create_topic") as mock_create: result = CliRunner().invoke(topics, ["create", "--base-ref", "origin/main", "--commit", "x", "name"]) assert result.exit_code == 1 @@ -685,8 +701,10 @@ def test_create_switch_with_publish_is_clean_error(self) -> None: assert "never switches" in result.stderr mock_create.assert_not_called() - def test_create_switch_flag_forwarded(self) -> None: + def test_create_switch_flag_forwarded(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """--switch (either form) reaches the domain as the switch positional True.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: short_form = CliRunner().invoke(topics, ["create", "feat-a", "--base-ref", "origin/main", "-s", "-t", "T"]) long_form = CliRunner().invoke( @@ -779,8 +797,10 @@ def test_create_publish_flag_template_with_config_base( # The base flag is absent, so the config is read for it. mock_load.assert_called_once_with() - def test_create_publish_delegation(self) -> None: + def test_create_publish_delegation(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The publish path delegates through create_topic with publish=True.""" + monkeypatch.chdir(tmp_path) + with mock.patch.object( _topics_module, "create_topic", diff --git a/tests/conftest.py b/tests/conftest.py index f4a8bc3d..37d25c53 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,6 +58,12 @@ def is_kw_only_dataclass(cls: type) -> bool: ``_DataclassParams.kw_only`` attribute exists only from Python 3.12, so ``cls.__dataclass_params__.kw_only`` is unusable on 3.10/3.11, while ``dataclasses.fields()`` exposes the same fact on every version. + + Args: + cls: The dataclass to inspect. + + Returns: + True when every field of ``cls`` is keyword-only. """ return all(field.kw_only for field in dataclasses.fields(cls)) diff --git a/tests/history/statuses/test_assembly.py b/tests/history/statuses/test_assembly.py index 812137b8..eef0db20 100644 --- a/tests/history/statuses/test_assembly.py +++ b/tests/history/statuses/test_assembly.py @@ -15,7 +15,7 @@ registration contract of a crashed hook is guarded at this level too; the platform-internal failure handling is covered by ``tests/hooks/``, and the fatal import case is asserted here by letting the fake re-raise it. Warnings -are checked with ``capsys``. +are checked with ``caplog``. """ from __future__ import annotations @@ -329,7 +329,7 @@ def test_assemble_both_anchors_range(self, monkeypatch: pytest.MonkeyPatch) -> N assert names.index("discovered") < names.index("a.x") < names.index("backlog") def test_assembly_anchors_around_todo_axis( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """Anchors around ``empty``/``todo``/``defined`` stay resolvable on the nine-entry axis.""" _fake_emission( @@ -350,10 +350,10 @@ def test_assembly_anchors_around_todo_axis( names = _names(scale) assert names.index("empty") < names.index("x.ranged") < names.index("defined") assert names.index("todo") < names.index("x.aftertodo") < names.index("defined") - assert capsys.readouterr().err == "" + assert caplog.text == "" def test_assemble_invalid_anchor_range_skips_with_warning( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """An inverted range is invalid — the entry is skipped with a warning naming the entry.""" _fake_emission( @@ -364,12 +364,11 @@ def test_assemble_invalid_anchor_range_skips_with_warning( scale = assemble_status_scale() assert "a.x" not in _names(scale) - stderr = capsys.readouterr().err - assert "Warning: skipping status registration a.x" in stderr - assert "anchor range" in stderr + assert "skipping status registration a.x" in caplog.text + assert "anchor range" in caplog.text def test_assemble_unresolvable_anchor_warns_and_skips_entry( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """An anchor naming no entry of the scale skips only its own entry — the rest survives.""" _fake_emission( @@ -390,10 +389,10 @@ def test_assemble_unresolvable_anchor_warns_and_skips_entry( names = _names(scale) assert "a.good" in names assert "a.bad" not in names - assert "Warning: skipping status registration a.bad" in capsys.readouterr().err + assert "skipping status registration a.bad" in caplog.text def test_assemble_unresolvable_before_anchor_skips( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """A ``before`` anchor naming no entry of the scale skips the registration.""" _fake_emission( @@ -404,9 +403,8 @@ def test_assemble_unresolvable_before_anchor_skips( scale = assemble_status_scale() assert "a.x" not in _names(scale) - stderr = capsys.readouterr().err - assert "Warning: skipping status registration a.x" in stderr - assert "unknown before anchor" in stderr + assert "skipping status registration a.x" in caplog.text + assert "unknown before anchor" in caplog.text assert _names(scale) == _BUILTIN_NAMES def test_assemble_same_anchor_block_follows_delivery_order(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -482,13 +480,13 @@ def test_assemble_anchor_to_earlier_tool_entry(self, monkeypatch: pytest.MonkeyP class TestAssembleFailures: def test_assemble_crashed_hook_warns_and_keeps_earlier_registrations( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """A hook that crashes after its first entry keeps that entry. The registration made before the crash lives in the tool's delivered registry, so it still assembles into the scale, and the next tool - still contributes. The soft action turns the crash into a stderr + still contributes. The soft action turns the crash into a log warning naming the hook, the tool, and the action. """ @@ -512,11 +510,10 @@ def crashing(context: Any) -> None: names = _names(scale) assert "a.first" in names assert "b.y" in names - stderr = capsys.readouterr().err - assert "Warning: hook crashed of tool a failed on statuses.register_statuses: boom" in stderr + assert "hook crashed of tool a failed on statuses.register_statuses: boom" in caplog.text def test_assemble_rejected_registration_warns_and_continues( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """A structural violation inside one hook skips that hook's registration only. @@ -546,9 +543,8 @@ def test_assemble_rejected_registration_warns_and_continues( assert "a.good" in names assert "a.bad" not in names assert "b.y" in names - stderr = capsys.readouterr().err - assert "Warning: hook mixed of tool a failed on statuses.register_statuses" in stderr - assert "at least one anchor is required" in stderr + assert "hook mixed of tool a failed on statuses.register_statuses" in caplog.text + assert "at least one anchor is required" in caplog.text def test_assemble_broken_import_is_fatal_through_the_emission(self, monkeypatch: pytest.MonkeyPatch) -> None: """A broken package import is the only fatal case — it propagates through the emission.""" diff --git a/tests/history/statuses/test_scale.py b/tests/history/statuses/test_scale.py index 97f3eabc..df070c1b 100644 --- a/tests/history/statuses/test_scale.py +++ b/tests/history/statuses/test_scale.py @@ -103,28 +103,36 @@ def test_resolve_status_returns_the_scale_entry(self) -> None: class TestMaximalPresent: - def test_maximal_present_returns_deepest_artifact_status(self, builtin_scale: StatusScale) -> None: - """The deepest present artifact of the axis wins; files outside the scale mark nothing.""" - paths = ["prd.md", "adr.md", "task.md", "notes.txt"] - - assert builtin_scale.maximal_present(paths) == ["backlog"] - - def test_maximal_present_done_outranks_flat_artifacts(self, builtin_scale: StatusScale) -> None: - """The nested ``completed/plan.md`` outranks the flat ``plan.md``.""" - assert builtin_scale.maximal_present(["plan.md", "completed/plan.md"]) == ["done"] - - def test_maximal_present_empty_when_no_artifacts(self, builtin_scale: StatusScale) -> None: - """No present artifact yields the single built-in name ``empty``.""" - assert builtin_scale.maximal_present([]) == ["empty"] - assert builtin_scale.maximal_present(["notes.txt"]) == ["empty"] - - def test_maximal_present_todo_mark_only(self, builtin_scale: StatusScale) -> None: - """The todo artifact alone marks the built-in ``todo`` entry.""" - assert builtin_scale.maximal_present(["todo.md"]) == ["todo"] - - def test_maximal_present_todo_below_prd(self, builtin_scale: StatusScale) -> None: - """``todo.md`` below ``prd.md`` — the maximal entry wins, ``todo`` is not duplicated.""" - assert builtin_scale.maximal_present(["todo.md", "prd.md"]) == ["defined"] + @pytest.mark.parametrize( + ("paths", "expected"), + [ + (["todo.md"], ["todo"]), + (["prd.md"], ["defined"]), + (["adr.md"], ["discovered"]), + (["task.md"], ["backlog"]), + (["arch.md"], ["designed"]), + (["design.md"], ["specified"]), + (["plan.md"], ["planned"]), + (["completed/plan.md"], ["done"]), + ([], ["empty"]), + (["notes.txt"], ["empty"]), + (["todo.md", "prd.md"], ["defined"]), + (["plan.md", "completed/plan.md"], ["done"]), + (["prd.md", "adr.md", "task.md", "notes.txt"], ["backlog"]), + ], + ) + def test_maximal_present_progression( + self, builtin_scale: StatusScale, paths: list[str], expected: list[str] + ) -> None: + """The maximal present status of the axis for the artifacts on disk. + + The table walks the built-in axis artifact by artifact, the empty + boundaries — no artifact at all and an off-scale artifact only — + and the combinations where the deeper artifact wins: the nested + ``completed/plan.md`` over the flat ``plan.md``, and the deepest + present artifact over the shallower ones. + """ + assert builtin_scale.maximal_present(paths) == expected def test_maximal_present_empty_and_todo_interplay(self, builtin_scale: StatusScale) -> None: """``empty`` against ``todo``: no artifact and an off-scale artifact stay ``empty``.""" diff --git a/tests/hooks/dispatch/test_emit.py b/tests/hooks/dispatch/test_emit.py index 3ac5cc48..06415b18 100644 --- a/tests/hooks/dispatch/test_emit.py +++ b/tests/hooks/dispatch/test_emit.py @@ -9,9 +9,9 @@ ``goga_tool_*`` modules — so the registry, the registrars, and the delivery run for real behind the emission. The hard failure treatment needs a hard-class catalog record; ``tests/hooks/dispatch/conftest.py`` pins the -catalog of the emission for that. Only the hard-failure registry is a plain -fake: a hard address cannot be registered through the real envelope, because -the real catalog does not declare it. +catalog of the emission for that, and the registration-side catalog is +pinned the same way in the hard-failure test below, so the hard address +registers through the real envelope. """ from __future__ import annotations @@ -23,10 +23,11 @@ from unittest import mock import pytest +from goga.hooks.catalog import Action from goga.hooks.dispatch import emit_hook_event from goga.hooks.dispatch.delivery import wrap_context from goga.hooks.registry import HookRegistry, ToolContext -from goga.hooks.tools import Subscription +from goga.hooks.tools import registration _CELL_ALL = ["build_hook_arguments", "emit_hook_event", "wrap_context"] @@ -40,41 +41,20 @@ def _plain_view(tool: str) -> object: return object() -def _subscribe(name: str, hook: Callable[..., object]) -> Callable[[object], None]: - """Build a facade callback subscribing ``hook`` under ``name`` on statuses.""" +def _subscribe( + name: str, + hook: Callable[..., object], + domain: str = "statuses", + action: str = "register_statuses", +) -> Callable[[object], None]: + """Build a facade callback subscribing ``hook`` under ``name`` on an address.""" def register_hooks(hooks: object) -> None: - hooks.subscribe("statuses", "register_statuses", name, hook) # type: ignore[attr-defined] + hooks.subscribe(domain, action, name, hook) # type: ignore[attr-defined] return register_hooks -class _FakeRegistry: - """A pre-assembled registry stand-in for addresses the real catalog lacks.""" - - def __init__(self, subscriptions: list[Subscription]) -> None: - self._subscriptions = subscriptions - self._contexts: dict[str, ToolContext] = {} - - def build_once(self) -> None: - """Already assembled — the emission must not rebuild it.""" - - def subscriptions_for(self, domain: str, action: str) -> list[Subscription]: - """Exact address match, given order.""" - return [ - subscription - for subscription in self._subscriptions - if subscription.domain == domain and subscription.action == action - ] - - def self_context(self, tool: str) -> ToolContext: - """One context per tool, as the real registry does.""" - if tool not in self._contexts: - self._contexts[tool] = ToolContext(tool=tool) - - return self._contexts[tool] - - # --- Contract tests --- @@ -271,7 +251,7 @@ def test_emit_soft_failure_warns_and_continues( self, pin_package_environment, install_tool_package, - capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, ) -> None: """A soft hook is skipped with a warning — the sequence continues.""" pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) @@ -290,12 +270,15 @@ def b_hook(context: object) -> None: emit_hook_event(registry, "statuses", "register_statuses", _plain_view) assert len(b_calls) == 1 - assert ( - "Warning: hook x of tool a failed on statuses.register_statuses: bad registration" - in capsys.readouterr().err - ) + assert "hook x of tool a failed on statuses.register_statuses: bad registration" in caplog.text - def test_emit_hard_failure_stops_at_first_failure(self, hard_action_catalog: object) -> None: + def test_emit_hard_failure_stops_at_first_failure( + self, + pin_package_environment, + install_tool_package, + monkeypatch: pytest.MonkeyPatch, + hard_action_catalog: object, + ) -> None: """A hard hook failure stops the sequence with a clean error.""" calls: list[str] = [] @@ -306,12 +289,18 @@ def first(context: object) -> None: def second(context: object) -> None: calls.append("second") - registry = _FakeRegistry( - [ - Subscription(tool="t1", domain="d", action="act", name="n1", hook=first), - Subscription(tool="t2", domain="d", action="act", name="n2", hook=second), - ] + monkeypatch.setattr( + registration, + "declared_actions", + lambda: [ + Action(domain="d", name="act", error_class="hard"), + Action(domain="statuses", name="register_statuses", error_class="soft"), + ], ) + pin_package_environment({"goga_tool_t1": ["goga-tool-t1"], "goga_tool_t2": ["goga-tool-t2"]}) + install_tool_package("goga_tool_t1", register_hooks=_subscribe("n1", first, domain="d", action="act")) + install_tool_package("goga_tool_t2", register_hooks=_subscribe("n2", second, domain="d", action="act")) + registry = HookRegistry() with pytest.raises(ValueError, match=r"hook n1 of tool t1 failed on d\.act: stop"): emit_hook_event(registry, "d", "act", _plain_view) @@ -322,7 +311,7 @@ def test_emit_context_for_failure_is_clean_error_not_hook_failure( self, pin_package_environment, install_tool_package, - capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, ) -> None: """A crashing view builder is an emitting-side error, never a warning.""" pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) @@ -341,13 +330,13 @@ def context_for(tool: str) -> object: emit_hook_event(registry, "statuses", "register_statuses", context_for) assert hook_calls == [] - assert "Warning: hook" not in capsys.readouterr().err + assert "failed on statuses.register_statuses" not in caplog.text def test_emit_projection_failure_is_treated_as_hook_failure( self, pin_package_environment, install_tool_package, - capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, ) -> None: """An unprojectable signature is a hook failure under the error class.""" pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) @@ -356,15 +345,12 @@ def test_emit_projection_failure_is_treated_as_hook_failure( emit_hook_event(registry, "statuses", "register_statuses", _plain_view) - err = capsys.readouterr().err - - assert "Warning: hook" in err - assert "statuses.register_statuses" in err + assert "hook built-in of tool a failed on statuses.register_statuses" in caplog.text def test_emit_address_without_submissions_emits_nothing( self, pin_package_environment, - capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, ) -> None: """No subscriptions of the address — no view, no call, no diagnostics.""" pin_package_environment({}) @@ -375,4 +361,4 @@ def test_emit_address_without_submissions_emits_nothing( assert result is None context_for.assert_not_called() - assert capsys.readouterr().err == "" + assert caplog.text == "" diff --git a/tests/hooks/registry/test_state.py b/tests/hooks/registry/test_state.py index 5b8b17a1..563b4cbb 100644 --- a/tests/hooks/registry/test_state.py +++ b/tests/hooks/registry/test_state.py @@ -25,7 +25,7 @@ from pathlib import Path import pytest -from goga.hooks.registry import HookRegistry, ToolContext, ToolHooks, state +from goga.hooks.registry import HookRegistry, ToolContext, ToolHooks from goga.hooks.tools import RejectedRegistration, Subscription from tests.conftest import is_kw_only_dataclass @@ -237,7 +237,7 @@ def test_build_once_skips_a_package_without_the_callback_quietly( self, pin_package_environment, install_tool_package, - capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, ) -> None: """A facade without ``register_hooks`` is a quiet skip — no warning.""" pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) @@ -248,7 +248,7 @@ def test_build_once_skips_a_package_without_the_callback_quietly( registry.build_once() assert [s.name for s in registry.subscriptions] == ["two"] - assert capsys.readouterr().err == "" + assert caplog.text == "" def test_build_once_collects_rejections_of_every_registrar( self, @@ -279,7 +279,7 @@ def test_build_once_callback_crash_warns_and_keeps_partial_registrations( self, pin_package_environment, install_tool_package, - capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, ) -> None: """A crashed callback ends its own registration only — the rest runs.""" pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) @@ -295,25 +295,7 @@ def crashing(hooks: object) -> None: registry.build_once() assert [s.name for s in registry.subscriptions] == ["first", "second"] - assert "Warning: skipping hook registration of tool a: kaput" in capsys.readouterr().err - - def test_build_once_broken_import_is_fatal_and_not_swallowed( - self, - pin_package_environment, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """The platform-wrapped broken import is the single fatal case.""" - pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) - - def broken(package: object, registrar: object) -> bool: - facade = package.facade # type: ignore[attr-defined] - raise ImportError(f"package {facade} failed to import: boom") - - monkeypatch.setattr(state, "call_register_hooks", broken) - registry = HookRegistry() - - with pytest.raises(ImportError, match="goga_tool_a"): - registry.build_once() + assert "skipping hook registration of tool a: kaput" in caplog.text def test_build_once_broken_import_is_fatal_through_the_real_import( self, @@ -343,7 +325,7 @@ def test_build_once_callback_importerror_is_warning_not_fatal( self, pin_package_environment, install_tool_package, - capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, ) -> None: """An import failure raised inside a callback is a crash, not the fatal case.""" pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) @@ -359,7 +341,7 @@ def crashing(hooks: object) -> None: registry.build_once() assert [s.name for s in registry.subscriptions] == ["first", "second"] - assert "Warning: skipping hook registration of tool a" in capsys.readouterr().err + assert "skipping hook registration of tool a" in caplog.text # --- Logic tests: the read side --- diff --git a/tests/hooks/tools/test_registration.py b/tests/hooks/tools/test_registration.py index 96748139..fe3dc075 100644 --- a/tests/hooks/tools/test_registration.py +++ b/tests/hooks/tools/test_registration.py @@ -265,7 +265,7 @@ def test_subscription_and_rejection_reads_are_copies(self) -> None: class TestSubscribeRejects: - def test_subscribe_unknown_address_is_rejected_with_warning(self, capsys: pytest.CaptureFixture[str]) -> None: + def test_subscribe_unknown_address_is_rejected_with_warning(self, caplog: pytest.LogCaptureFixture) -> None: """An address outside the catalog is refused — data, not an exception.""" registrar = HookRegistrar(tool="t") @@ -275,13 +275,11 @@ def test_subscribe_unknown_address_is_rejected_with_warning(self, capsys: pytest assert [r.reason for r in registrar.rejections] == ["unknown action nope.no_action"] assert (registrar.rejections[0].tool, registrar.rejections[0].name) == ("t", "n") - assert "Warning: rejected hook of tool t on nope.no_action: unknown action nope.no_action" in ( - capsys.readouterr().err - ) + assert "rejected hook of tool t on nope.no_action: unknown action nope.no_action" in caplog.text def test_subscribe_invalid_envelope_is_rejected_and_partial_registrations_survive( self, - capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, ) -> None: """Every violation is refused on its own; the accepted one survives.""" registrar = HookRegistrar(tool="t") @@ -298,12 +296,11 @@ def test_subscribe_invalid_envelope_is_rejected_and_partial_registrations_surviv "repeated name on the same address", ] - err = capsys.readouterr().err - assert err.count("Warning: rejected hook of tool t on statuses.register_statuses:") == 3 + assert caplog.text.count("rejected hook of tool t on statuses.register_statuses:") == 3 def test_subscribe_non_string_name_is_rejected_with_an_empty_name( self, - capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, ) -> None: """A name that is not a string lands in the refusal as an empty string.""" registrar = HookRegistrar(tool="t") @@ -313,7 +310,7 @@ def test_subscribe_non_string_name_is_rejected_with_an_empty_name( assert registrar.subscriptions == [] assert registrar.rejections[0].name == "" assert registrar.rejections[0].reason == "name must be a non-empty string" - assert capsys.readouterr().err.startswith("Warning: rejected hook of tool t on statuses.register_statuses:") + assert "rejected hook of tool t on statuses.register_statuses:" in caplog.text def test_subscribe_repeats_are_refused_per_registrar(self) -> None: """Two tools hold separate registrars — the same name applies for both.""" diff --git a/tests/pipeline/compiler/test_compile_flow_memory_integration.py b/tests/integration/test_compile_flow_memory_integration.py similarity index 100% rename from tests/pipeline/compiler/test_compile_flow_memory_integration.py rename to tests/integration/test_compile_flow_memory_integration.py diff --git a/tests/commands/history/test_history_command.py b/tests/integration/test_history_command.py similarity index 99% rename from tests/commands/history/test_history_command.py rename to tests/integration/test_history_command.py index c742145c..6ddac2bf 100644 --- a/tests/commands/history/test_history_command.py +++ b/tests/integration/test_history_command.py @@ -3,7 +3,7 @@ Cross-entity scenarios — group → subcommand → domain → render → stdout/filesystem: the command layer resolves the inputs, the ``goga.history`` domain computes, the ``render`` module prints. The negative -paths live in ``test_history.py``; this file drives the happy paths and the +paths live in ``tests/commands/history/test_history.py``; this file drives the happy paths and the empty-result edges through the real command objects. Setup follows the cell conventions: ``tmp_path`` + ``monkeypatch.chdir`` for diff --git a/tests/topics/editor/test_entry.py b/tests/topics/editor/test_entry.py index a006312b..c75cf372 100644 --- a/tests/topics/editor/test_entry.py +++ b/tests/topics/editor/test_entry.py @@ -109,16 +109,25 @@ def test_edit_text_unchanged_save_cancels(self, monkeypatch: pytest.MonkeyPatch, assert edit_text(initial="Old text.\n") is None assert _tree_of(repo) == [] + @pytest.mark.parametrize( + "editor_command", + [ + "exit 0", + "printf 'Old text.\\n' > \"$1\"", + ], + ) def test_edit_text_initial_without_newline_normalized( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, editor_command: str ) -> None: - """A prefill without a trailing newline is normalized before the equality check.""" + """A prefill without a trailing newline is normalized before the equality check. + + Both arms leave the caller with no change: an editor that never + writes, and one that writes the normalized prefill back verbatim. + """ _tty(monkeypatch, isatty=True) - _editor_script(monkeypatch, tmp_path, "exit 0") - assert edit_text(initial="Old text.") is None + _editor_script(monkeypatch, tmp_path, editor_command) - _editor_script(monkeypatch, tmp_path, "printf 'Old text.\\n' > \"$1\"") assert edit_text(initial="Old text.") is None def test_edit_text_non_tty_clean_error(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py index dbe80e4f..d02c5855 100644 --- a/tests/topics/test_creation.py +++ b/tests/topics/test_creation.py @@ -462,8 +462,6 @@ def test_create_topic_switch_path_order(self, tmp_path: Path, monkeypatch: pytes wired = _wire_creation(monkeypatch, current="main", base_commit="c0ffee") wired.ensure_topic_dir.side_effect = lambda name, year: _topic_dir(tmp_path, year, name) monkeypatch.setattr(creation, "ensure_topic_dir", wired.ensure_topic_dir) - wired.write_todo.side_effect = creation._write_todo - monkeypatch.setattr(creation, "_write_todo", wired.write_todo) _tty(monkeypatch) monkeypatch.setattr(click, "confirm", mock.Mock(return_value=False)) @@ -475,7 +473,6 @@ def test_create_topic_switch_path_order(self, tmp_path: Path, monkeypatch: pytes mock.call.create_branch("feature-foo", "c0ffee"), mock.call.checkout("feature-foo"), mock.call.ensure_topic_dir("feature-foo", "2026"), - mock.call.write_todo("feature-foo", "2026", "Fix."), ] todo_file = tmp_path / ".goga" / "history" / "2026" / "feature-foo" / "todo.md" assert todo_file.read_text(encoding="utf-8") == "Fix.\n" @@ -923,25 +920,35 @@ def test_create_topic_todo_write_failure_is_clean_error( class TestEnterTopicTodo: - def test_enter_topic_todo_edits_existing_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + @pytest.mark.parametrize( + ("editor_command", "expected_result", "expected_content"), + [ + ("printf 'New line.\\n' > \"$1\"", True, "New line.\n"), + ("exit 0", False, "Old line.\n"), + ], + ) + def test_enter_topic_todo_edits_existing_file( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + editor_command: str, + expected_result: bool, + expected_content: str, + ) -> None: """An existing todo.md seeds the session; the saved text overwrites it. - Variant: a cancelled session — an editor that never writes — - returns False and leaves the file verbatim. + A cancelled session — an editor that never writes — returns False + and leaves the file verbatim. """ monkeypatch.chdir(tmp_path) todo_file = _topic_dir(tmp_path, "2026", "feature-foo") / "todo.md" _tty(monkeypatch) todo_file.write_text("Old line.\n", encoding="utf-8") - _editor_script(monkeypatch, tmp_path, "printf 'New line.\\n' > \"$1\"") - assert enter_topic_todo("feature-foo", year="2026") is True - assert todo_file.read_text(encoding="utf-8") == "New line.\n" + _editor_script(monkeypatch, tmp_path, editor_command) - todo_file.write_text("Old line.\n", encoding="utf-8") - _editor_script(monkeypatch, tmp_path, "exit 0") - assert enter_topic_todo("feature-foo", year="2026") is False - assert todo_file.read_text(encoding="utf-8") == "Old line.\n" + assert enter_topic_todo("feature-foo", year="2026") is expected_result + assert todo_file.read_text(encoding="utf-8") == expected_content def test_enter_topic_todo_seeds_existing_content(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The session starts from the existing todo.md — the prefill proves it. diff --git a/tests/topics/test_switching.py b/tests/topics/test_switching.py index 75fed4a3..e3888e42 100644 --- a/tests/topics/test_switching.py +++ b/tests/topics/test_switching.py @@ -541,14 +541,21 @@ def test_switch_topic_multiple_candidates_prompt( assert "1) feat/a (feat-a) [planned]" in captured.out assert "2) feat/ab (feat-ab) [defined]" in captured.out + @pytest.mark.parametrize("bad_answer", ["0", "9"]) def test_switch_topic_prompt_rejects_out_of_range_input( self, builtin_scale: StatusScale, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + bad_answer: str, ) -> None: - """The prompt is bounded by ``IntRange(1, N)`` — a bad answer re-asks.""" + """The prompt is bounded by ``IntRange(1, N)`` — a bad answer re-asks. + + Both invalid sides of the range are exercised — ``0`` below the + minimum and ``9`` above the maximum — and the re-ask then accepts + the minimum boundary ``1``. + """ monkeypatch.chdir(tmp_path) inventory = [ BranchRef(name="feat/a", remote=False), @@ -561,13 +568,13 @@ def test_switch_topic_prompt_rejects_out_of_range_input( _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=True) monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) - answers = iter(["9", "2"]) + answers = iter([bad_answer, "1"]) monkeypatch.setattr(click.termui, "visible_prompt_func", lambda _text: next(answers)) result = switch_topic("feat", year="2026") - assert result == "Switched to branch feat/ab" - checkout.assert_called_once_with("feat/ab") + assert result == "Switched to branch feat/a" + checkout.assert_called_once_with("feat/a") assert next(answers, None) is None, "both answers were consumed by the re-asking prompt" captured = capsys.readouterr() # The out-of-range complaint came through click's own error echo.