diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/accepted-state.json b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/accepted-state.json new file mode 100644 index 00000000..596fe80e --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/accepted-state.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "workflow_version": 2, + "workflow_origin_version": 2, + "id": "lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file", + "slug": "lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file", + "title": "Lifecycle commits stage only what the change owns, never every untracked file", + "description": "Lifecycle commits stage only what the change owns, never every untracked file", + "kind": "bug_fix", + "state": "accepted", + "canonical_applied": true, + "base_commit": "be3d90d8a67b31204623dd48c761e9c25770636f", + "created_at": 1790399719, + "updated_at": 1790402817, + "affected_specs": [ + "cmd_change", + "change" + ], + "affected_paths": [ + "src/commands/change.rs", + "src/change.rs", + "src/change_tests.rs", + "docs/ADOPTING.md" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "change check --commit and change ship --push never commit an untracked file outside the paths the change owns: an untracked file elsewhere in the tree stays untracked and is absent from every lifecycle commit, and each one left out is listed on standard error. Those commits still carry the change workspace, its archive package, the canonical spec and requirements files its deltas write, the sequence ledger, and every tracked edit, staged through explicit literal pathspecs rather than git add -A. The run_checked_commit doc comment states what is actually committed when the second verification fails, and that error names the materialize commit already made. Regression tests drive both commands over a tree holding an unrelated untracked file and fail if it is committed." + ], + "selected_artifacts": [ + "context", + "testing", + "tasks", + "design", + "requirements", + "docs", + "research", + "plan" + ], + "dependencies": [], + "answers": { + "architecture_risk": "yes", + "public_contract": "yes" + } +} diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/approvals.json b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/approvals.json new file mode 100644 index 00000000..be38ec2f --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/approvals.json @@ -0,0 +1,51 @@ +{ + "approvals": [ + { + "gate": "definition", + "actor": "user:0xLeif", + "timestamp": 1790402114, + "digest": "5a998fa452db13dff8d35fe8edbb0266b9173d6baa04e50a2a60831ee7c461f5", + "note": null, + "approved_scope": { + "schema_version": 1, + "change_id": "lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file", + "title": "Lifecycle commits stage only what the change owns, never every untracked file", + "description": "Lifecycle commits stage only what the change owns, never every untracked file", + "kind": "bug_fix", + "affected_specs": [ + "change", + "cmd_change" + ], + "affected_paths": [ + "docs/ADOPTING.md", + "src/change.rs", + "src/change_tests.rs", + "src/commands/change.rs" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "change check --commit and change ship --push never commit an untracked file outside the paths the change owns: an untracked file elsewhere in the tree stays untracked and is absent from every lifecycle commit, and each one left out is listed on standard error. Those commits still carry the change workspace, its archive package, the canonical spec and requirements files its deltas write, the sequence ledger, and every tracked edit, staged through explicit literal pathspecs rather than git add -A. The run_checked_commit doc comment states what is actually committed when the second verification fails, and that error names the materialize commit already made. Regression tests drive both commands over a tree holding an unrelated untracked file and fail if it is committed." + ], + "dependencies": [], + "supersedes": [], + "answers": { + "architecture_risk": "yes", + "public_contract": "yes" + } + }, + "approved_delta_digests": { + "change": "c65380e2fed99c09858dd7827c0404024d01d92206b0ed91a342fdd7d1596481", + "cmd_change": "75ae96beedc815128d33c82fa26311e8d21529a34b6348256e9bd8debcf182a3" + } + }, + { + "gate": "finalization", + "actor": "specsync:finalization", + "timestamp": 1790402816, + "digest": "6fa921faeaa7e5740f31c181921c7e1c1a783e1c2ae3e7f04c08754ed96297db", + "note": "Same-PR finalization closing digest" + } + ], + "reopenings": [] +} diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/change.md b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/change.md new file mode 100644 index 00000000..37206b1a --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/change.md @@ -0,0 +1,25 @@ +--- +id: lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file +state: archived +type: bug_fix +base_commit: be3d90d8a67b31204623dd48c761e9c25770636f +--- + +# Lifecycle commits stage only what the change owns, never every untracked file + +## Intent + +Lifecycle commits stage only what the change owns, never every untracked file + +## Affected Canonical Specs + +- `cmd_change` +- `change` + +## Acceptance Criteria + +- change check --commit and change ship --push never commit an untracked file outside the paths the change owns: an untracked file elsewhere in the tree stays untracked and is absent from every lifecycle commit, and each one left out is listed on standard error. Those commits still carry the change workspace, its archive package, the canonical spec and requirements files its deltas write, the sequence ledger, and every tracked edit, staged through explicit literal pathspecs rather than git add -A. The run_checked_commit doc comment states what is actually committed when the second verification fails, and that error names the materialize commit already made. Regression tests drive both commands over a tree holding an unrelated untracked file and fail if it is committed. + +## No-spec Rationale + +Not applicable diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/context.md b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/context.md new file mode 100644 index 00000000..70010183 --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/context.md @@ -0,0 +1,36 @@ +--- +change: lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file +artifact: context +--- + +# Context + +`change check --commit` and `change ship --push` both committed through `git_commit_all` in +`src/commands/change.rs`, which ran `git add -A`. That stages every untracked, non-ignored file +in the project, and `--push` publishes it. In one week it put a private debug zip into a pushed +arcsite commit, and an agent's `.agents/` directory and an `exp2.sh` experiment script into +corvid-bot commits. Nothing warned, because nothing in the lifecycle knew which files were its own. + +The same function carried a doc comment on `run_checked_commit` saying nothing is committed unless +verification passes. That holds for the first pass only: when the second pass fails, the +materialize commit is already on the branch. + +Constraints a session picking this up needs: + +- Verification digests the working tree, tracked and untracked (`project_input_digest` walks + `git ls-files --cached --others --exclude-standard`). A tracked edit left unstaged would be + verified and never committed, so tracked edits stay in the commit. +- An untracked file that is left out stays inside that digest. Committing it later keeps the + evidence current. Removing it stales the evidence, so the warning has to say so. +- `change new` writes `.specsync/workflow-v2-baseline.json` in a freshly adopted project. The + first test run of the fix left it out, which would have committed a change whose origin anchor + never reached history. It is a lifecycle ledger and is owned. +- `.specsync/change.lock` and the transaction journal are runtime files. `init` ignores them, + but a project without that ignore file used to commit the lock through `git add -A`. They are + neither committed nor reported now. +- The command layer holds no lifecycle policy (`specs/cmd_change/context.md`), so the decision + about which untracked paths the change owns lives in `src/change.rs`. + +Ruled out: owning the change's `affected_paths`. They are prefixes such as `src/` or `.`, and +owning every untracked file under them brings the sweep back. Also ruled out: owning a whole spec +directory, because a stray file dropped beside a spec is not a spec. diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/deltas/change.md b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/deltas/change.md new file mode 100644 index 00000000..fe8c0713 --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/deltas/change.md @@ -0,0 +1,163 @@ +## MODIFIED + +### SPEC SECTION Public API + +**Exported Constants** + +| Name | Description | +|------|-------------| +| `LESSON_BUNDLE_FILE` | Filename of the lesson bundle written into an archive, shared with the command layer so the two cannot name different files | +| `SDD_VERSION` | Current SDD project-layout version written by initialization | + +**Exported Types** + +| Type | Description | +|------|-------------| +| `ChangeState` | Six-state delivery lifecycle: draft, approved, implementing, verifying, accepted, archived | +| `ChangeKind` | Deterministic policy classification for feature, bug fix, refactor, migration, documentation, and operations work | +| `ArtifactKind` | Built-in or custom adaptive companion artifact selection | +| `SddPolicy` | Versioned enforcement, path, verification-command, template, and principles configuration | +| `SuccessionObligation` | Definition-bound predecessor path, canonical owner module, and full predecessor entry digest | +| `SupersedesEdge` | Durable predecessor ID and its sorted semantic succession obligations | +| `AcceptanceOwnerCorrection` | Sequenced human-authored exact path/module ownership correction for acceptance evidence | +| `ChangeRecord` | Durable machine state for one change workspace, including an explicit legacy-or-single-workflow version and omitted-when-empty supersedes/correction evidence | +| `LegacyArchiveBaselineV1` | Definition- and closing-bound authority, cutoff, and sorted legacy archive subtree entries | +| `LegacyArchiveBaselineEntryV1` | Archive ID, canonical dated path, unique introduction commit, and exact subtree digest | +| `CreateChangeRequest` | Validated creation inputs grouped for CLI, imports, and agent clients | +| `ApprovalRecord` | Actor, timestamp, gate, digest, optional note, optional backward-readable portable-pair metadata, and the optional per-module semantic delta body digests a definition gate approved | +| `ApprovedScopeV1` | Canonical stable intent, acceptance contract, risk declarations, and affected scope bound by one human approval | +| `NonMaterialScopeChangeCategory` | Closed implementation, test/evidence, canonical-materialization, and lifecycle-metadata classification set | +| `NonMaterialScopeChangeV1` | Path and concise evidence-backed classification for one approval-preserving migration change | +| `ScopeApprovalMigrationV1` | Historical embedded migration shape retained only to authenticate the allowlisted CHG-0068 anchor blob; it is not accepted as a general live projection bridge | +| `ScopeAdoptionSourcePreimageStatus` | Explicit declaration that the one allowlisted legacy approval preimage is unavailable | +| `ScopeAdoptionEquivalenceClaim` | Explicit declaration that the allowlisted adoption makes no cryptographic equivalence claim | +| `ScopeAdoptionAnchorV1` | Exact historical commit, approval index, and approval-ledger blob digest | +| `ScopeAdoptionAuthorizationV1` | Actor, recording time, and truthful reason for the one allowlisted adoption exception | +| `ScopeAdoptionV1` | Frozen adopted stable scope, anchor, authorization, and non-material classification evidence | +| `DefinitionApprovalPairRole` | Current/full or legacy/projected role for one marked portable definition member | +| `DefinitionApprovalPairV1` | Versioned pair identity, projection, role, change/correction coordinates, event index, and both digests | +| `ReopenRecord` | Immutable audit event preserving superseded closing approval, prior verification, actor, reason, transition, stale/current input digests, and the staleness cause when the digests are equal | +| `ReopenCauseV1` | Why accepted evidence was stale despite matching inputs: unanchored verification or unreconstructible legacy acceptance; absent means inputs drifted | +| `recorded_verification_is_current` | Whether a change's recorded verification still matches the tree and plan on disk, as a content question; missing or unreadable evidence is not current | +| `ReopenResult` | Deterministic change-plus-audit result returned by the reopen transition | +| `ReopenBackfillReport` | Per-change repair, skip, and failure detail for a `migrate 5.0` ledger backfill | +| `CorrectionField` | Closed supported accepted-metadata field set: public contract and architecture risk | +| `CorrectionRecord` | Immutable sequenced metadata correction with original/effective values, actor, reason, artifacts, prior evidence, and portable digest chain | +| `EffectiveChangeDefinition` | Validated projection of original answers/artifacts plus ordered corrections | +| `CorrectionResult` | Deterministic corrected change, event, effective definition, history, and gate-summary projection | +| `DefinitionMutationResult` | Crate-private successful definition mutation plus the effective definition, correction history, and normal/strict summaries validated inside its persistence transaction | +| `ApprovalLedger` | Ordered portable approval, allowlisted scope-adoption, and reopen history | +| `CommandEvidence` | Evidence for one verification step (in-process spec↔code sync) | +| `AcceptanceInputKind` | Canonical file, symlink, gitlink, missing, or non-file topology kind | +| `AcceptanceInputEntryV1` | Bounded path, kind, mode, payload digest, full-entry digest, and sorted owners for one accepted input | +| `AcceptanceManifestV1` | Versioned sorted per-input acceptance manifest | +| `SemanticSuccessionTupleV1` | Exact predecessor, path, module, old-entry digest, and new-entry digest transition | +| `SemanticSuccessionEvidenceV1` | Versioned sorted one-to-one closing evidence for approved supersedes obligations | +| `VerificationRecord` | Commit-bound verification result with separate stable-scope and volatile-execution digests, commands, requirement coverage, and optional acceptance manifest/succession evidence | +| `ScopedReviewVerdict` | Explicit passing or blocking conclusion for one scoped human review | +| `ScopedReviewProvenanceProvider` | Stored provenance-provider declaration; identity authentication requires separately enforced external policy | +| `ScopedReviewProvenanceV1` | Versioned required GitHub Actions check binding carried by review evidence | +| `ScopedReviewRecord` | Stable reviewer claim, required-check provenance, explicit verdict, implementation commit, scope/execution/workspace digests, and review timestamp bound before finalization | +| `ScopedReviewCurrency` | Three-valued answer to whether a recorded scoped review still holds: current, stale carrying what moved, or unavailable when the guarantee could not be evaluated at all | +| `reason` | Why a scoped review is not current, for the callers that render a blocker or a warning | +| `FinalizationRecord` | Automated non-approval evidence binding implementation commit/tree, contract/workspace/closing/review digests, archive identity, and a domain-separated finalization digest | +| `ChangeReadScope` | Crate-private invocation guard that owns one bounded read-only lifecycle snapshot | +| `InterviewQuestion` | Stable deterministic question with choices and recommendation | +| `TerminalEvidenceValidity` | State-aware exact, successor-covered, stale, authenticated-history, or corrupt-history evidence conclusion | +| `TerminalEvidenceSummary` | Shared terminal validity plus optional fail-closed reason | +| `TerminalEvidenceResult` | Change ID paired with its shared terminal-evidence summary | +| `HandoffReadiness` | `Safe`, `Conditional`, or `NotYet`: whether clearing context now loses anything the lifecycle has not recorded; serialized kebab-case, printed as `safe` / `conditional` / `not yet` | +| `HandoffSummary` | Readiness, a plain-language reason, the `specsync change status ` resume command, and the steps to take before clearing; carries no digest | +| `HandoffSignals` | The complete, digest-free input to `classify_handoff`: state, workflow version, sequence-ledger freeze, open questions, artifact completeness, approval/correction validity, uncommitted scoped edits, verification/review currency, and stale legacy terminal evidence | +| `ChangeSummary` | Human/agent status projection with approval health/current scope digest, plain-language material expansion, validator plan, scoped-review freshness, exactly one next action, a handoff verdict, and optional terminal evidence | +| `SddCheckReport` | Unified lifecycle errors, warnings, checked-change count, and terminal-evidence results | +| `UnreadableChange` | One active-change workspace that exists on disk but could not be read, carrying its directory identity and a reason naming the offending path | +| `ChangeRoster` | The active-change roster as two separate facts: the records that were read and the workspaces that could not be, so absence and unreadability cannot share a value | +| `LifecycleCommitScope` | Crate-private answer to what one change's lifecycle commit may stage: the untracked paths the lifecycle wrote or the change owns, and the runtime files (lock, transaction journal) it must neither stage nor report | + +**Exported Functions** + +| Function | Parameters | Returns | Description | +|----------|------------|---------|-------------| +| `accept_change` | `root, id, actor, note` | `Result` | Record closing approval and atomically apply semantic deltas only when not already canonical | +| `acceptance_entries` | `root: &Path, record: &ChangeRecord` | `Vec` | Accepted acceptance-input entries, so `change show --json` can surface the `specsync.acceptance-entry.v1` digests `change supersede --digest` requires; empty when evidence is absent | +| `accumulated_lessons` | `root, modules` | `Vec<(String, usize)>` | Substantive-prose line count for each module context that holds any, so a new change can be pointed at what its modules already learned | +| `active_change_id` | `root` | `Option` | The change a bare lifecycle command acts on: the single active approved/implementing/verifying record — the same states `check_change` selects — or none | +| `add_acceptance_owner_correction` | `root, id, path, module, actor, reason` | `Result` | Append one audited exact canonical owner correction to a reopened already-applied change | +| `add_acceptance_owner_corrections` | `root, id, entries, actor, reason` | `Result` | Validate every exact path/module owner correction, then append all as sequenced audit entries in one transactional write | +| `add_dependency` | `root, id, dependency` | `Result` | Production domain API that validates ledger health under lock, declares ordering between active changes, and invalidates stale approval digests | +| `add_dependency_with_snapshot` | `root, id, dependency` | `Result` | Crate-private command path that returns the dependency mutation with its full in-transaction machine snapshot | +| `add_missing_acceptance_owner_corrections` | `root, id, module, actor, reason` | `Result` | Discover production-source affected paths lacking canonical ownership for a module and append them as one transactional batch | +| `add_supersedes_obligation` | `root, id, predecessor, path, module, predecessor_entry_digest` | `Result` | Production domain API that validates ledger health under lock, then adds one definition-bound semantic succession obligation to a draft | +| `add_supersedes_obligation_with_snapshot` | `root, id, predecessor, path, module, predecessor_entry_digest` | `Result` | Crate-private command path that returns the supersession mutation with its full in-transaction machine snapshot | +| `adopt` | `root, dry_run, source` | `Result, String>` | Preview or atomically enable SDD — writing an enabled policy when none exists and flipping `enabled` on one written off by `init`, failing closed on a policy it cannot parse — activate workflow v2 without stranding cutoff-ineligible legacy records or rewriting legacy policy, and import OpenSpec or Spec Kit artifacts | +| `answer_question` | `root, id, question, answer` | `Result` | Production domain API that validates ledger health under lock, then persists an interview answer and updates adaptive artifacts | +| `answer_question_with_snapshot` | `root, id, question, answer` | `Result` | Crate-private command path that returns the answer mutation with its full in-transaction machine snapshot | +| `approve_definition` | `root, id, actor, note` | `Result` | Validate and record an ordinary mandatory definition approval | +| `approve_definition_portable_v501` | `root, id, actor, note` | `Result` | Atomically record the marked current/5.0.1 portable definition pair | +| `archive_change` | `root, id` | `Result` | Move an accepted workspace into the dated archive | +| `artifacts_complete_for_guidance` | `root, record` | `bool` | Lightweight selected-artifact completeness for human next-action guidance without digest loaders | +| `audit_project` | `root: &Path` | `SddCheckReport` | Active workspaces + living policy/spec coherence only — does not rewalk archived terminal evidence | +| `backfill_reopen_digests` | `root: &Path, dry_run: bool` | `Result` | Backfill 5.1 reopening digest fields on 5.0.1-era ledgers with verified, idempotent, dry-run-aware writes | +| `begin_change_read_scope` | `root: &Path` | `ChangeReadScope` | Install one invocation-scoped read snapshot for list/show/status and project reports | +| `check_change` | `root, optional id` | `Result, String>` | Select one approved/implementing change, materialize its canonical deltas, and compare this change's specs to code | +| `check_change_with_strict` | `root, optional id, strict` | `Result, String>` | Run `check_change` with warnings failing as they do under `specsync check --strict` | +| `check_project` | `root: &Path` | `SddCheckReport` | Full lifecycle integrity including archive terminal evidence (tests and rare callers; not the default CLI path) | +| `classify_handoff` | `id, signals` | `HandoffSummary` | Pure classification of handoff readiness from `HandoffSignals`; the only source of the verdict, so text and JSON cannot disagree | +| `correct_interview_metadata` | `root, id, field, value, actor, reason` | `Result` | Append a supported accepted-metadata correction and return the effective audited view | +| `correction_history` | `root, record` | `Result, String>` | Load validated append-only correction records for inspection clients | +| `create_change` | `root: &Path, request: CreateChangeRequest` | `Result` | Create a sequential draft workspace and adaptive artifacts | +| `detect_verification_commands` | `root: &Path` | `Vec` | Detect explicit fledge, Cargo, Bun, or Swift test commands | +| `effective_change_definition` | `root, record` | `Result` | Validate and project original metadata through its ordered correction history | +| `finalize_change` | `root, id` | `Result` | Validate current verification/review evidence and transactionally produce the dated same-PR archive | +| `find_change_dir` | Resolves a change's workspace wherever it lives, active or archived — the single answer to where a change's artifacts are | +| `floor_sequence_ledger_to_committed` | `root: &Path` | `Result, String>` | Raise a working-tree sequence ledger to the committed high-water mark before staging, returning the previous and adopted values so the caller can disclose the raise, or `None` when the ledger is already at or above it | +| `handoff_summary` | `root, record` | `HandoffSummary` | Gather only the signals the record's state needs — never the archive-history walk for an Archived record — and classify them; the same verdict `ChangeSummary.handoff` carries | +| `lesson_fold_targets` | `root, id` | `Vec` | Module context paths this change's lessons are folded into at archival; empty when the change is unreadable or owns no specs | +| `lifecycle_commit_scope` | `root, id` | `Result` | The one answer to which untracked paths a lifecycle commit for this change may stage: its workspace, its archive package, each affected spec's canonical file and companions, and the lifecycle ledgers — never its `affected_paths` prefixes; `Err` when the change cannot be loaded | +| `list_changes` | `root: &Path` | `Result` | List active changes in stable ID order alongside the workspaces that could not be read; `Err` only when the changes directory itself is unreadable | +| `load_change` | `root: &Path, id: &str` | `Result` | Load active or archived change state | +| `load_policy` | `root: &Path` | `Option` | Load `.specsync/sdd.json`; absence leaves existing projects unenforced | +| `module_context_path` | `module` | `String` | The single definition of where a module's accumulated lessons live, shared by surfacing and folding so they cannot disagree | +| `next_questions` | `record: &ChangeRecord` | `Vec` | Return deterministic unanswered interview questions | +| `record_bootstrap_paths` | `root: &Path` | `Result<(), String>` | Record the protected SDD paths this bootstrap created in `.specsync/bootstrap.json`, so initialization's own output is not reported as uncovered meaningful delivery; editing a recorded file revokes its exemption | +| `record_scoped_review` | `root, id, reviewer` | `Result` | Record one implementation-scoped review bound to current governed inputs; the reviewer may be the definition approver | +| `record_scoped_review_with_verdict` | `root, id, reviewer, verdict` | `Result` | Record an explicit passing or blocking review; only a current passing verdict permits finalization | +| `recorded_scoped_review_currency` | `root, record` | `Option` | Classify a change's recorded scoped review against the tree on disk; `None` means no usable review record exists, which is a different question from currency | +| `reopen_change` | `root, id, actor, reason` | `Result` | Move stale accepted evidence to verifying and append an immutable supersession audit event | +| `start_implementation` | `root, id` | `Result` | Enter implementation after approval and conflict validation | +| `summarize_change` | `root, record` | `ChangeSummary` | Project gate health, correction health, and next action using the shared verification-freshness predicate | +| `summarize_change_with_strict` | `root, record, explicit_strict` | `ChangeSummary` | Project the same status plus the exact scoped command the next pass records as evidence; `--strict` only when requested | +| `verify_change` | `root, id` | `Result` | Compare this change's specs to code in-process and record commit/contract evidence | +| `verify_change_with_strict` | `root, id, strict` | `Result` | The same spec↔code pass with warnings failing as they do under `specsync check --strict` | +| `write_default_policy` | `root: &Path, verification_commands: Vec` | `Result<(), String>` | Write new-project/adoption policy without overwriting existing policy | + +**Exported Methods** + +| Method | Description | +|--------|-------------| +| `as_str` | Return the stable serialized name for a change state, kind, or correction field | +| `parse` | Parse user-facing change-kind, artifact, or supported correction-field names into typed values | +| `file_name` | Resolve an adaptive artifact to its safe Markdown filename | +| `is_clean` | Return true when a ledger backfill recorded no per-change failures | +| `is_degraded` | Return true when at least one workspace could not be read, so no caller may draw a conclusion from a missing record | + +Acceptance Criteria + +- Nested lifecycle commands still fail once with the established deterministic contextual error. +- The process marker and diagnostic helper remain private binary implementation details. +- Correction inspection exposes typed portable records without exposing mutable ledger internals. +- Acceptance-owner corrections expose only immutable audit fields and never mutable internal ledgers. + +## ADDED + +### REQUIREMENT REQ-change-102 + +The change domain SHALL be the single answer to which untracked paths a lifecycle commit for one change may stage, so the command layer that stages them never decides lifecycle ownership itself. + +Acceptance Criteria +- `lifecycle_commit_scope` names the change's active workspace, its archive package once finalization has moved it there, each affected spec's canonical spec file and its canonical companions, and the lifecycle ledgers: the sequence ledger, the workflow-v2 and legacy-archive baselines, the bootstrap record, and the hash cache. +- It never names the change's `affected_paths` prefixes or an affected spec's whole directory, so an untracked file under `src/` or beside a spec is not owned by being there. +- It names the project lock and the transaction journal separately as runtime files, which a lifecycle commit neither stages nor reports. +- Canonical spec paths resolve through the same registry-aware resolver materialization writes through. +- Paths are project-relative with forward slashes, sorted and deduplicated, and a change that cannot be loaded is an error rather than an empty answer. diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/deltas/cmd_change.md b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/deltas/cmd_change.md new file mode 100644 index 00000000..f7264c9f --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/deltas/cmd_change.md @@ -0,0 +1,25 @@ +## MODIFIED + +### REQUIREMENT REQ-cmd-change-012 + +Lifecycle commits SHALL apply the sequence-ledger floor before staging, and SHALL NOT block the author when they do. + +Acceptance Criteria +- Materialize, verification-evidence and archive commits all floor the ledger before staging. +- A change whose ledger went stale while its branch sat still completes, because the author caused nothing and blocking them would punish a race they cannot observe. +- The disclosure appears on standard error rather than standard output, so `--format json` output remains a single parseable document. + +## ADDED + +### REQUIREMENT REQ-cmd-change-017 + +`change check --commit` and `change ship --push` SHALL commit only the project's tracked edits and the untracked paths the change domain reports the change owns, and SHALL NOT stage any other untracked file. + +Acceptance Criteria +- Staging uses explicit literal pathspecs; no lifecycle commit runs `git add -A`. +- An untracked file outside the change's owned paths is absent from the materialize, verification-evidence and archive commits and from what `--push` publishes, and it stays untracked and unmodified in the working tree. +- The change's own untracked workspace, its archive package, the canonical spec files its deltas write, the lifecycle ledgers, and every tracked edit are still committed, so the committed tree is the tree that was verified. +- Each untracked file left out is listed once per run on standard error, bounded for a long list, with guidance to `git add` what belongs to the delivery and to move, delete or ignore the rest; after `check --commit` the guidance also says that removing one stales the recorded verification and names the command that re-records it. Standard output under `--format json` stays a single document. +- Lifecycle runtime files, the project lock and the transaction journal, are neither staged nor listed. +- Unmerged entries are not staged, so an unresolved conflict still stops the commit instead of being recorded as resolved. +- `check --commit` makes no commit unless its first verification passes. When the re-verification against the committed tree fails, the materialize commit stays on the branch, and the error names that commit and the command that resumes. diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/design.md b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/design.md new file mode 100644 index 00000000..756dfdd6 --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/design.md @@ -0,0 +1,62 @@ +--- +change: lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file +artifact: design +--- + +# Design + +## Ownership is a domain answer + +`change::lifecycle_commit_scope(root, id) -> Result` returns two +lists of project-relative paths: + +- `owned` holds the untracked paths a lifecycle commit may stage: the active workspace + `.specsync/changes/` (still owned after `finalize` moves it, so the removal of its tracked + files is staged), the archive package once it lives there (`find_change_dir`), each affected + spec's canonical spec file plus the five `CANONICAL_SPEC_COMPANIONS` beside it (resolved through + `canonical_module_paths`, the resolver materialization writes through), and the lifecycle + ledgers: `change-sequence.json`, `workflow-v2-baseline.json`, `archive/legacy-baseline.json`, + `bootstrap.json` and `hashes.json`. +- `runtime` holds `.specsync/change.lock` and `.specsync/change-transaction.json`, which are + never staged and never reported. + +## Staging is a command-layer mechanism + +`git_commit_all` becomes `git_commit_lifecycle(root, scope, message) -> LifecycleCommit`. It +still floors the sequence ledger first (REQ-cmd-change-012/013), then calls +`stage_lifecycle_paths`, which reads `git status --porcelain=v1 -z --untracked-files=all -- .` +once and classifies each entry: + +| Entry | Action | +|---|---| +| tracked, worktree column not blank | staged | +| tracked, fully staged already | nothing to do | +| unmerged (`U` in either column, `AA`, `DD`) | not staged, so `git commit` still refuses the conflict | +| untracked under `scope.owned` | staged | +| untracked under `scope.runtime` | ignored | +| any other untracked file | left out and returned | + +Staging runs `git --literal-pathspecs add -- ` in batches of 200, so a filename containing +glob or `:` magic is taken literally. Porcelain paths are relative to the repository top level, so +the `rev-parse --show-prefix` prefix is stripped before matching and staging. A project nested in +a larger repository then stages its own paths and only those. Rename and copy entries carry their +source as a second NUL field, in either column, and it is skipped. The index is not reset, so +anything the author already staged is committed as before. + +`LifecycleCommit { committed, left_out }` lets the callers disclose and word their output +truthfully: + +- `run_checked_commit` warns once per run on stderr, listing at most 20 paths and summarizing the + rest. It adds that removing a listed file stales the recorded verification and names the command + that re-records it. When the second verification fails after a materialize commit was made, the + error names that commit and the resume command. The commit is not rewound. +- `ship_commit_and_push_archive` warns the same way before pushing, without the re-check line, + because archived evidence is not recomputed against the live tree. + +## Why not rewind on a second-pass failure + +The alternative was to make the doc comment true by running `git reset --soft` to the pre-commit +HEAD. The second pass verifies the same content as the first, so a failure there is rare, and the +materialize commit left behind is a valid intermediate state that a re-run completes. Moving a +branch tip back on the author's behalf is the less safe choice, so the comment now describes the +behavior and the error names the commit. diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/docs.md b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/docs.md new file mode 100644 index 00000000..c4e7e64c --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/docs.md @@ -0,0 +1,23 @@ +--- +change: lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file +artifact: docs +--- + +# Docs + +- `docs/ADOPTING.md`, section "Drive one real change end to end", gains a paragraph. It says + `check --commit` and `ship --push` commit tracked edits plus the change's own untracked files, + never any other untracked file, and that a new source file has to be `git add`ed before + `check --commit`. +- `AGENTS.md` Quick Reference row for `change check [id] --commit` says the same in one clause. +- `specs/cmd_change/requirements.md`: REQ-cmd-change-012 no longer names `git add -A`, and + REQ-cmd-change-017 is added (delta `deltas/cmd_change.md`). +- `specs/change/requirements.md`: REQ-change-102 is added, and `specs/change/change.spec.md` + Public API documents `LifecycleCommitScope` and `lifecycle_commit_scope` (delta + `deltas/change.md`). +- Companion context, testing and tasks notes are updated for both modules. The stale + `git_commit_all` reference in `specs/change/context.md` now names `git_commit_lifecycle`. + +Reader-visible behavior change: a lifecycle commit now prints a `warning: left N untracked +file(s) out of this lifecycle commit` block on stderr when it leaves files out. Standard output, +including `--format json`, is unchanged. diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/finalization.json b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/finalization.json new file mode 100644 index 00000000..2e0313a6 --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/finalization.json @@ -0,0 +1,12 @@ +{ + "schema_version": 2, + "change_id": "lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file", + "implementation_commit": "f50bfdd57e62925c4cebcd6cb60719907cb5ff63", + "implementation_tree": "4554f306e90222ba3d608a8800a118c50f382012", + "contract_digest": "5a998fa452db13dff8d35fe8edbb0266b9173d6baa04e50a2a60831ee7c461f5", + "workspace_digest": "8bd65c4f58d8b0244e9d3c692dfc843446137fca4d9a9df62594374f775be2ef", + "closing_digest": "6fa921faeaa7e5740f31c181921c7e1c1a783e1c2ae3e7f04c08754ed96297db", + "review_digest": "209f5256ff60df0c9178552a198db78970fd55335229b12f39a15720822d9b50", + "finalization_digest": "d4beee9001f1fea6ee85a32457e54a7ae42bffa31c85e1809b9b8a31a497bd5a", + "timestamp": 1790402817 +} diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/lesson-bundle.md b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/lesson-bundle.md new file mode 100644 index 00000000..6bb163c6 --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/lesson-bundle.md @@ -0,0 +1,168 @@ +# Lesson bundle — lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file + +Material for folding this change's lessons into the affected specs' `context.md`. +Synthesise from what actually happened below; do not restate the change description. + +## What this change was + +- **Title**: Lifecycle commits stage only what the change owns, never every untracked file +- **Kind**: BugFix +- **Specs**: cmd_change, change +- **Paths**: src/commands/change.rs, src/change.rs, src/change_tests.rs, docs/ADOPTING.md +- **Acceptance**: change check --commit and change ship --push never commit an untracked file outside the paths the change owns: an untracked file elsewhere in the tree stays untracked and is absent from every lifecycle commit, and each one left out is listed on standard error. Those commits still carry the change workspace, its archive package, the canonical spec and requirements files its deltas write, the sequence ledger, and every tracked edit, staged through explicit literal pathspecs rather than git add -A. The run_checked_commit doc comment states what is actually committed when the second verification fails, and that error names the materialize commit already made. Regression tests drive both commands over a tree holding an unrelated untracked file and fail if it is committed. + +## Evidence + +- Verification commit: `f50bfdd57e62925c4cebcd6cb60719907cb5ff63` +- Base commit: `be3d90d8a67b31204623dd48c761e9c25770636f` +- Verified by: `specsync check --spec change --spec cmd_change` + +## From the change's context.md + +# Context + +`change check --commit` and `change ship --push` both committed through `git_commit_all` in +`src/commands/change.rs`, which ran `git add -A`. That stages every untracked, non-ignored file +in the project, and `--push` publishes it. In one week it put a private debug zip into a pushed +arcsite commit, and an agent's `.agents/` directory and an `exp2.sh` experiment script into +corvid-bot commits. Nothing warned, because nothing in the lifecycle knew which files were its own. + +The same function carried a doc comment on `run_checked_commit` saying nothing is committed unless +verification passes. That holds for the first pass only: when the second pass fails, the +materialize commit is already on the branch. + +Constraints a session picking this up needs: + +- Verification digests the working tree, tracked and untracked (`project_input_digest` walks + `git ls-files --cached --others --exclude-standard`). A tracked edit left unstaged would be + verified and never committed, so tracked edits stay in the commit. +- An untracked file that is left out stays inside that digest. Committing it later keeps the + evidence current. Removing it stales the evidence, so the warning has to say so. +- `change new` writes `.specsync/workflow-v2-baseline.json` in a freshly adopted project. The + first test run of the fix left it out, which would have committed a change whose origin anchor + never reached history. It is a lifecycle ledger and is owned. +- `.specsync/change.lock` and the transaction journal are runtime files. `init` ignores them, + but a project without that ignore file used to commit the lock through `git add -A`. They are + neither committed nor reported now. +- The command layer holds no lifecycle policy (`specs/cmd_change/context.md`), so the decision + about which untracked paths the change owns lives in `src/change.rs`. + +Ruled out: owning the change's `affected_paths`. They are prefixes such as `src/` or `.`, and +owning every untracked file under them brings the sweep back. Also ruled out: owning a whole spec +directory, because a stray file dropped beside a spec is not a spec. + +## From the change's design.md + +# Design + +## Ownership is a domain answer + +`change::lifecycle_commit_scope(root, id) -> Result` returns two +lists of project-relative paths: + +- `owned` holds the untracked paths a lifecycle commit may stage: the active workspace + `.specsync/changes/` (still owned after `finalize` moves it, so the removal of its tracked + files is staged), the archive package once it lives there (`find_change_dir`), each affected + spec's canonical spec file plus the five `CANONICAL_SPEC_COMPANIONS` beside it (resolved through + `canonical_module_paths`, the resolver materialization writes through), and the lifecycle + ledgers: `change-sequence.json`, `workflow-v2-baseline.json`, `archive/legacy-baseline.json`, + `bootstrap.json` and `hashes.json`. +- `runtime` holds `.specsync/change.lock` and `.specsync/change-transaction.json`, which are + never staged and never reported. + +## Staging is a command-layer mechanism + +`git_commit_all` becomes `git_commit_lifecycle(root, scope, message) -> LifecycleCommit`. It +still floors the sequence ledger first (REQ-cmd-change-012/013), then calls +`stage_lifecycle_paths`, which reads `git status --porcelain=v1 -z --untracked-files=all -- .` +once and classifies each entry: + +| Entry | Action | +|---|---| +| tracked, worktree column not blank | staged | +| tracked, fully staged already | nothing to do | +| unmerged (`U` in either column, `AA`, `DD`) | not staged, so `git commit` still refuses the conflict | +| untracked under `scope.owned` | staged | +| untracked under `scope.runtime` | ignored | +| any other untracked file | left out and returned | + +Staging runs `git --literal-pathspecs add -- ` in batches of 200, so a filename containing +glob or `:` magic is taken literally. Porcelain paths are relative to the repository top level, so +the `rev-parse --show-prefix` prefix is stripped before matching and staging. A project nested in +a larger repository then stages its own paths and only those. Rename and copy entries carry their +source as a second NUL field, in either column, and it is skipped. The index is not reset, so +anything the author already staged is committed as before. + +`LifecycleCommit { committed, left_out }` lets the callers disclose and word their output +truthfully: + +- `run_checked_commit` warns once per run on stderr, listing at most 20 paths and summarizing the + rest. It adds that removing a listed file stales the recorded verification and names the command + that re-records it. When the second verification fails after a materialize commit was made, the + error names that commit and the resume command. The commit is not rewound. +- `ship_commit_and_push_archive` warns the same way before pushing, without the re-check line, + because archived evidence is not recomputed against the live tree. + +## Why not rewind on a second-pass failure + +The alternative was to make the doc comment true by running `git reset --soft` to the pre-commit +HEAD. The second pass verifies the same content as the first, so a failure there is rare, and the +materialize commit left behind is a valid intermediate state that a re-run completes. Moving a +branch tip back on the author's behalf is the less safe choice, so the comment now describes the +behavior and the error names the commit. + +## From the change's testing.md + +# Testing + +## Requirement evidence + +| Requirement | Evidence | +|---|---| +| REQ-cmd-change-017 | `check_commit_never_commits_an_unrelated_untracked_file`, `ship_push_never_commits_an_unrelated_untracked_file`, `staging_reads_each_porcelain_entry_once_and_stages_only_tracked_edits`, `a_lifecycle_scope_owns_its_subtree_and_not_a_prefix_sibling`, `the_left_out_warning_names_the_files_and_what_to_do` in `src/commands/change.rs` | +| REQ-cmd-change-012 | `lifecycle_commit_raises_a_stale_ledger_before_staging_it` (renamed from `git_commit_all_raises_a_stale_ledger_before_staging_it`; it now drives `git_commit_lifecycle`) | +| REQ-change-102 | `lifecycle_commit_scope_names_exactly_what_the_change_owns` in `src/change_tests.rs` | + +## What the regression tests do + +- `check_commit_never_commits_an_unrelated_untracked_file` runs `run_checked_commit` on an + approved change whose workspace is still untracked. The tree holds a tracked delivery edit and + three strays: `debug-dump.zip`, `.agents/scratch.md` and `exp2.sh`. It requires every stray + to be absent from all history and still untracked and unmodified. As controls, it requires the + workspace files, the workflow-v2 baseline `change new` wrote, and the tracked edit to be + committed, and nothing else except strays and runtime files to be left over. +- `ship_push_never_commits_an_unrelated_untracked_file` runs `check --commit`, `review` and + `run_ship --push` into a bare remote, with the strays present from before verification. It + requires no stray in the remote's history, the archive package in the pushed tree, the vacated + workspace absent from it, and the pushed tip to be the archive commit. + +## Discrimination + +Run against the same tree with staging put back, and the file compared byte for byte with the +fixed copy after each experiment: + +| Tree | `check_commit_…` | `ship_push_…` | +|---|---|---| +| fixed | ok | ok | +| `git_commit_lifecycle` staging with `git add -A` | **FAILED**: `debug-dump.zip was committed` | **FAILED**: `debug-dump.zip was committed` | +| only the archive commit staging with `git add -A` | n/a | **FAILED** | + +`staging_reads_each_porcelain_entry_once_and_stages_only_tracked_edits` fails when the rename +source field is not skipped, because the source name is then parsed as an entry and `git add` is +handed a truncated path. + +The first run of the check test also caught a defect in the fix itself. +`.specsync/workflow-v2-baseline.json`, which `change new` writes in a freshly adopted project, +was being left out. It is now an owned ledger, and the test asserts it is committed. + +## Suite + +`fledge lanes run verify`: fmt, `cargo clippy -- -D warnings` and `cargo check` pass; the +full `cargo test` passes 2504 unit and 437 integration tests with 0 failures; release build +passes; `specsync check --strict --require-coverage 100 --force` passes 62/62 specs with 100% +file coverage; the release-candidate test passes. `fledge trust verify` passes. + +## Where these lessons go + +- `specs/cmd_change/context.md` +- `specs/change/context.md` diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/plan.md b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/plan.md new file mode 100644 index 00000000..690b5186 --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/plan.md @@ -0,0 +1,23 @@ +--- +change: lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file +artifact: plan +--- + +# Plan + +1. Add `LifecycleCommitScope` and `lifecycle_commit_scope` to `src/change.rs`, which owns the + ownership decision, and a domain test that asserts the exact owned and runtime sets. +2. Replace `git_commit_all` with `git_commit_lifecycle` and `stage_lifecycle_paths` in + `src/commands/change.rs`: stage tracked edits plus owned untracked paths through literal + pathspecs, skip unmerged and runtime entries, and return what was left out. +3. Disclose left-out files on stderr from `run_checked_commit` (once per run, with the re-check + guidance) and from `ship_commit_and_push_archive`. +4. Correct `run_checked_commit`'s doc comment, and name the materialize commit in the second-pass + error. +5. Add regression tests. `check --commit` and `ship --push` run over a tree with unrelated + untracked files, and each test fails when staging is put back to `git add -A`. Controls check + that owned files and tracked edits still land. Add unit tests for the porcelain parse, the scope + boundary and the warning. +6. Update `docs/ADOPTING.md`, `AGENTS.md`, the two deltas, the change spec's Public API table, + and the companion notes for both modules. +7. Run `fledge lanes run verify` and `fledge trust verify`. diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/requirements.md b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/requirements.md new file mode 100644 index 00000000..bf292a98 --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/requirements.md @@ -0,0 +1,30 @@ +--- +change: lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file +artifact: requirements +--- + +# Requirements + +## `REQ-cmd-change-017` (ADDED) + +Lifecycle commits stage only the project's tracked edits and the untracked paths the change owns, +through literal pathspecs. Any other untracked file stays out of every commit, and out of what +`--push` publishes, and is listed on stderr. The runtime lock and journal are neither staged nor +listed, and unmerged entries are not staged. `check --commit` commits nothing unless its first +pass verifies, and a second-pass failure names the materialize commit it leaves in place. + +## `REQ-cmd-change-012` (MODIFIED) + +Same guarantee: the sequence-ledger floor still runs before staging and still does not block the +author. The only change is that the text no longer says staging is `git add -A`. + +## `REQ-change-102` (ADDED) + +The change domain is the single answer to which untracked paths a lifecycle commit may stage, and +the answer never includes `affected_paths` prefixes or whole spec directories. + +## Out of scope + +- Making the project-input digest ignore untracked files. Evidence still covers the working tree + as it sits, and the warning says what that means for a left-out file. +- Rewinding the materialize commit when the second pass fails (see design). diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/research.md b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/research.md new file mode 100644 index 00000000..53e56759 --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/research.md @@ -0,0 +1,42 @@ +--- +change: lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file +artifact: research +--- + +# Research + +## Where the sweep happened + +- `src/commands/change.rs` `git_commit_all` ran `git add -A`. It had three callers: the + materialize and verification-evidence commits in `run_checked_commit` (`check --commit`), and + the archive commit in `ship_commit_and_push_archive` (`ship --push`). +- Nothing else in the lifecycle stages files. `finalize` and plain `check` only write the working + tree. + +## What the lifecycle writes between `change new` and the archive commit + +- `create_change`: the workspace, and `.specsync/workflow-v2-baseline.json` when the project was + just adopted (`ensure_workflow_v2_baseline`). +- `materialize_change_deltas`: each affected spec's canonical spec file and `requirements.md` + (`prepare_pending_delta_application` via `canonical_module_paths`), plus the workspace + `state.json` and `change.md`. +- `verify_change_locked`: `verification.json`, `verification-attempts.json` and `state.json` + in the workspace. +- The staging path: `.specsync/change-sequence.json` (`floor_sequence_ledger_to_committed`). +- `finalize_change`: moves the workspace into `.specsync/archive/changes/-/` and + writes `accepted-state.json`, `finalization.json` and `lesson-bundle.md` there. +- Every locked operation: `.specsync/change.lock`, and `.specsync/change-transaction.json` while + a transaction is in flight. Both are volatile to the project-input digest and ignored by + `init`'s `.specsync/.gitignore`. + +## Why tracked edits stay staged + +`project_input_digest` walks `git ls-files --cached --others --exclude-standard` and hashes the +working-tree content. If a tracked edit were verified but left uncommitted, CI would check out a +tree the evidence does not describe. + +## Measured on the unfixed code + +With staging put back to `git add -A`, both new regression tests fail on `debug-dump.zip`, and the +unfixed history also carries `.specsync/change.lock`. With only the archive commit put back, the +ship test still fails, so it guards that path by itself. diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/review-attempts.json b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/review-attempts.json new file mode 100644 index 00000000..d7ac824f --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/review-attempts.json @@ -0,0 +1,21 @@ +{ + "schema_version": 1, + "reviews": [ + { + "schema_version": 2, + "change_id": "lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file", + "reviewer": "user:0xLeif", + "provenance": { + "schema_version": 1, + "provider": "github_actions_check", + "required_check": "SpecSync scoped review" + }, + "verdict": "pass", + "implementation_commit": "f50bfdd57e62925c4cebcd6cb60719907cb5ff63", + "contract_digest": "5a998fa452db13dff8d35fe8edbb0266b9173d6baa04e50a2a60831ee7c461f5", + "execution_digest": "a7e55a85c519fd09ff7008e3a379dca54f21dd1861d50473f6469220f9b67d94", + "workspace_digest": "8bd65c4f58d8b0244e9d3c692dfc843446137fca4d9a9df62594374f775be2ef", + "timestamp": 1790402756 + } + ] +} diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/review.json b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/review.json new file mode 100644 index 00000000..b4f39901 --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/review.json @@ -0,0 +1,16 @@ +{ + "schema_version": 2, + "change_id": "lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file", + "reviewer": "user:0xLeif", + "provenance": { + "schema_version": 1, + "provider": "github_actions_check", + "required_check": "SpecSync scoped review" + }, + "verdict": "pass", + "implementation_commit": "f50bfdd57e62925c4cebcd6cb60719907cb5ff63", + "contract_digest": "5a998fa452db13dff8d35fe8edbb0266b9173d6baa04e50a2a60831ee7c461f5", + "execution_digest": "a7e55a85c519fd09ff7008e3a379dca54f21dd1861d50473f6469220f9b67d94", + "workspace_digest": "8bd65c4f58d8b0244e9d3c692dfc843446137fca4d9a9df62594374f775be2ef", + "timestamp": 1790402756 +} diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/state.json b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/state.json new file mode 100644 index 00000000..24b32816 --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/state.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "workflow_version": 2, + "workflow_origin_version": 2, + "id": "lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file", + "slug": "lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file", + "title": "Lifecycle commits stage only what the change owns, never every untracked file", + "description": "Lifecycle commits stage only what the change owns, never every untracked file", + "kind": "bug_fix", + "state": "archived", + "canonical_applied": true, + "base_commit": "be3d90d8a67b31204623dd48c761e9c25770636f", + "created_at": 1790399719, + "updated_at": 1790403209, + "affected_specs": [ + "cmd_change", + "change" + ], + "affected_paths": [ + "src/commands/change.rs", + "src/change.rs", + "src/change_tests.rs", + "docs/ADOPTING.md" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "change check --commit and change ship --push never commit an untracked file outside the paths the change owns: an untracked file elsewhere in the tree stays untracked and is absent from every lifecycle commit, and each one left out is listed on standard error. Those commits still carry the change workspace, its archive package, the canonical spec and requirements files its deltas write, the sequence ledger, and every tracked edit, staged through explicit literal pathspecs rather than git add -A. The run_checked_commit doc comment states what is actually committed when the second verification fails, and that error names the materialize commit already made. Regression tests drive both commands over a tree holding an unrelated untracked file and fail if it is committed." + ], + "selected_artifacts": [ + "context", + "testing", + "tasks", + "design", + "requirements", + "docs", + "research", + "plan" + ], + "dependencies": [], + "answers": { + "architecture_risk": "yes", + "public_contract": "yes" + } +} diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/tasks.md b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/tasks.md new file mode 100644 index 00000000..af991fb6 --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/tasks.md @@ -0,0 +1,15 @@ +--- +change: lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file +artifact: tasks +--- + +# Tasks + +- [x] Add `LifecycleCommitScope` / `lifecycle_commit_scope` to the change domain, with an exact-set test. +- [x] Replace `git_commit_all` (`git add -A`) with literal-pathspec staging of tracked edits plus owned untracked paths. +- [x] Leave unmerged entries and runtime files unstaged, and list every other left-out untracked file on stderr once per run. +- [x] Correct `run_checked_commit`'s doc comment and name the materialize commit in the second-pass error. +- [x] Regression tests for `check --commit` and `ship --push`, each shown to fail with `git add -A` restored. +- [x] Unit tests for the porcelain parse, the scope boundary, and the warning. +- [x] Deltas, Public API rows, ADOPTING/AGENTS docs, and companion notes for `change` and `cmd_change`. +- [x] `fledge lanes run verify` and `fledge trust verify`. diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/testing.md b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/testing.md new file mode 100644 index 00000000..2b13b8af --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/testing.md @@ -0,0 +1,53 @@ +--- +change: lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file +artifact: testing +--- + +# Testing + +## Requirement evidence + +| Requirement | Evidence | +|---|---| +| REQ-cmd-change-017 | `check_commit_never_commits_an_unrelated_untracked_file`, `ship_push_never_commits_an_unrelated_untracked_file`, `staging_reads_each_porcelain_entry_once_and_stages_only_tracked_edits`, `a_lifecycle_scope_owns_its_subtree_and_not_a_prefix_sibling`, `the_left_out_warning_names_the_files_and_what_to_do` in `src/commands/change.rs` | +| REQ-cmd-change-012 | `lifecycle_commit_raises_a_stale_ledger_before_staging_it` (renamed from `git_commit_all_raises_a_stale_ledger_before_staging_it`; it now drives `git_commit_lifecycle`) | +| REQ-change-102 | `lifecycle_commit_scope_names_exactly_what_the_change_owns` in `src/change_tests.rs` | + +## What the regression tests do + +- `check_commit_never_commits_an_unrelated_untracked_file` runs `run_checked_commit` on an + approved change whose workspace is still untracked. The tree holds a tracked delivery edit and + three strays: `debug-dump.zip`, `.agents/scratch.md` and `exp2.sh`. It requires every stray + to be absent from all history and still untracked and unmodified. As controls, it requires the + workspace files, the workflow-v2 baseline `change new` wrote, and the tracked edit to be + committed, and nothing else except strays and runtime files to be left over. +- `ship_push_never_commits_an_unrelated_untracked_file` runs `check --commit`, `review` and + `run_ship --push` into a bare remote, with the strays present from before verification. It + requires no stray in the remote's history, the archive package in the pushed tree, the vacated + workspace absent from it, and the pushed tip to be the archive commit. + +## Discrimination + +Run against the same tree with staging put back, and the file compared byte for byte with the +fixed copy after each experiment: + +| Tree | `check_commit_…` | `ship_push_…` | +|---|---|---| +| fixed | ok | ok | +| `git_commit_lifecycle` staging with `git add -A` | **FAILED**: `debug-dump.zip was committed` | **FAILED**: `debug-dump.zip was committed` | +| only the archive commit staging with `git add -A` | n/a | **FAILED** | + +`staging_reads_each_porcelain_entry_once_and_stages_only_tracked_edits` fails when the rename +source field is not skipped, because the source name is then parsed as an entry and `git add` is +handed a truncated path. + +The first run of the check test also caught a defect in the fix itself. +`.specsync/workflow-v2-baseline.json`, which `change new` writes in a freshly adopted project, +was being left out. It is now an owned ledger, and the test asserts it is committed. + +## Suite + +`fledge lanes run verify`: fmt, `cargo clippy -- -D warnings` and `cargo check` pass; the +full `cargo test` passes 2504 unit and 437 integration tests with 0 failures; release build +passes; `specsync check --strict --require-coverage 100 --force` passes 62/62 specs with 100% +file coverage; the release-candidate test passes. `fledge trust verify` passes. diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/verification-attempts.json b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/verification-attempts.json new file mode 100644 index 00000000..f8b3b82e --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/verification-attempts.json @@ -0,0 +1,211 @@ +{ + "schema_version": 1, + "attempts": [ + { + "timestamp": 1790402128, + "commit": "bb1d80f2dfb08b3f30c4e84796ee76ab20e16d99", + "contract_digest": "5a998fa452db13dff8d35fe8edbb0266b9173d6baa04e50a2a60831ee7c461f5", + "execution_digest": "a7e55a85c519fd09ff7008e3a379dca54f21dd1861d50473f6469220f9b67d94", + "workspace_digest": "8bd65c4f58d8b0244e9d3c692dfc843446137fca4d9a9df62594374f775be2ef", + "passed": true, + "commands": [ + { + "command": "specsync check --spec change --spec cmd_change", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-change-102", + "REQ-cmd-change-012", + "REQ-cmd-change-017" + ] + }, + { + "timestamp": 1790402136, + "commit": "ed4690f8b3adff7226621c6df7fbaead6c4533bd", + "contract_digest": "5a998fa452db13dff8d35fe8edbb0266b9173d6baa04e50a2a60831ee7c461f5", + "execution_digest": "a7e55a85c519fd09ff7008e3a379dca54f21dd1861d50473f6469220f9b67d94", + "workspace_digest": "8bd65c4f58d8b0244e9d3c692dfc843446137fca4d9a9df62594374f775be2ef", + "passed": true, + "commands": [ + { + "command": "specsync check --spec change --spec cmd_change", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-change-102", + "REQ-cmd-change-012", + "REQ-cmd-change-017" + ] + }, + { + "timestamp": 1790402136, + "commit": "f50bfdd57e62925c4cebcd6cb60719907cb5ff63", + "contract_digest": "5a998fa452db13dff8d35fe8edbb0266b9173d6baa04e50a2a60831ee7c461f5", + "execution_digest": "a7e55a85c519fd09ff7008e3a379dca54f21dd1861d50473f6469220f9b67d94", + "workspace_digest": "8bd65c4f58d8b0244e9d3c692dfc843446137fca4d9a9df62594374f775be2ef", + "acceptance_input_digest": "bb609a89407bd269ea849bd5aa2e8a2a5716b1906742563717955035f70aec0d", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": "docs/ADOPTING.md", + "kind": "file", + "mode": 33188, + "payload_digest": "dc3d806d0bbdc79f285c2fa87a2b66803fe8eb482781ba83aabd576547dff7aa", + "entry_digest": "433df1df5fd65aa5d23abad660bf33fa606a686556c2d7f3936150374b212f33", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/change/change.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "e391d360a57651c087ad71eb80f88b2b9c1d8a6a2d6b8855190875d59e8094aa", + "entry_digest": "e67f2f291b33a8243e49b329cbe74b005d6ddb2c311950ea7418cb49f2773f6d", + "owners": [ + "change" + ] + }, + { + "path": "specs/change/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "20f79584b9a089122ad7a2836962d42426eeba4d22690e09927c0f7ce5ae311b", + "entry_digest": "600ce48a335f5161add09b25478952503a55485fb310bbf7fa36be9c4d754a63", + "owners": [ + "change" + ] + }, + { + "path": "specs/change/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "f95ef5b67696abc7ba4a7a9c08a9762da51c0f0bc91679f466819af53d7eb743", + "entry_digest": "104c9334361210920d6245c4dedeaf5cdc91f06237b21a4622af67b58eee9df2", + "owners": [ + "change" + ] + }, + { + "path": "specs/change/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "8033d46db356def0497f4b4567d765046464a62ad11458b524bcd4a563d8c7b8", + "entry_digest": "5ddadaa4f11cdc180cad10b9842ce95cb7cd40d4eca2074f6914d78e47138ca2", + "owners": [ + "change" + ] + }, + { + "path": "specs/change/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ae6bae948b56e21d78ffd4bccd67b2df524d22be14206b3ef79df6f917155153", + "entry_digest": "ba8d34d7711fd8389b5d44d3275e429cf7cc9e776c4857770abd31b5a52e76de", + "owners": [ + "change" + ] + }, + { + "path": "specs/cmd_change/cmd_change.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "8af582fbb7eb1fe1c1b4664966daaa5a9802aba4f7ab7cfd5ba0fbed2cc8e947", + "entry_digest": "706fb8e847b4e4d47b7fd644d828771dc6ad6f3afa11602beae116fed4bebd03", + "owners": [ + "cmd_change" + ] + }, + { + "path": "specs/cmd_change/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ca16c9c5c48825746607eb16507accc1b64d3a9ceaade68ea380120b6d75b142", + "entry_digest": "5935ae13a7d660c7c1e6044f4488be737090c5a3e93b05b51a318a72543e9448", + "owners": [ + "cmd_change" + ] + }, + { + "path": "specs/cmd_change/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "6cda2d2a9002df5fd950b4f47acda0860ed9587f392379c957cbc115928d6fb8", + "entry_digest": "01d6c619145a3d62e42849bc26d77b2a2b021b5bceaf5902ce8d6d4b1caee8f8", + "owners": [ + "cmd_change" + ] + }, + { + "path": "specs/cmd_change/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "3e36d86ee91467e78294a8acaa47ce4fb5954875ee3dccd2e210749820960a55", + "entry_digest": "de4e04e93f5312224465a12d9a7e20a70cfe730006a2fe0c04a88b3afc7adee3", + "owners": [ + "cmd_change" + ] + }, + { + "path": "specs/cmd_change/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "839b370a3088d321f391a130f05ed85a46aedec9776f27cfccabb3022d9a0200", + "entry_digest": "f3ae6de37df3e3d46e4519313c708342e3d82dc489539dc2b6194f7bfa77c1c8", + "owners": [ + "cmd_change" + ] + }, + { + "path": "src/change.rs", + "kind": "file", + "mode": 33188, + "payload_digest": "22883b8f0e3d2423348552a2bfb17d5e2c9722d897cf9fb4cfc335fa4939f98d", + "entry_digest": "e1f65e22c65ef2e1064a3a2d89bfa1fbe9fd23d824bacf9ac2cf1cb396c8dfa8", + "owners": [ + "change" + ] + }, + { + "path": "src/change_tests.rs", + "kind": "file", + "mode": 33188, + "payload_digest": "3dc36961e9d7af7c95c33948d97f487e70b802da6e85cfd0c607aa21bac1e1aa", + "entry_digest": "502c7ae5bfd9b4a2e46a30a92a230876b1da96219074aff37b9d519846424a6e", + "owners": [ + "change" + ] + }, + { + "path": "src/commands/change.rs", + "kind": "file", + "mode": 33188, + "payload_digest": "d315b5487e0b2c36aa22fa57062f3c5db2fedad303f0b7063df3f7c5c4376b22", + "entry_digest": "b3fecae0a1616f93e5880cac50bbfa989518696bd41f962b0c1c916713c1b6fc", + "owners": [ + "cmd_change" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "specsync check --spec change --spec cmd_change", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-change-102", + "REQ-cmd-change-012", + "REQ-cmd-change-017" + ] + } + ] +} diff --git a/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/verification.json b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/verification.json new file mode 100644 index 00000000..ed7dea12 --- /dev/null +++ b/.specsync/archive/changes/2026-09-26-lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file/verification.json @@ -0,0 +1,166 @@ +{ + "timestamp": 1790402136, + "commit": "f50bfdd57e62925c4cebcd6cb60719907cb5ff63", + "contract_digest": "5a998fa452db13dff8d35fe8edbb0266b9173d6baa04e50a2a60831ee7c461f5", + "execution_digest": "a7e55a85c519fd09ff7008e3a379dca54f21dd1861d50473f6469220f9b67d94", + "workspace_digest": "8bd65c4f58d8b0244e9d3c692dfc843446137fca4d9a9df62594374f775be2ef", + "acceptance_input_digest": "bb609a89407bd269ea849bd5aa2e8a2a5716b1906742563717955035f70aec0d", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": "docs/ADOPTING.md", + "kind": "file", + "mode": 33188, + "payload_digest": "dc3d806d0bbdc79f285c2fa87a2b66803fe8eb482781ba83aabd576547dff7aa", + "entry_digest": "433df1df5fd65aa5d23abad660bf33fa606a686556c2d7f3936150374b212f33", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/change/change.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "e391d360a57651c087ad71eb80f88b2b9c1d8a6a2d6b8855190875d59e8094aa", + "entry_digest": "e67f2f291b33a8243e49b329cbe74b005d6ddb2c311950ea7418cb49f2773f6d", + "owners": [ + "change" + ] + }, + { + "path": "specs/change/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "20f79584b9a089122ad7a2836962d42426eeba4d22690e09927c0f7ce5ae311b", + "entry_digest": "600ce48a335f5161add09b25478952503a55485fb310bbf7fa36be9c4d754a63", + "owners": [ + "change" + ] + }, + { + "path": "specs/change/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "f95ef5b67696abc7ba4a7a9c08a9762da51c0f0bc91679f466819af53d7eb743", + "entry_digest": "104c9334361210920d6245c4dedeaf5cdc91f06237b21a4622af67b58eee9df2", + "owners": [ + "change" + ] + }, + { + "path": "specs/change/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "8033d46db356def0497f4b4567d765046464a62ad11458b524bcd4a563d8c7b8", + "entry_digest": "5ddadaa4f11cdc180cad10b9842ce95cb7cd40d4eca2074f6914d78e47138ca2", + "owners": [ + "change" + ] + }, + { + "path": "specs/change/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ae6bae948b56e21d78ffd4bccd67b2df524d22be14206b3ef79df6f917155153", + "entry_digest": "ba8d34d7711fd8389b5d44d3275e429cf7cc9e776c4857770abd31b5a52e76de", + "owners": [ + "change" + ] + }, + { + "path": "specs/cmd_change/cmd_change.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "8af582fbb7eb1fe1c1b4664966daaa5a9802aba4f7ab7cfd5ba0fbed2cc8e947", + "entry_digest": "706fb8e847b4e4d47b7fd644d828771dc6ad6f3afa11602beae116fed4bebd03", + "owners": [ + "cmd_change" + ] + }, + { + "path": "specs/cmd_change/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ca16c9c5c48825746607eb16507accc1b64d3a9ceaade68ea380120b6d75b142", + "entry_digest": "5935ae13a7d660c7c1e6044f4488be737090c5a3e93b05b51a318a72543e9448", + "owners": [ + "cmd_change" + ] + }, + { + "path": "specs/cmd_change/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "6cda2d2a9002df5fd950b4f47acda0860ed9587f392379c957cbc115928d6fb8", + "entry_digest": "01d6c619145a3d62e42849bc26d77b2a2b021b5bceaf5902ce8d6d4b1caee8f8", + "owners": [ + "cmd_change" + ] + }, + { + "path": "specs/cmd_change/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "3e36d86ee91467e78294a8acaa47ce4fb5954875ee3dccd2e210749820960a55", + "entry_digest": "de4e04e93f5312224465a12d9a7e20a70cfe730006a2fe0c04a88b3afc7adee3", + "owners": [ + "cmd_change" + ] + }, + { + "path": "specs/cmd_change/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "839b370a3088d321f391a130f05ed85a46aedec9776f27cfccabb3022d9a0200", + "entry_digest": "f3ae6de37df3e3d46e4519313c708342e3d82dc489539dc2b6194f7bfa77c1c8", + "owners": [ + "cmd_change" + ] + }, + { + "path": "src/change.rs", + "kind": "file", + "mode": 33188, + "payload_digest": "22883b8f0e3d2423348552a2bfb17d5e2c9722d897cf9fb4cfc335fa4939f98d", + "entry_digest": "e1f65e22c65ef2e1064a3a2d89bfa1fbe9fd23d824bacf9ac2cf1cb396c8dfa8", + "owners": [ + "change" + ] + }, + { + "path": "src/change_tests.rs", + "kind": "file", + "mode": 33188, + "payload_digest": "3dc36961e9d7af7c95c33948d97f487e70b802da6e85cfd0c607aa21bac1e1aa", + "entry_digest": "502c7ae5bfd9b4a2e46a30a92a230876b1da96219074aff37b9d519846424a6e", + "owners": [ + "change" + ] + }, + { + "path": "src/commands/change.rs", + "kind": "file", + "mode": 33188, + "payload_digest": "d315b5487e0b2c36aa22fa57062f3c5db2fedad303f0b7063df3f7c5c4376b22", + "entry_digest": "b3fecae0a1616f93e5880cac50bbfa989518696bd41f962b0c1c916713c1b6fc", + "owners": [ + "cmd_change" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "specsync check --spec change --spec cmd_change", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-change-102", + "REQ-cmd-change-012", + "REQ-cmd-change-017" + ] +} diff --git a/AGENTS.md b/AGENTS.md index 5c0618b0..a2f43141 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Enforcement is **strict** — CI and pre-commit hooks will block on any spec vio | `specsync change correct-owner ` | Append audited exact owner corrections (single `--path/--spec`, or batch: repeated flags, `--manifest`, `--all-missing`) | | `specsync change finalize ` | Validate current review/evidence and move the package into the dated archive in the same PR; GitHub performs the merge | | `specsync change check [id]` | Scoped verification for one change (materialize + spec↔code sync); not archive history | -| `specsync change check [id] --commit` | Verify and commit the materialize → verify sequence CI accepts | +| `specsync change check [id] --commit` | Verify and commit the materialize → verify sequence CI accepts. Stages tracked edits and the change's own files only; `git add` new source files first | | `specsync change audit` | Project health over active workspaces and living specs (archives are history) | | `specsync migrate 5.0` | Backfill 5.0.1-era reopening digest fields idempotently (the remediation `check` prints for missing-field ledgers) | diff --git a/docs/ADOPTING.md b/docs/ADOPTING.md index 92057675..3bd87fb4 100644 --- a/docs/ADOPTING.md +++ b/docs/ADOPTING.md @@ -108,6 +108,12 @@ Run `review` and `ship` consecutively, without committing between them. The revi the scope approver. Commit and push the archive result, wait for required checks, then merge on GitHub. **Merge only after every active change on the PR is archived.** +`check --commit` and `ship --push` commit edits to files git already tracks, plus the untracked +files the change owns: its workspace and archive package, the canonical specs its deltas write, +and SpecSync's own ledgers. They never stage any other untracked file, so `git add` a new source +file yourself before `check --commit`. Every other untracked file stays out of the commit and is +listed as a warning, so a stray debug file cannot end up in a pushed commit. + ## 5. Wire CI # For a candidate instead, choose a release that has binary assets. diff --git a/specs/change/change.spec.md b/specs/change/change.spec.md index 1dec85f2..8e155774 100644 --- a/specs/change/change.spec.md +++ b/specs/change/change.spec.md @@ -1,6 +1,6 @@ --- module: change -version: 125 +version: 126 status: active files: - src/change.rs @@ -116,6 +116,7 @@ Provides the SpecSync verified spec-driven development lifecycle: one scope appr | `SddCheckReport` | Unified lifecycle errors, warnings, checked-change count, and terminal-evidence results | | `UnreadableChange` | One active-change workspace that exists on disk but could not be read, carrying its directory identity and a reason naming the offending path | | `ChangeRoster` | The active-change roster as two separate facts: the records that were read and the workspaces that could not be, so absence and unreadability cannot share a value | +| `LifecycleCommitScope` | Crate-private answer to what one change's lifecycle commit may stage: the untracked paths the lifecycle wrote or the change owns, and the runtime files (lock, transaction journal) it must neither stage nor report | **Exported Functions** @@ -156,6 +157,7 @@ Provides the SpecSync verified spec-driven development lifecycle: one scope appr | `floor_sequence_ledger_to_committed` | `root: &Path` | `Result, String>` | Raise a working-tree sequence ledger to the committed high-water mark before staging, returning the previous and adopted values so the caller can disclose the raise, or `None` when the ledger is already at or above it | | `handoff_summary` | `root, record` | `HandoffSummary` | Gather only the signals the record's state needs — never the archive-history walk for an Archived record — and classify them; the same verdict `ChangeSummary.handoff` carries | | `lesson_fold_targets` | `root, id` | `Vec` | Module context paths this change's lessons are folded into at archival; empty when the change is unreadable or owns no specs | +| `lifecycle_commit_scope` | `root, id` | `Result` | The one answer to which untracked paths a lifecycle commit for this change may stage: its workspace, its archive package, each affected spec's canonical file and companions, and the lifecycle ledgers — never its `affected_paths` prefixes; `Err` when the change cannot be loaded | | `list_changes` | `root: &Path` | `Result` | List active changes in stable ID order alongside the workspaces that could not be read; `Err` only when the changes directory itself is unreadable | | `load_change` | `root: &Path, id: &str` | `Result` | Load active or archived change state | | `load_policy` | `root: &Path` | `Option` | Load `.specsync/sdd.json`; absence leaves existing projects unenforced | @@ -486,3 +488,4 @@ Acceptance Criteria | 2026-09-09 | close-the-specsync-6-0-0-p1-release-defects-found-in-overnight-proving: Close the SpecSync 6.0.0 P1 release defects found in overnight proving | | 2026-09-09 | close-remaining-specsync-6-0-0-first-user-p1s-pre-commit-honors-config-toml-config-fail-closed-merge-git-sanitization: Close remaining SpecSync 6.0.0 first-user P1s: pre-commit honors config, TOML config fail-closed, merge git sanitization, and 5.x upgrade docs | | 2026-09-09 | drop-interpolated-next-action-from-v1-verifying-assert-messages-so-codeql-cleartext-logging-is-not-a-required-check: Drop interpolated next_action from v1 verifying assert messages so CodeQL cleartext-logging is not a required-check failure | +| 2026-09-26 | lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file: Lifecycle commits stage only what the change owns, never every untracked file | diff --git a/specs/change/context.md b/specs/change/context.md index f2cdedf4..df9ce48c 100644 --- a/specs/change/context.md +++ b/specs/change/context.md @@ -10,7 +10,7 @@ Issue #751: explicit legacy reopen now evaluates the same historical manifest re Canonical module maturity remains under `specsync lifecycle`; SDD delivery uses six separate states. `.specsync/sdd.json` is a dedicated versioned policy so existing projects remain opt-in. Human artifacts and deltas are Markdown, while state, approvals, and evidence are JSON. `change check` compares specs to code in-process and does not run the project's tests. -The committed `.specsync/change-sequence.json` ledger records the last numeric allocation ever made. Nothing ALLOCATES into it any more — identity is minted from the description as a slug — and it is retained so the marks it already carries cannot be lost: the gates read it and refuse a ledger that has fallen below what disk or the branch's own history already recorded. It is not read-only, and that is the part worth carrying: `floor_sequence_ledger_to_committed` still WRITES the file, from inside `git_commit_all`, raising a working-tree ledger that has fallen behind back to the committed high-water mark before staging. Every lifecycle commit runs it, so treating the ledger as immutable is how a change that edits it goes uncovered. The OS lock still serializes a checkout. Lifecycle checking scans active and archived records together; the repository's immutable historical sequence collisions are acknowledged only as exact sets of full IDs. +The committed `.specsync/change-sequence.json` ledger records the last numeric allocation ever made. Nothing ALLOCATES into it any more — identity is minted from the description as a slug — and it is retained so the marks it already carries cannot be lost: the gates read it and refuse a ledger that has fallen below what disk or the branch's own history already recorded. It is not read-only, and that is the part worth carrying: `floor_sequence_ledger_to_committed` still WRITES the file, from inside the command layer's lifecycle staging path (`git_commit_lifecycle`, formerly `git_commit_all`), raising a working-tree ledger that has fallen behind back to the committed high-water mark before staging. Every lifecycle commit runs it, so treating the ledger as immutable is how a change that edits it goes uncovered. The OS lock still serializes a checkout. Lifecycle checking scans active and archived records together; the repository's immutable historical sequence collisions are acknowledged only as exact sets of full IDs. Historical acceptance reconstruction treats the committed sequence ledger as evidence, not a template. When immutable collision members signed one canonical collision-owner ledger, a bounded invocation-cached history lookup reuses those exact bytes after later claims advance the current ledger. The historical candidate must explicitly name the record in its same-sequence collision; ordinary records, unavailable history, and collision acknowledgements added after acceptance keep successor-aware synthetic reconstruction. @@ -546,3 +546,15 @@ authenticate. ## Release review: claims and authentication The stored scoped-review provider declaration is format-validated metadata bound to the recorded review; it is not a live provider response or authenticated identity. Local finalization validates the claim, verdict, history, and content currency. Authenticated provenance must be enforced by separately configured hosted checks and policy verification. The CLI, canonical REQ-change-046, and Public API description now state that boundary consistently. Existing blocking, append-only, same-approver, and freshness rules are unchanged. + +## Lifecycle commit ownership + +`lifecycle_commit_scope` is the domain's answer to which untracked paths a lifecycle commit may +stage; the command layer does the staging and decides nothing about ownership. It names the +workspace, the archive package, each affected spec's canonical file and companions, and the +lifecycle ledgers, and it names the lock and transaction journal separately as runtime files that +are never committed. It deliberately leaves out `affected_paths`. Those are prefixes as broad as +`src/` or `.`, and owning every untracked file under them is the `git add -A` sweep with extra +steps. The workflow-v2 baseline is on the list because `change new` writes it in a freshly adopted +project: the first test run of the fix left it out, which would have committed a change whose +origin anchor never reached history. diff --git a/specs/change/requirements.md b/specs/change/requirements.md index c7170367..89aebb5c 100644 --- a/specs/change/requirements.md +++ b/specs/change/requirements.md @@ -1337,3 +1337,14 @@ Acceptance Criteria - The message contains `--kind bug-fix` and does not contain `--kind fix`. - `--no-spec-change` and `--path` guidance is unchanged. +### REQ-change-102 + +The change domain SHALL be the single answer to which untracked paths a lifecycle commit for one change may stage, so the command layer that stages them never decides lifecycle ownership itself. + +Acceptance Criteria +- `lifecycle_commit_scope` names the change's active workspace, its archive package once finalization has moved it there, each affected spec's canonical spec file and its canonical companions, and the lifecycle ledgers: the sequence ledger, the workflow-v2 and legacy-archive baselines, the bootstrap record, and the hash cache. +- It never names the change's `affected_paths` prefixes or an affected spec's whole directory, so an untracked file under `src/` or beside a spec is not owned by being there. +- It names the project lock and the transaction journal separately as runtime files, which a lifecycle commit neither stages nor reports. +- Canonical spec paths resolve through the same registry-aware resolver materialization writes through. +- Paths are project-relative with forward slashes, sorted and deduplicated, and a change that cannot be loaded is an error rather than an empty answer. + diff --git a/specs/change/tasks.md b/specs/change/tasks.md index f02bfad1..1974d7a6 100644 --- a/specs/change/tasks.md +++ b/specs/change/tasks.md @@ -63,3 +63,5 @@ spec: change.spec.md - [x] Let a module own paths beyond its spec's `files:` through `[modules.""] owns`, sign them under the module ahead of the reserved exact classes, judge a successor's eligibility for an exact-only predecessor entry by the module that owns the path now, read the claimants of a changed exact-only entry from the successors' declared obligations in the walk, and name the supersede alternative beside the audited reopen (2026-09-05) - [x] Clarify scoped-review claims versus separately enforced authentication without changing runtime review rules. + +- [x] Name the untracked paths a lifecycle commit may stage in the domain (`lifecycle_commit_scope`), excluding `affected_paths` prefixes and whole spec directories, and name the lock and transaction journal as never-committed runtime files. diff --git a/specs/change/testing.md b/specs/change/testing.md index 5ba53248..3402e7c8 100644 --- a/specs/change/testing.md +++ b/specs/change/testing.md @@ -75,3 +75,5 @@ Unit tests cover IDs, requirement grammar, semantic application, unsafe command - `added_requirement_already_in_living_tree_fails_delta_validation` — `## ADDED` of an existing living REQ fails `validate_delta_files` and `approve_definition`; `## MODIFIED` succeeds validation. - `REQ-change-036` with `REQ-change-020`/`REQ-change-024` (refused successors): `finalize_archives_a_v2_successor_that_supersedes_a_legacy_accepted_change` is the DISCRIMINATOR for the archive preflight dropping the package being closed — on ac796b8 it fails with the swift-algorand field message ("no accepted or archived successor change covers it"), because the successor walk handed the `working-tree-closing-evidence` anchor label to `git merge-base` and `continue`d the `Ok(false)`. Its tail proves the legacy predecessor is successor-covered before and after the archive commit, and that a later disturbance of the successor's own inputs names the successor and its reason and steers to `change status ` rather than `change reopen `. `stale_accepted_change_error_names_the_successor_rejected_for_failed_authentication` is the DISCRIMINATOR for the silent `Err(_) => continue`: a `passed: false` successor is named with the authentication reason. `stale_accepted_change_error_names_covering_successor_with_stale_evidence` is the CONTROL for the v1/v1 pair, now carrying the nested stale reason; both share the `accepted_change_with_covering_successor` fixture. The finalize discriminator also asserts `audit_project` — clean and `SuccessorCovered` for the predecessor before and after the archive commit, and the same refused-successor error as the full walk after the disturbance — because the active-only audit used to hand its active listing to the successor walk as the candidate universe and so never saw a finalized successor. - `REQ-change-050` with `REQ-change-020`/`REQ-change-024` (configured ownership): `configured_module_ownership_lets_a_v2_successor_supersede_exact_only_inputs_of_a_bootstrap_change` is the DISCRIMINATOR for the frozen non-spec tree — on 404fe4d6 `add_supersedes_obligation` refuses `auth` for `tests/auth/legacy.rs` as "not a successor-eligible signed owner"; with the feature the v2 successor adopts the bootstrap's `@exact:test` and `@exact:delivery` entries (an edited test, a deleted test, the package manifest), finalizes, and the bootstrap is successor-covered on `check_project` and `audit_project` before and after the archive commit, with the directory entry signed under the module too and a later disturbance reported through the archived successor with its reason. `supersede_refuses_an_exact_only_input_the_configuration_grants_no_module` is the NEGATIVE: a path the configuration does not grant keeps refusing, names the `owns` remedy, and persists nothing. `configured_ownership_overrides_reserved_exact_classes_for_declared_modules_only` is the unit CONTROL over `acceptance_input_owners`: declared modules only, `.specsync/` never, a mapped test still `@exact:test`. `stale_accepted_change_error_names_exact_only_input_and_audited_reopen` pins the amended exact-only message. + +- `REQ-change-102`: `lifecycle_commit_scope_names_exactly_what_the_change_owns` asserts the exact owned set (workspace, `specs/auth/auth.spec.md` and its five canonical companions, the sequence ledger, the workflow-v2 and legacy baselines, the bootstrap record, the hash cache) and the exact runtime set (lock, transaction journal). It is exact on purpose, because returning `affected_paths` or the spec directory would pass a looser check. It then moves the workspace into the archive and requires both the package and the vacated workspace to be owned, and an unknown change to be an error. diff --git a/specs/cmd_change/cmd_change.spec.md b/specs/cmd_change/cmd_change.spec.md index c304d579..882f8e34 100644 --- a/specs/cmd_change/cmd_change.spec.md +++ b/specs/cmd_change/cmd_change.spec.md @@ -1,6 +1,6 @@ --- module: cmd_change -version: 37 +version: 38 status: active files: - src/commands/change.rs @@ -150,4 +150,4 @@ Implementation SHALL add `specs/cli_args/cli_args.spec.md` to `depends_on`. Rust | 2026-09-02 | tell-agents-when-it-is-safe-to-clear-context: Tell agents when it is safe to clear context | | 2026-09-07 | make-ship-status-product-stage-completion-use-current-verification-content-consistently: Make ship-status product-stage completion use current verification content consistently | | 2026-09-10 | document-shipped-specsync-6-0-0-and-set-the-action-default-to-the-stable-release: Document shipped SpecSync 6.0.0 in ADOPTING (stable install, Trust 1.2.0, always pass Action version) | - +| 2026-09-26 | lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file: Lifecycle commits stage only what the change owns, never every untracked file | diff --git a/specs/cmd_change/context.md b/specs/cmd_change/context.md index f11ff3a8..393f5ebd 100644 --- a/specs/cmd_change/context.md +++ b/specs/cmd_change/context.md @@ -76,3 +76,16 @@ decide an open design question by accident. Readiness can decline to answer with what should happen next. Issue #745 extends the same verification-content predicate used by readiness to the product-tip stage. Commit ancestry remains diagnostic; it no longer substitutes for freshness in product-stage completion. The scoped-review currency check is unchanged, so unavailable review evidence may still prevent finalization even when product verification is current. + +Lifecycle commits used to stage with `git add -A`, and `--push` published whatever that swept up: +a private debug archive in one project, an agent's `.agents/` directory and an experiment script in +another. `git_commit_lifecycle` now stages every tracked edit plus the untracked paths +`change::lifecycle_commit_scope` names, using explicit literal pathspecs, and lists every other +untracked file on stderr. Tracked edits stay in because verification digests the working tree, so +a tracked edit that was verified but not committed would leave CI checking a different tree. +Untracked files stay out because being on disk says nothing about belonging in history. A new +source file therefore joins the delivery when its author stages it. The warning says what removing +a left-out file does to verification, because the digest covers it where it sits. `run_checked_commit`'s +doc comment used to say nothing is committed unless verification passes. That holds for the first +pass only. When the second pass fails, the materialize commit stays on the branch rather than being +rewound, and the error now names it. diff --git a/specs/cmd_change/requirements.md b/specs/cmd_change/requirements.md index f3e47f15..24724631 100644 --- a/specs/cmd_change/requirements.md +++ b/specs/cmd_change/requirements.md @@ -248,10 +248,10 @@ Acceptance Criteria ### REQ-cmd-change-012 -Commands that stage the whole worktree SHALL apply the sequence-ledger floor before staging, and SHALL NOT block the author when they do. +Lifecycle commits SHALL apply the sequence-ledger floor before staging, and SHALL NOT block the author when they do. Acceptance Criteria -- Materialize, verification-evidence and archive commits all floor the ledger before `git add -A`. +- Materialize, verification-evidence and archive commits all floor the ledger before staging. - A change whose ledger went stale while its branch sat still completes, because the author caused nothing and blocking them would punish a race they cannot observe. - The disclosure appears on standard error rather than standard output, so `--format json` output remains a single parseable document. @@ -297,3 +297,16 @@ Acceptance Criteria - Missing or malformed evidence does not become a completed product stage. - Existing scoped-review currency and finalization gates retain their behavior; this change does not decide #694's unavailable-review policy. +### REQ-cmd-change-017 + +`change check --commit` and `change ship --push` SHALL commit only the project's tracked edits and the untracked paths the change domain reports the change owns, and SHALL NOT stage any other untracked file. + +Acceptance Criteria +- Staging uses explicit literal pathspecs; no lifecycle commit runs `git add -A`. +- An untracked file outside the change's owned paths is absent from the materialize, verification-evidence and archive commits and from what `--push` publishes, and it stays untracked and unmodified in the working tree. +- The change's own untracked workspace, its archive package, the canonical spec files its deltas write, the lifecycle ledgers, and every tracked edit are still committed, so the committed tree is the tree that was verified. +- Each untracked file left out is listed once per run on standard error, bounded for a long list, with guidance to `git add` what belongs to the delivery and to move, delete or ignore the rest; after `check --commit` the guidance also says that removing one stales the recorded verification and names the command that re-records it. Standard output under `--format json` stays a single document. +- Lifecycle runtime files, the project lock and the transaction journal, are neither staged nor listed. +- Unmerged entries are not staged, so an unresolved conflict still stops the commit instead of being recorded as resolved. +- `check --commit` makes no commit unless its first verification passes. When the re-verification against the committed tree fails, the materialize commit stays on the branch, and the error names that commit and the command that resumes. + diff --git a/specs/cmd_change/tasks.md b/specs/cmd_change/tasks.md index 56fa112b..8ec7f323 100644 --- a/specs/cmd_change/tasks.md +++ b/specs/cmd_change/tasks.md @@ -19,3 +19,5 @@ spec: cmd_change.spec.md - [x] Use recorded verification content currency consistently in the product stage and readiness (#745). - [x] Add preserved-content squash and stale-content ancestor regression controls. +- [x] Stage lifecycle commits by explicit literal pathspecs (tracked edits plus the change's owned untracked paths), never `git add -A`, and list every other untracked file on stderr (REQ-cmd-change-017). +- [x] State in `run_checked_commit`'s doc comment what happens when the second verification fails, and name the materialize commit in that error. diff --git a/specs/cmd_change/testing.md b/specs/cmd_change/testing.md index cf02a257..4e5273c9 100644 --- a/specs/cmd_change/testing.md +++ b/specs/cmd_change/testing.md @@ -24,3 +24,5 @@ that correction-ledger-derived values remain confined to the JSON branch; text-o from an independent state reload. REQ-cmd-change-016: report-level regressions assert a preserved-content squash completes the product stage, stale content with ancestor evidence does not, and missing evidence does not. The first two failed against the previous implementation. Existing report/finalize agreement checks continue to enforce stale and unavailable review behavior. Run `cargo test --bin specsync commands::change::tests`. + +REQ-cmd-change-017: `check_commit_never_commits_an_unrelated_untracked_file` and `ship_push_never_commits_an_unrelated_untracked_file` drive `run_checked_commit` and `run_ship --push` (into a bare remote) over a tree holding `debug-dump.zip`, `.agents/scratch.md` and `exp2.sh`. They require every stray to be absent from all history, or from the remote's history for ship, and still untracked and unmodified. Both fail when staging is put back to `git add -A`. The ship test also fails when only the archive commit is put back, so it guards that path on its own. The controls in the same tests require the change's untracked workspace, the tracked delivery edit and the archive package to be committed, and the vacated workspace to be gone from the pushed tree. `staging_reads_each_porcelain_entry_once_and_stages_only_tracked_edits` pins the porcelain parse: a staged rename is one entry, and it fails if the rename's source field is read as an entry of its own. `a_lifecycle_scope_owns_its_subtree_and_not_a_prefix_sibling` pins the ownership boundary, and `the_left_out_warning_names_the_files_and_what_to_do` pins the warning's listing, its bound, the re-check guidance and once-per-run disclosure. REQ-cmd-change-012 is covered by `lifecycle_commit_raises_a_stale_ledger_before_staging_it`. Run `cargo test --bin specsync commands::change::tests`. diff --git a/src/change.rs b/src/change.rs index 2d02acdd..3446c5cf 100644 --- a/src/change.rs +++ b/src/change.rs @@ -6775,6 +6775,81 @@ pub(crate) fn lesson_fold_targets(root: &Path, id: &str) -> Vec { .unwrap_or_default() } +/// The hash cache `specsync check` rewrites beside the lifecycle ledgers. `init` ignores it; it +/// is named as lifecycle-owned so a project that does not ignore it still commits its own cache. +const HASH_CACHE_PATH: &str = ".specsync/hashes.json"; + +/// What a lifecycle commit for one change may stage, and what it must neither stage nor report. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LifecycleCommitScope { + /// Untracked paths the lifecycle wrote or the change owns. A lifecycle commit stages these. + pub(crate) owned: Vec, + /// Lifecycle runtime files: the project lock and an in-flight transaction journal. They are + /// never committed, and never reported as left out either, because they are nobody's delivery + /// and the project-input digest already excludes them. + pub(crate) runtime: Vec, +} + +/// Which untracked paths a lifecycle commit for this change may stage, and which runtime files it +/// must leave alone. +/// +/// `change check --commit` and `change ship --push` staged with `git add -A`, which swept every +/// untracked, non-ignored file in the project into a pushed commit: a private debug archive, an +/// agent's scratch directory, an experiment script. Tracked edits are the delivery, and the +/// command layer stages all of them. An untracked file is the lifecycle's to commit only when the +/// lifecycle wrote it or the change owns it, and this is the one answer to which files those are: +/// +/// - the active workspace. `finalize` empties it by moving it, so staging the removal of its +/// tracked files needs this path as well; +/// - the archive package, once `finalize` has moved the workspace there; +/// - each affected spec's canonical spec file and its companions, resolved through the same +/// registry-aware resolver materialization writes through, so the two cannot name different +/// files. Only those files, never the whole directory: a stray file there is not a spec; +/// - the lifecycle ledgers: the sequence ledger the staging path floors, the workflow-v2 and +/// legacy-archive baselines, the bootstrap record, and the hash cache. `change new` writes the +/// workflow-v2 baseline in a freshly adopted project, and it has to reach history with the first +/// change or every later reader of that change fails its origin check. +/// +/// The paths are project-relative with forward slashes, sorted and deduplicated. The change's +/// `affected_paths` are deliberately absent. They are prefixes as broad as `src/` or `.`, and +/// owning every untracked file under them is the sweep this exists to stop. A new source file +/// joins the delivery when its author stages it. +pub(crate) fn lifecycle_commit_scope( + root: &Path, + id: &str, +) -> Result { + let record = load_change(root, id)?; + let mut owned = BTreeSet::new(); + owned.insert(portable_project_path(root, &change_dir(root, &record.id))); + owned.insert(portable_project_path( + root, + &find_change_dir(root, &record.id)?, + )); + let specs_dir = crate::config::load_config(root).specs_dir; + for module in &record.affected_specs { + let (spec, _) = canonical_module_paths(root, &specs_dir, module)?; + owned.insert(portable_project_path(root, &spec)); + if let Some(directory) = spec.parent() { + for companion in CANONICAL_SPEC_COMPANIONS { + owned.insert(portable_project_path(root, &directory.join(companion))); + } + } + } + for ledger in [ + SEQUENCE_PATH, + WORKFLOW_V2_BASELINE_PATH, + LEGACY_BASELINE_PATH, + BOOTSTRAP_RECORD_PATH, + HASH_CACHE_PATH, + ] { + owned.insert(ledger.to_string()); + } + Ok(LifecycleCommitScope { + owned: owned.into_iter().collect(), + runtime: vec![LOCK_PATH.to_string(), TRANSACTION_PATH.to_string()], + }) +} + /// Assemble the material an agent needs to fold this change's lessons into the SPEC's context. /// /// Lessons belong in `specs//context.md`, not in the change — a per-change lessons file diff --git a/src/change_tests.rs b/src/change_tests.rs index 0b6afe2d..31a123e6 100644 --- a/src/change_tests.rs +++ b/src/change_tests.rs @@ -19041,3 +19041,80 @@ fn verifying_change_refuses_to_recreate_a_missing_attempts_ledger() { "got {error}" ); } + +// Verifies REQ-change-102. +// +// Honest label: DISCRIMINATOR for the ownership answer the lifecycle staging path trusts. The +// exact-set assertion is the point. A version that returned `affected_paths` would hand the +// staging path `src/` (or `.`) and bring back the `git add -A` sweep through the side door, and a +// version that returned the spec directory would own any stray file dropped beside a spec. +#[test] +fn lifecycle_commit_scope_names_exactly_what_the_change_owns() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + let git = |args: &[&str]| { + assert!( + Command::new("git") + .args(args) + .current_dir(root) + .status() + .unwrap() + .success(), + "git command failed: {args:?}" + ); + }; + git(&["init", "-b", "main"]); + git(&["config", "user.email", "test@example.com"]); + git(&["config", "user.name", "Test"]); + fs::write(root.join("README.md"), "base\n").unwrap(); + git(&["add", "README.md"]); + git(&["commit", "-m", "base"]); + ensure_auth_spec_owns_its_source(root); + let record = completed_current_record(root); + let workspace = format!(".specsync/changes/{}", record.id); + + let mut expected = vec![ + workspace.clone(), + SEQUENCE_PATH.to_string(), + WORKFLOW_V2_BASELINE_PATH.to_string(), + LEGACY_BASELINE_PATH.to_string(), + BOOTSTRAP_RECORD_PATH.to_string(), + HASH_CACHE_PATH.to_string(), + "specs/auth/auth.spec.md".to_string(), + ]; + expected.extend( + CANONICAL_SPEC_COMPANIONS + .iter() + .map(|companion| format!("specs/auth/{companion}")), + ); + expected.sort(); + let scope = lifecycle_commit_scope(root, &record.id).unwrap(); + assert_eq!(scope.owned, expected); + assert!( + !scope + .owned + .iter() + .any(|path| path == "src/auth.rs" || path == "src" || path == "specs/auth"), + "affected paths and spec directories are not lifecycle-owned" + ); + assert_eq!( + scope.runtime, + vec![LOCK_PATH.to_string(), TRANSACTION_PATH.to_string()], + "the lock and journal are runtime files, never owned delivery" + ); + assert!( + !scope.owned.iter().any(|path| scope.runtime.contains(path)), + "no path may be both committed and never-committed" + ); + + // Once `finalize` has moved the workspace, the package it moved into is owned as well, and + // the vacated workspace stays owned so the removal of its tracked files can be staged. + let package = format!("{ARCHIVE_PATH}/2026-01-01-{}", record.id); + fs::create_dir_all(root.join(ARCHIVE_PATH)).unwrap(); + fs::rename(change_dir(root, &record.id), root.join(&package)).unwrap(); + let archived = lifecycle_commit_scope(root, &record.id).unwrap().owned; + assert!(archived.contains(&package), "{archived:?}"); + assert!(archived.contains(&workspace), "{archived:?}"); + + assert!(lifecycle_commit_scope(root, "no-such-change").is_err()); +} diff --git a/src/commands/change.rs b/src/commands/change.rs index fa28ccde..17ef4ccb 100644 --- a/src/commands/change.rs +++ b/src/commands/change.rs @@ -1911,7 +1911,12 @@ fn run_ship( /// Commit archive package (if dirty) and push the current branch. fn ship_commit_and_push_archive(root: &Path, id: &str) -> Result { - git_commit_all(root, &format!("chore(lifecycle): archive {id}"))?; + let archive = git_commit_lifecycle( + root, + &change::lifecycle_commit_scope(root, id)?, + &format!("chore(lifecycle): archive {id}"), + )?; + disclose_left_out(&archive.left_out, &mut Vec::new(), None); run_git(root, &["push"])?; let sha = git_rev_parse(root, "HEAD").unwrap_or_else(|_| "HEAD".into()); Ok(format!("pushed archive tip {sha:.8}")) @@ -2394,7 +2399,8 @@ mod tests { /// The floor must be WIRED, not merely present (#533). /// /// `floor_sequence_ledger_to_committed` has its own unit tests, but those - /// exercise the function directly. Nothing asserted that `git_commit_all` + /// exercise the function directly. Nothing asserted that the lifecycle + /// staging path (`git_commit_lifecycle`, formerly `git_commit_all`) /// actually calls it, so deleting the call left the entire suite green /// while every lifecycle commit went back to staging a stale ledger over a /// higher committed mark — the exact regression #533 is about. @@ -2402,7 +2408,7 @@ mod tests { /// This test drives the real staging path and inspects what landed in the /// commit, so it fails if the call is removed. #[test] - fn git_commit_all_raises_a_stale_ledger_before_staging_it() { + fn lifecycle_commit_raises_a_stale_ledger_before_staging_it() { use std::process::Command; let temp = TempDir::new().expect("temp project"); let root = temp.path(); @@ -2451,12 +2457,17 @@ mod tests { write_ledger(1); std::fs::write(root.join("README.md"), "work\n").unwrap(); - git_commit_all(root, "lifecycle commit").expect("commit"); + // Nothing is owned here: the ledger is tracked, so the tracked-edit rule stages it. + let scope = change::LifecycleCommitScope { + owned: Vec::new(), + runtime: Vec::new(), + }; + git_commit_lifecycle(root, &scope, "lifecycle commit").expect("commit"); assert_eq!( committed_sequence(), 3, - "the staging path must raise the stale ledger before `git add -A`; \ + "the staging path must raise the stale ledger before staging it; \ committing 1 over a committed 3 is the #533 regression, and it is what happens \ if the floor call is removed from this function" ); @@ -2553,6 +2564,30 @@ if the floor call is removed from this function" /// honour. The caller owns the repository and the branch, because the three #743/#689 /// cases differ only in what happens to history afterwards. fn reviewed_change_fixture(root: &Path) -> String { + let id = approved_change_fixture(root); + git_in(root, &["add", "."]); + git_in(root, &["commit", "-m", "implement"]); + // `check_change`, not `verify_change`: the CLI's `change check` materializes the + // approved deltas as well as verifying, and `finalize` refuses outright on a change + // whose canonical deltas were never applied. A fixture that only verifies would make + // `finalize` fail for a reason that has nothing to do with the review — exactly the + // kind of accidental agreement these tests must not be built on. + change::check_change(root, Some(&id)).expect("check"); + git_in(root, &["add", "."]); + git_in(root, &["commit", "-m", "record verification"]); + change::record_scoped_review_with_verdict( + root, + &id, + "Independent".into(), + change::ScopedReviewVerdict::Pass, + ) + .expect("review"); + id + } + + /// Drives one change through approval into implementation and stops there, with its + /// workspace still uncommitted: the state `change check --commit` is run from. + fn approved_change_fixture(root: &Path) -> String { let record = draft_fixture(root); let id = record.id.clone(); for (question, answer) in [ @@ -2580,16 +2615,164 @@ if the floor call is removed from this function" } change::approve_definition(root, &id, Some("Reviewer".into()), None).expect("approve"); change::start_implementation(root, &id).expect("implement"); - git_in(root, &["add", "."]); - git_in(root, &["commit", "-m", "implement"]); - // `check_change`, not `verify_change`: the CLI's `change check` materializes the - // approved deltas as well as verifying, and `finalize` refuses outright on a change - // whose canonical deltas were never applied. A fixture that only verifies would make - // `finalize` fail for a reason that has nothing to do with the review — exactly the - // kind of accidental agreement these tests must not be built on. - change::check_change(root, Some(&id)).expect("check"); - git_in(root, &["add", "."]); - git_in(root, &["commit", "-m", "record verification"]); + id + } + + /// Untracked files nobody asked the lifecycle to commit, shaped like the ones `git add -A` + /// swept into pushed commits: a private debug archive, an agent's scratch directory, and an + /// experiment script at the project root. + const STRAYS: [&str; 3] = ["debug-dump.zip", ".agents/scratch.md", "exp2.sh"]; + + fn write_strays(root: &Path) { + for stray in STRAYS { + let path = root.join(stray); + fs::create_dir_all(path.parent().expect("parent")).expect("stray dir"); + fs::write(&path, format!("not for history: {stray}\n")).expect("stray"); + } + } + + /// Every path any commit reachable from any ref in `git_dir` ever touched. + fn paths_in_history(git_dir: &Path) -> Vec { + let output = std::process::Command::new("git") + .arg(format!("--git-dir={}", git_dir.display())) + .args(["log", "--all", "--name-only", "--pretty=format:"]) + .output() + .expect("git log"); + assert!(output.status.success(), "git log failed"); + String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|line| !line.is_empty()) + .map(str::to_owned) + .collect() + } + + fn worktree_status(root: &Path) -> Vec { + let output = std::process::Command::new("git") + .args(["status", "--porcelain=v1", "--untracked-files=all"]) + .current_dir(root) + .output() + .expect("git status"); + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::to_owned) + .collect() + } + + /// The strays are in no commit of `git_dir` and still sit untracked in `root`, untouched. + fn assert_strays_left_alone(root: &Path, git_dir: &Path) { + let history = paths_in_history(git_dir); + let status = worktree_status(root); + for stray in STRAYS { + assert!( + !history.iter().any(|path| path == stray), + "{stray} was committed; a lifecycle commit must never stage an untracked file the change does not own: {history:?}" + ); + assert!( + status.contains(&format!("?? {stray}")), + "{stray} must still be untracked in the working tree: {status:?}" + ); + assert_eq!( + fs::read_to_string(root.join(stray)).expect("stray still on disk"), + format!("not for history: {stray}\n") + ); + } + } + + /// Honest label: DISCRIMINATOR for the `git add -A` sweep. On the unfixed binary every + /// stray lands in the materialize commit, and this fails on the first one. + /// + /// The second half is the CONTROL that keeps the fix honest: the cheap way to pass the + /// first half is to stage nothing untracked at all, which would leave the change's own + /// workspace, deliberately left uncommitted here, out of the commit that records it. + #[test] + fn check_commit_never_commits_an_unrelated_untracked_file() { + let temp = TempDir::new().expect("temp project"); + let root = temp.path(); + git_project_fixture(root); + let id = approved_change_fixture(root); + // The delivery is a tracked edit. + fs::write(root.join("README.md"), "# fixture\n\nthe delivery\n").unwrap(); + write_strays(root); + + run_checked_commit(root, Some(&id), false, false, OutputFormat::Json) + .expect("check --commit"); + + assert_strays_left_alone(root, &root.join(".git")); + + let history = paths_in_history(&root.join(".git")); + for owned in [ + "state.json", + "change.md", + "verification.json", + "approvals.json", + ] { + let path = format!(".specsync/changes/{id}/{owned}"); + assert!( + history.contains(&path), + "the change's own untracked workspace must still be committed: {path} missing from {history:?}" + ); + } + // `change new` wrote this ledger in the freshly adopted fixture. Left out, the change + // would reach history without the origin anchor every later read of it checks. + assert!( + history.contains(&".specsync/workflow-v2-baseline.json".to_string()), + "the lifecycle ledger `change new` wrote must be committed: {history:?}" + ); + let committed_readme = std::process::Command::new("git") + .args(["show", "HEAD:README.md"]) + .current_dir(root) + .output() + .expect("git show"); + assert_eq!( + String::from_utf8_lossy(&committed_readme.stdout), + "# fixture\n\nthe delivery\n", + "the tracked delivery edit must be committed" + ); + // The fixture has no `.specsync/.gitignore`, so the project lock is untracked too. It is + // a runtime file: never committed, never reported, and allowed to stay behind. + let runtime = change::lifecycle_commit_scope(root, &id) + .expect("scope") + .runtime; + let leftovers: Vec = worktree_status(root) + .into_iter() + .filter(|line| !STRAYS.iter().any(|stray| *line == format!("?? {stray}"))) + .filter(|line| !runtime.iter().any(|path| *line == format!("?? {path}"))) + .collect(); + assert!( + leftovers.is_empty(), + "only the strays and runtime files may remain outside history: {leftovers:?}" + ); + } + + /// Honest label: DISCRIMINATOR for the archive commit `ship --push` makes, which went + /// through the same `git add -A`. It asserts on what reached the remote, because a pushed + /// commit is the one that cannot be taken back. + /// + /// CONTROL in the same test: the archive package still reaches the remote, and the active + /// workspace it moved out of is gone from the pushed tree. + #[test] + fn ship_push_never_commits_an_unrelated_untracked_file() { + let temp = TempDir::new().expect("temp project"); + let root = temp.path(); + let remote_parent = TempDir::new().expect("temp remote"); + let remote = remote_parent.path().join("origin.git"); + git_project_fixture(root); + git_in( + remote_parent.path(), + &["init", "--bare", "-b", "main", "origin.git"], + ); + git_in( + root, + &["remote", "add", "origin", remote.to_str().expect("utf-8")], + ); + git_in(root, &["push", "-u", "origin", "main"]); + + let id = approved_change_fixture(root); + // Present from before verification, as in the reports: a stray written after review + // would stale the review and block ship for an unrelated reason. + write_strays(root); + run_checked_commit(root, Some(&id), false, false, OutputFormat::Json) + .expect("check --commit"); change::record_scoped_review_with_verdict( root, &id, @@ -2597,7 +2780,136 @@ if the floor call is removed from this function" change::ScopedReviewVerdict::Pass, ) .expect("review"); - id + + run_ship(root, Some(&id), false, true, false, 1, OutputFormat::Json).expect("ship --push"); + + assert_strays_left_alone(root, &remote); + + let pushed = std::process::Command::new("git") + .arg(format!("--git-dir={}", remote.display())) + .args(["ls-tree", "-r", "--name-only", "main"]) + .output() + .expect("git ls-tree"); + let pushed = String::from_utf8_lossy(&pushed.stdout).into_owned(); + assert!( + pushed + .lines() + .any(|path| path.starts_with(".specsync/archive/changes/") + && path.ends_with(&format!("-{id}/finalization.json"))), + "the archive package must reach the remote: {pushed}" + ); + assert!( + !pushed + .lines() + .any(|path| path.starts_with(&format!(".specsync/changes/{id}/"))), + "the archived workspace must be gone from the pushed tree: {pushed}" + ); + let subject = std::process::Command::new("git") + .arg(format!("--git-dir={}", remote.display())) + .args(["log", "-1", "--pretty=%s", "main"]) + .output() + .expect("git log"); + assert_eq!( + String::from_utf8_lossy(&subject.stdout).trim(), + format!("chore(lifecycle): archive {id}") + ); + } + + /// Porcelain `-z` writes a rename as two fields, destination then source. Reading the source + /// as an entry of its own would take the first two bytes of a filename as status codes. A + /// staged rename, a tracked deletion, a tracked edit and a stray are each classified once: + /// the edit and the deletion are staged, the stray is reported and left untracked. + #[test] + fn staging_reads_each_porcelain_entry_once_and_stages_only_tracked_edits() { + let temp = TempDir::new().expect("temp project"); + let root = temp.path(); + git_in(root, &["init", "-b", "main"]); + git_in(root, &["config", "user.email", "t@example.com"]); + git_in(root, &["config", "user.name", "T"]); + for name in ["moved.txt", "gone.txt", "edited.txt"] { + fs::write(root.join(name), format!("{name}\n")).unwrap(); + } + git_in(root, &["add", "."]); + git_in(root, &["commit", "-m", "base"]); + git_in(root, &["mv", "moved.txt", "renamed.txt"]); + fs::remove_file(root.join("gone.txt")).unwrap(); + fs::write(root.join("edited.txt"), "edited\n").unwrap(); + fs::write(root.join("exp2.sh"), "echo stray\n").unwrap(); + + let scope = change::LifecycleCommitScope { + owned: Vec::new(), + runtime: Vec::new(), + }; + let left_out = stage_lifecycle_paths(root, &scope).expect("stage"); + + assert_eq!(left_out, vec!["exp2.sh".to_string()]); + let staged = worktree_status(root); + for expected in [ + "R moved.txt -> renamed.txt", + "D gone.txt", + "M edited.txt", + "?? exp2.sh", + ] { + assert!( + staged.contains(&expected.to_string()), + "missing `{expected}`: {staged:?}" + ); + } + } + + /// A scope owns itself and what lies beneath it, never a sibling sharing its prefix: the + /// workspace of `add-auth` must not own the workspace of `add-auth-v2`. + #[test] + fn a_lifecycle_scope_owns_its_subtree_and_not_a_prefix_sibling() { + let workspace = ".specsync/changes/add-auth"; + assert!(path_is_within(workspace, workspace)); + assert!(path_is_within( + ".specsync/changes/add-auth/state.json", + workspace + )); + assert!(path_is_within( + ".specsync/changes/add-auth/deltas/auth.md", + &format!("{workspace}/") + )); + assert!(!path_is_within( + ".specsync/changes/add-auth-v2/state.json", + workspace + )); + assert!(!path_is_within(".specsync/changes", workspace)); + assert!(!path_is_within("exp2.sh", workspace)); + } + + /// The warning names each left-out file, bounds a long list, and tells the author what + /// removing one does to verification only when there is verification to stale. + #[test] + fn the_left_out_warning_names_the_files_and_what_to_do() { + let few: Vec = STRAYS.iter().map(|stray| stray.to_string()).collect(); + let few_refs: Vec<&String> = few.iter().collect(); + let checked = left_out_warning(&few_refs, Some("add-auth")); + for stray in STRAYS { + assert!(checked.contains(&format!("\n {stray}")), "{checked}"); + } + assert!(checked.contains("git add"), "{checked}"); + assert!( + checked.contains("specsync change check add-auth --commit"), + "{checked}" + ); + let archived = left_out_warning(&few_refs, None); + assert!(!archived.contains("change check"), "{archived}"); + + let many: Vec = (0..LEFT_OUT_LISTING_LIMIT + 5) + .map(|index| format!("scratch/{index}.log")) + .collect(); + let many_refs: Vec<&String> = many.iter().collect(); + let bounded = left_out_warning(&many_refs, None); + assert!(bounded.contains("… and 5 more"), "{bounded}"); + assert!(!bounded.contains(&format!("scratch/{}.log", LEFT_OUT_LISTING_LIMIT))); + + // A second commit in the same run does not repeat what the first already named. + let mut disclosed = few.clone(); + let before = disclosed.len(); + disclose_left_out(&few, &mut disclosed, None); + assert_eq!(disclosed.len(), before); } /// Honest label: CONTROL for the two tests below, and it passes on the unfixed binary @@ -3151,8 +3463,14 @@ if the floor call is removed from this function" /// /// SpecSync still does not commit by default; this runs only under `--commit`. /// -/// Nothing is committed unless verification passes: a half-committed lifecycle is -/// worse than none. +/// Nothing is committed unless the first verification passes. The second pass re-verifies the +/// same content against the commit the first one made. If it fails anyway, that materialize +/// commit stays on the branch and is not rewound, and the error names it along with the command +/// that resumes. Moving a branch tip back on the author's behalf is worse than leaving a commit +/// they can see and finish. +/// +/// Each commit stages the change's own paths and the project's tracked edits, never other +/// untracked files; see [`git_commit_lifecycle`]. fn run_checked_commit( root: &std::path::Path, id: Option<&str>, @@ -3201,17 +3519,37 @@ fn run_checked_commit( } }; - git_commit_all(root, &format!("chore(lifecycle): materialize {resolved}"))?; - say("Committed the materialized spec."); + let mut disclosed = Vec::new(); + let materialized = git_commit_lifecycle( + root, + &change::lifecycle_commit_scope(root, &resolved)?, + &format!("chore(lifecycle): materialize {resolved}"), + )?; + disclose_left_out(&materialized.left_out, &mut disclosed, Some(&resolved)); + say(if materialized.committed { + "Committed the materialized spec." + } else { + "Nothing new to commit for the materialized spec." + }); // Second pass: re-anchor against the committed tree. Evidence files live under // `.specsync/changes/`, which is excluded from the project-input digest, so // committing them cannot stale this result. - verify("Checking (2/2): re-verifying against the committed tree…")?; - git_commit_all( + if let Err(error) = verify("Checking (2/2): re-verifying against the committed tree…") { + if !materialized.committed { + return Err(error); + } + let head = git_rev_parse(root, "HEAD").unwrap_or_else(|_| "HEAD".into()); + return Err(format!( + "{error}\nnote: the first pass already committed `chore(lifecycle): materialize {resolved}` as {head:.8} and it was left in place; fix the failure, then re-run `specsync change check {resolved} --commit`" + )); + } + let recorded = git_commit_lifecycle( root, + &change::lifecycle_commit_scope(root, &resolved)?, &format!("chore(lifecycle): record {resolved} verification"), )?; + disclose_left_out(&recorded.left_out, &mut disclosed, Some(&resolved)); say("Committed the verification evidence."); if push { @@ -3238,13 +3576,29 @@ fn run_git(root: &std::path::Path, args: &[&str]) -> Result<(), String> { Ok(()) } -/// Stage everything and commit, treating "nothing to commit" as success so the -/// sequence stays idempotent when a pass produced no change. -fn git_commit_all(root: &std::path::Path, message: &str) -> Result<(), String> { - // Every lifecycle commit stages `-A`, so every one of them can commit a - // sequence ledger that went stale while the branch sat. Flooring here rather - // than at the single call site named in the report covers all three, which is - // the difference between fixing this and fixing where it was noticed. +/// What one lifecycle commit did. +struct LifecycleCommit { + /// False when nothing was staged, so no commit was made. + committed: bool, + /// Untracked files deliberately left out, as project-relative paths. + left_out: Vec, +} + +/// Stage what a lifecycle commit owns and commit it, treating "nothing to commit" as success so +/// the sequence stays idempotent when a pass produced no change. +/// +/// `scope` is [`change::lifecycle_commit_scope`] for the change being committed. This used to be +/// `git add -A`, which put every untracked, non-ignored file in the project into a commit that +/// `--push` then published. See [`stage_lifecycle_paths`] for what is staged now. +fn git_commit_lifecycle( + root: &Path, + scope: &change::LifecycleCommitScope, + message: &str, +) -> Result { + // Every lifecycle commit can commit a sequence ledger that went stale while + // the branch sat. Flooring here rather than at the single call site named in + // the report covers all three, which is the difference between fixing this + // and fixing where it was noticed. // Reported on stderr rather than through the caller's `say`: this is a state // correction, not progress chatter, so it must survive `--quiet`, and it must // stay off stdout where a `--format json` payload is being written. @@ -3254,16 +3608,193 @@ fn git_commit_all(root: &std::path::Path, message: &str) -> Result<(), String> { a ledger written before the branch caught up would have committed a lower high-water mark" ); } - run_git(root, &["add", "-A"])?; + let left_out = stage_lifecycle_paths(root, scope)?; let status = std::process::Command::new("git") .args(["diff", "--cached", "--quiet"]) .current_dir(root) .status() .map_err(|error| format!("failed to inspect staged changes: {error}"))?; if status.success() { - return Ok(()); + return Ok(LifecycleCommit { + committed: false, + left_out, + }); + } + run_git(root, &["commit", "-m", message])?; + Ok(LifecycleCommit { + committed: true, + left_out, + }) +} + +/// How many paths go to one `git add`, well inside every platform's argument limit. +const STAGE_BATCH_PATHS: usize = 200; + +/// Stage the project's tracked edits and the untracked files under `scope.owned`, by explicit +/// literal pathspecs. Returns every other untracked file, which is left unstaged, except the +/// lifecycle's own runtime files, which are neither staged nor returned. +/// +/// A tracked edit is part of the delivery. Verification digests the working tree, so a tracked +/// edit left unstaged would be verified and never committed. An untracked file is different: it +/// may be a private debug archive, an agent's scratch directory or an experiment script, and +/// nothing about it being on disk says it belongs in history. Of those, only the ones the +/// lifecycle wrote or the change owns are staged. The rest are the author's to stage. +/// +/// The index is not reset. Whatever the author already staged is committed with the rest, as it +/// always was. Unmerged entries are not staged, so an unresolved conflict still stops the commit +/// instead of being recorded as resolved. +fn stage_lifecycle_paths( + root: &Path, + scope: &change::LifecycleCommitScope, +) -> Result, String> { + // Porcelain paths are relative to the repository top-level, and `git add` resolves them + // against the working directory. Both are brought to project-relative form so that a project + // nested inside a larger repository stages its own paths and only those. + let prefix = git_output_text(root, &["rev-parse", "--show-prefix"])? + .trim_end_matches(['\n', '\r']) + .to_string(); + let output = std::process::Command::new("git") + .args([ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--", + ".", + ]) + .current_dir(root) + .output() + .map_err(|error| format!("failed to run git status: {error}"))?; + if !output.status.success() { + return Err(format!( + "git status failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let mut stage = Vec::new(); + let mut left_out = Vec::new(); + let mut fields = output.stdout.split(|byte| *byte == 0); + while let Some(entry) = fields.next() { + if entry.len() < 4 { + continue; + } + let (index, worktree) = (entry[0], entry[1]); + // A rename or copy, in either column, carries its source as the next field. The source + // is already recorded as removed, so only the destination is acted on. + if matches!(index, b'R' | b'C') || matches!(worktree, b'R' | b'C') { + fields.next(); + } + let path = String::from_utf8_lossy(&entry[3..]).into_owned(); + let Some(path) = path.strip_prefix(prefix.as_str()).map(str::to_owned) else { + continue; + }; + let unmerged = index == b'U' + || worktree == b'U' + || (index == b'A' && worktree == b'A') + || (index == b'D' && worktree == b'D'); + if unmerged { + continue; + } + if index == b'?' { + if scope.owned.iter().any(|owned| path_is_within(&path, owned)) { + stage.push(path); + } else if !scope + .runtime + .iter() + .any(|runtime| path_is_within(&path, runtime)) + { + left_out.push(path); + } + } else if worktree != b' ' { + stage.push(path); + } + } + + for batch in stage.chunks(STAGE_BATCH_PATHS) { + let mut args = vec!["--literal-pathspecs", "add", "--"]; + args.extend(batch.iter().map(String::as_str)); + run_git(root, &args)?; + } + left_out.sort(); + Ok(left_out) +} + +/// True when `path` is `scope` itself or lies beneath it. +fn path_is_within(path: &str, scope: &str) -> bool { + let scope = scope.trim_end_matches('/'); + path == scope + || path + .strip_prefix(scope) + .is_some_and(|rest| rest.starts_with('/')) +} + +fn git_output_text(root: &Path, args: &[&str]) -> Result { + let output = std::process::Command::new("git") + .args(args) + .current_dir(root) + .output() + .map_err(|error| format!("failed to run git {}: {error}", args.join(" ")))?; + if !output.status.success() { + return Err(format!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// How many left-out paths a warning names before it summarizes the rest. +const LEFT_OUT_LISTING_LIMIT: usize = 20; + +/// Name, on stderr, the untracked files a lifecycle commit left out. `disclosed` carries what an +/// earlier commit of the same run already named, so the second commit does not repeat it. +/// +/// Stderr, like the ledger note: it must survive `--quiet` and keep `--format json` stdout a +/// single document. `verified_change` is set when the files sit under recorded verification, which +/// digests the working tree including them. +fn disclose_left_out( + left_out: &[String], + disclosed: &mut Vec, + verified_change: Option<&str>, +) { + let fresh: Vec<&String> = left_out + .iter() + .filter(|path| !disclosed.contains(path)) + .collect(); + if fresh.is_empty() { + return; + } + eprintln!("{}", left_out_warning(&fresh, verified_change)); + disclosed.extend(fresh.into_iter().cloned()); +} + +fn left_out_warning(paths: &[&String], verified_change: Option<&str>) -> String { + let mut out = format!( + "warning: left {} untracked file(s) out of this lifecycle commit. It commits only the change's \ + own workspace, its specs, the lifecycle ledgers and tracked edits:", + paths.len() + ); + for path in paths.iter().take(LEFT_OUT_LISTING_LIMIT) { + out.push_str(&format!("\n {path}")); + } + if paths.len() > LEFT_OUT_LISTING_LIMIT { + out.push_str(&format!( + "\n … and {} more", + paths.len() - LEFT_OUT_LISTING_LIMIT + )); + } + out.push_str( + "\n `git add` and commit any that belong to this delivery. Move, delete, or .gitignore the rest.", + ); + if let Some(id) = verified_change { + out.push_str(&format!( + "\n The recorded verification digests them where they sit. Committing one keeps it current; \ + removing one stales it, so re-run `specsync change check {id} --commit` afterwards." + )); } - run_git(root, &["commit", "-m", message]) + out } #[cfg(test)]