Skip to content

feat(cli): add export-patch command to export local spec edits as upstream patches#2162

Open
edenfunf wants to merge 4 commits into
microsoft:mainfrom
edenfunf:feat/2118-export-patch
Open

feat(cli): add export-patch command to export local spec edits as upstream patches#2162
edenfunf wants to merge 4 commits into
microsoft:mainfrom
edenfunf:feat/2118-export-patch

Conversation

@edenfunf

Copy link
Copy Markdown
Contributor

Closes #2118

What

New apm export-patch command: exports local edits to APM-managed files as unified diffs against the packages that deployed them -- one git apply-ready .patch file per package, with the locked base (resolved commit for git deps, version/URL for registry deps) recorded in the patch header.

$ apm export-patch
[>] Replaying install (cache-only)...
[+] Replayed 2 package(s)
[i] .github/instructions/design-standards.instructions.md ->
    microsoft/apm-sample-package:.apm/instructions/design-standards.instructions.md
[i] wrote apm-patches\microsoft-apm-sample-package.patch

How it works

  • Reuses the drift replay (install/drift.py) read-only: replay the locked install into a scratch tree, diff against the project, and take the modified findings.
  • Reverse mapping is content-addressed: a finding is exportable iff the replayed content digest-matches exactly one file in the owning package's source tree and that source is byte-identical to it (not merely normalized-equal), which guarantees the emitted diff applies to the raw on-disk source.
  • Everything that cannot be exported honestly says why: format-transformed deployments, CRLF/BOM-bearing sources, ambiguous matches, conflicting edits across multiple deployed copies, local-path deps, project-local content, binary content, cache misses.
  • Local deletions (unintegrated) are never exported -- deleting a deployed copy does not imply upstream removal intent.

Scope notes

  • Core logic lives in commands/export_patch.py: it consumes replay/diff APIs read-only and adds no install/lockfile semantics, so no normative critical path is touched (Mode B detector is clean).
  • The diff phase resolves targets with apm.yml's explicit target:, matching the replay's own resolution. _check_drift in policy/ci_checks.py has the same auto-detect-only pattern; left untouched here to keep scope contained -- can file a follow-up.

Testing

  • 34 unit tests covering reverse mapping, applicability gating, skip reasons, diff shape (git line model, no-newline markers), filename collision handling, and header sanitization.
  • 4 integration tests including a full closed loop: edit a deployed file -> apm export-patch -> git apply in the package source -> update the snapshot -> the same command reports nothing left to export.
  • Verified end-to-end against a real install of microsoft/apm-sample-package: exported patch applies cleanly with git apply in a fresh clone at the recorded base commit.

Docs page, changelog entry, and the apm-guide command reference are included.

edenfunf added 2 commits July 11, 2026 22:40
…tream patches (microsoft#2118)

Replays the locked install (same machinery as the audit drift check)
and re-expresses every locally modified managed file as a unified diff
against the package source file that deployed it, one .patch file per
package, applicable with 'git apply' at the base recorded in the patch
header (resolved commit for git deps, version/URL for registry deps).

Reverse mapping is content-addressed: a modified finding is exportable
iff the replayed content is byte-identical (after drift normalization)
to exactly one file in the owning package's source tree. Transformed
deployments (rule-dir frontmatter rewrites, compiled or aggregated
outputs, resolved links), ambiguous matches, local path deps, and
project-local content are skipped with an explicit reason instead of
producing a patch that would not apply.

The command consumes the replay/diff APIs read-only and never mutates
the project tree, cache, or lockfile; patch files are the only output.
Local deletions (unintegrated findings) are never exported since they
do not imply upstream removal intent.

Covered by unit tests for mapping/skip/diff semantics and an e2e
closed loop: edit -> export -> git apply in the package source ->
updated snapshot -> nothing left to export.
Address correctness and robustness gaps found in review of the initial
export-patch implementation:

- Gate exports on raw-bytes cleanliness of the matched source: a CRLF-,
  BOM-, or build-id-bearing source digest-matches in normalized space
  but would reject every hunk at git-apply time. Skipped with an
  accurate reason instead of exporting an unappliable patch.
- Disambiguate colliding patch filenames with a digest suffix and use
  an ASCII-only sanitizer (isalnum accepted Unicode) plus Windows
  reserved-name handling; previously one package's patch could silently
  overwrite another's.
- Deduplicate edits when one source file is deployed to several
  targets: identical edits export as a single diff, divergent edits are
  reported as a conflict instead of emitting clashing hunks.
- Resolve diff-phase targets with apm.yml's explicit target, matching
  the replay's own resolution; auto-detection alone silently hid
  findings for declared targets whose root directory does not exist.
- Split diff lines on newline only: str.splitlines also breaks on bare
  CR / form feed / U+2028 and desyncs hunk counts from git's line model.
- Sanitize lockfile-sourced header fields; an embedded newline could
  break out of the comment block and inject applyable diff content.
- Add an outer exception net (exit 1 with a message instead of a
  traceback), reject --out paths inside apm_modules/, distinguish an
  unparsable lockfile from a missing one, and short-circuit all-local
  lockfiles before the replay.
- Exclude .git/__pycache__/symlinks from the reverse-mapping index and
  track unindexed files (size cap, read errors) so skip reasons never
  misreport them as transformed; write patch files atomically.

Every fix carries a regression test (17 new). Also adds the changelog
entry and syncs the apm-guide command reference and the docs page.
Copilot AI review requested due to automatic review settings July 11, 2026 15:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new apm export-patch CLI command that turns local edits to APM-managed deployed files into git apply-compatible patch files against the originating package sources, using the drift replay/diff machinery as a read-only signal.

Changes:

  • Implement export-patch command + core reverse-mapping/diff generation logic with per-package patch output.
  • Add unit and integration coverage for patch generation, edge cases, and a closed-loop git apply convergence test.
  • Document the new command (docs site + apm-guide command reference) and add a changelog entry.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/apm_cli/commands/export_patch.py New command implementation: replay/diff integration, reverse mapping, patch/header generation, and patch file output.
src/apm_cli/cli.py Wires export-patch into the root CLI.
tests/unit/commands/test_export_patch.py Unit tests for filename sanitization, diff shape, reverse mapping, skip reasons, and CLI error handling.
tests/integration/test_export_patch_e2e.py End-to-end closed-loop test exercising export → git apply → convergence.
docs/src/content/docs/reference/cli/export-patch.md New CLI reference page for apm export-patch.
packages/apm-guide/.apm/skills/apm-usage/commands.md Updates the apm-guide command list to include export-patch.
CHANGELOG.md Adds an Unreleased entry for the new command.

Comment thread tests/unit/commands/test_export_patch.py
Comment thread src/apm_cli/commands/export_patch.py
Comment thread src/apm_cli/commands/export_patch.py
Comment thread CHANGELOG.md Outdated
edenfunf and others added 2 commits July 11, 2026 23:34
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Private-registry and authenticated-source URLs can embed credentials
(user:token@host); patch files are made to be shared, so source and
resolved-URL header fields now pass through a shared _strip_userinfo
helper before being written. Query-string '@' and plain owner/repo
sources are unaffected (covered by regression tests).

Also appends the PR number to the changelog entry per repo convention.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Command to export local spec edit back to package as patch

2 participants