diff --git a/.gitattributes b/.gitattributes index 7e817d69c..6f715fa32 100755 --- a/.gitattributes +++ b/.gitattributes @@ -1,11 +1,15 @@ -* text=auto -/.github/ export-ignore -/.vscode/ export-ignore -/docs/ export-ignore -/tests/ export-ignore -/.agents/agents/ export-ignore -/.gitattributes export-ignore -/.gitmodules export-ignore -/AGENTS.md export-ignore -/context7.json export-ignore -/README.md export-ignore +* text=auto +/.github/ export-ignore +/.vscode/ export-ignore +/docs/ export-ignore +/tests/ export-ignore +/.gitattributes export-ignore +/.gitmodules export-ignore +/.phpunit.result.cache export-ignore +/AGENTS.md export-ignore +/CODE_OF_CONDUCT.md export-ignore +/context7.json export-ignore +/CONTRIBUTING.md export-ignore +/README.md export-ignore +/SECURITY.md export-ignore +/SUPPORT.md export-ignore diff --git a/.github/wiki b/.github/wiki index 79fb0c2fb..0e0484634 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 79fb0c2fb28fca4e9d769e5e13d42df7bd876952 +Subproject commit 0e0484634c2fe11cdb158f61755db93295e31f5d diff --git a/CHANGELOG.md b/CHANGELOG.md index 968d04dcf..68bcfe463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Auto-create and push minimal changelog entries for same-repository Dependabot pull requests before changelog validation reruns (#186) - Resolve Dependabot changelog fallback state from the actual PR head branch and report `already-present`, `auto-created`, or `missing` in the workflow summary so rebased PRs cannot pass on inherited `Unreleased` entries alone (#191) +### Fixed + +- Keep packaged `.agents` payloads exportable and synchronize packaged skills and agents with repository-relative symlink targets so consumer repositories no longer receive broken absolute machine paths (#188) + ## [1.20.0] - 2026-04-23 ### Changed diff --git a/docs/commands/agents.rst b/docs/commands/agents.rst index a63b5a784..860a0b794 100644 --- a/docs/commands/agents.rst +++ b/docs/commands/agents.rst @@ -7,7 +7,8 @@ Description ----------- The ``agents`` command synchronizes packaged project-agent prompts into the -consumer repository's ``.agents/agents`` directory using symlinks. +consumer repository's ``.agents/agents`` directory using repository-relative +symlinks. Usage ----- @@ -54,7 +55,7 @@ Behavior - Verifies the packaged ``.agents/agents`` directory before doing any work. - Creates the consumer ``.agents/agents`` directory when missing. -- Creates missing symlinks to packaged project agents. +- Creates missing repository-relative symlinks to packaged project agents. - Repairs broken symlinks. - Preserves an existing non-symlink directory instead of overwriting it. - Reuses the same generic packaged-directory synchronizer as ``skills`` so diff --git a/docs/commands/skills.rst b/docs/commands/skills.rst index 11dd18b49..af610a43a 100644 --- a/docs/commands/skills.rst +++ b/docs/commands/skills.rst @@ -7,7 +7,7 @@ Description ----------- The ``skills`` command synchronizes packaged agent skills into the consumer -repository's ``.agents/skills`` directory using symlinks. +repository's ``.agents/skills`` directory using repository-relative symlinks. Usage ----- @@ -54,6 +54,6 @@ Behavior - Verifies the packaged ``.agents/skills`` directory before doing any work. - Creates the consumer ``.agents/skills`` directory when missing. -- Creates missing symlinks to packaged skills. +- Creates missing repository-relative symlinks to packaged skills. - Repairs broken symlinks. - Preserves an existing non-symlink directory instead of overwriting it. diff --git a/docs/usage/syncing-packaged-agents.rst b/docs/usage/syncing-packaged-agents.rst index 32671117d..de0d1b8bf 100644 --- a/docs/usage/syncing-packaged-agents.rst +++ b/docs/usage/syncing-packaged-agents.rst @@ -11,7 +11,7 @@ Why This Command Exists Fast Forward libraries can share role-based project agents without copying prompt files into every consumer repository. The packaged agent directories live in this repository, while consumer repositories receive lightweight -symlinks that point back to the packaged source. +repository-relative symlinks that point back to the packaged source. That approach keeps upgrades simple: @@ -38,7 +38,8 @@ What the Command Does * - ``.agents/agents`` is missing - Creates the directory in the consumer repository. * - A packaged agent is missing locally - - Creates a symlink that points to the packaged agent directory. + - Creates a repository-relative symlink that points to the packaged + agent directory. * - A valid symlink already exists - Leaves the link unchanged. * - A symlink is broken diff --git a/docs/usage/syncing-packaged-skills.rst b/docs/usage/syncing-packaged-skills.rst index ba76e0c85..aba19c58d 100644 --- a/docs/usage/syncing-packaged-skills.rst +++ b/docs/usage/syncing-packaged-skills.rst @@ -10,8 +10,8 @@ Why This Command Exists Fast Forward libraries can share agent skills without copying them into every consumer repository. The packaged skill directories live in this repository, -while consumer repositories receive lightweight symlinks that point back to the -packaged source. +while consumer repositories receive lightweight repository-relative symlinks +that point back to the packaged source. That approach keeps upgrades simple: @@ -38,7 +38,8 @@ What the Command Does * - ``.agents/skills`` is missing - Creates the directory in the consumer repository. * - A packaged skill is missing locally - - Creates a symlink that points to the packaged skill directory. + - Creates a repository-relative symlink that points to the packaged skill + directory. * - A valid symlink already exists - Leaves the link unchanged. * - A symlink is broken diff --git a/src/GitAttributes/ExportIgnoreFilter.php b/src/GitAttributes/ExportIgnoreFilter.php index 7fa0dace1..28147ec4a 100644 --- a/src/GitAttributes/ExportIgnoreFilter.php +++ b/src/GitAttributes/ExportIgnoreFilter.php @@ -40,7 +40,7 @@ final class ExportIgnoreFilter implements ExportIgnoreFilterInterface */ public function filter(array $candidates, array $keepInExportPaths): array { - $keptPathLookup = []; + $keptPaths = []; foreach ($keepInExportPaths as $path) { $normalizedPath = $this->normalizePath($path); @@ -49,15 +49,30 @@ public function filter(array $candidates, array $keepInExportPaths): array continue; } - $keptPathLookup[$normalizedPath] = true; + $keptPaths[] = $normalizedPath; } return array_values(array_filter( $candidates, - fn(string $candidate): bool => ! isset($keptPathLookup[$this->normalizePath($candidate)]) + fn(string $candidate): bool => ! $this->isKeptPath($this->normalizePath($candidate), $keptPaths) )); } + /** + * @param string $candidate + * @param list $keptPaths + */ + private function isKeptPath(string $candidate, array $keptPaths): bool + { + foreach ($keptPaths as $keptPath) { + if ($candidate === $keptPath || str_starts_with($candidate . '/', $keptPath . '/')) { + return true; + } + } + + return false; + } + /** * Normalizes a configured path for stable matching. * diff --git a/src/GitAttributes/Merger.php b/src/GitAttributes/Merger.php index c04f87978..379f0ec88 100644 --- a/src/GitAttributes/Merger.php +++ b/src/GitAttributes/Merger.php @@ -77,7 +77,7 @@ public function merge(string $existingContent, array $exportIgnoreEntries, array } $pathKey = $this->normalizePathKey($pathSpec); - if (isset($keptExportLookup[$pathKey])) { + if ($this->isKeptPath($pathKey, $keptExportLookup)) { continue; } @@ -96,7 +96,7 @@ public function merge(string $existingContent, array $exportIgnoreEntries, array $trimmedEntry = trim($entry); $pathKey = $this->normalizePathKey($trimmedEntry); - if (isset($keptExportLookup[$pathKey])) { + if ($this->isKeptPath($pathKey, $keptExportLookup)) { continue; } @@ -188,6 +188,21 @@ private function keepInExportLookup(array $keepInExportPaths): array return $lookup; } + /** + * @param string $pathKey + * @param array $keptExportLookup + */ + private function isKeptPath(string $pathKey, array $keptExportLookup): bool + { + foreach ($keptExportLookup as $keptPath => $_) { + if ($pathKey === $keptPath || str_starts_with($pathKey . '/', $keptPath . '/')) { + return true; + } + } + + return false; + } + /** * Builds a lookup table of generated directory candidates. * diff --git a/src/GitAttributes/Writer.php b/src/GitAttributes/Writer.php index cced2ae55..555e2f956 100644 --- a/src/GitAttributes/Writer.php +++ b/src/GitAttributes/Writer.php @@ -116,6 +116,10 @@ private function format(string $content): string ]; } + if ([] !== $rows && 'raw' === $rows[array_key_last($rows)]['type'] && '' === $rows[array_key_last($rows)]['line']) { + array_pop($rows); + } + $formattedLines = []; foreach ($rows as $row) { diff --git a/src/Sync/PackagedDirectorySynchronizer.php b/src/Sync/PackagedDirectorySynchronizer.php index 6be7eabb1..5e63726cd 100644 --- a/src/Sync/PackagedDirectorySynchronizer.php +++ b/src/Sync/PackagedDirectorySynchronizer.php @@ -136,8 +136,10 @@ private function createNewLink( string $sourcePath, SynchronizeResult $result, ): void { - $this->filesystem->symlink($sourcePath, $targetLink); - $this->logger->info('Created link: ' . $entryName . ' -> ' . $sourcePath); + $relativeSourcePath = $this->filesystem->makePathRelative($sourcePath, $this->filesystem->dirname($targetLink)); + + $this->filesystem->symlink($relativeSourcePath, $targetLink); + $this->logger->info('Created link: ' . $entryName . ' -> ' . $relativeSourcePath); $result->addCreatedLink($entryName); } diff --git a/tests/GitAttributes/ExportIgnoreFilterTest.php b/tests/GitAttributes/ExportIgnoreFilterTest.php index 2a8987909..7b388a68f 100644 --- a/tests/GitAttributes/ExportIgnoreFilterTest.php +++ b/tests/GitAttributes/ExportIgnoreFilterTest.php @@ -55,4 +55,20 @@ public function filterWillPreserveOriginalCandidateOrder(): void self::assertSame(['/docs/', '/.github/'], $result); } + + /** + * @return void + */ + #[Test] + public function filterWillKeepNestedCandidatesWhenParentPathIsConfigured(): void + { + $filter = new ExportIgnoreFilter(); + + $result = $filter->filter( + ['/.agents/agents/', '/.agents/skills/', '/tests/'], + ['/.agents/'], + ); + + self::assertSame(['/tests/'], $result); + } } diff --git a/tests/GitAttributes/MergerTest.php b/tests/GitAttributes/MergerTest.php index e9468b60e..8a6e108eb 100644 --- a/tests/GitAttributes/MergerTest.php +++ b/tests/GitAttributes/MergerTest.php @@ -134,6 +134,28 @@ public function mergeWillRemoveExistingExportIgnoreRulesForKeptPaths(): void self::assertSame("* text=auto\n" . '/README.md export-ignore', $result); } + /** + * @return void + */ + #[Test] + public function mergeWillRemoveNestedExportIgnoreRulesWhenParentPathIsKept(): void + { + $existingContent = implode("\n", [ + '* text=auto', + '/.agents/agents/ export-ignore', + '/.agents/skills/ export-ignore', + '/tests/ export-ignore', + ]); + + $result = $this->merger->merge( + $existingContent, + ['/.agents/agents/', '/.agents/skills/', '/tests/'], + ['/.agents/'], + ); + + self::assertSame("* text=auto\n" . '/tests/ export-ignore', $result); + } + /** * @return void */ diff --git a/tests/GitAttributes/WriterTest.php b/tests/GitAttributes/WriterTest.php index 053a146b1..61e083279 100644 --- a/tests/GitAttributes/WriterTest.php +++ b/tests/GitAttributes/WriterTest.php @@ -111,6 +111,18 @@ public function writeWillPreserveEmptyLines(): void ->shouldBeCalledOnce(); } + /** + * @return void + */ + #[Test] + public function writeWillNotAddAnExtraBlankLineWhenContentAlreadyEndsWithALineFeed(): void + { + $this->writer->write('/project/.gitattributes', "/docs/ export-ignore\n"); + + $this->filesystem->dumpFile('/project/.gitattributes', "/docs/ export-ignore\n") + ->shouldBeCalledOnce(); + } + /** * @return void */ diff --git a/tests/Sync/PackagedDirectorySynchronizerTest.php b/tests/Sync/PackagedDirectorySynchronizerTest.php index 1bf360481..bcc548a72 100644 --- a/tests/Sync/PackagedDirectorySynchronizerTest.php +++ b/tests/Sync/PackagedDirectorySynchronizerTest.php @@ -121,6 +121,7 @@ public function synchronizeWithMissingPackagePathWillReturnFailedResult(): void public function synchronizeWithMissingTargetDirWillCreateItAndCreateLinks(): void { $entryPath = '/package/.agents/agents/issue-editor'; + $relativeEntryPath = '../../../package/.agents/agents/issue-editor'; $this->mockFinder($this->createDirectory('issue-editor', $entryPath)); @@ -134,9 +135,15 @@ public function synchronizeWithMissingTargetDirWillCreateItAndCreateLinks(): voi ->shouldBeCalledOnce(); $this->filesystem->exists('/consumer/.agents/agents/issue-editor') ->willReturn(false); - $this->filesystem->symlink($entryPath, '/consumer/.agents/agents/issue-editor') + $this->filesystem->dirname('/consumer/.agents/agents/issue-editor') + ->willReturn('/consumer/.agents/agents') ->shouldBeCalledOnce(); - $this->logger->info('Created link: issue-editor -> ' . $entryPath)->shouldBeCalledOnce(); + $this->filesystem->makePathRelative($entryPath, '/consumer/.agents/agents') + ->willReturn($relativeEntryPath) + ->shouldBeCalledOnce(); + $this->filesystem->symlink($relativeEntryPath, '/consumer/.agents/agents/issue-editor') + ->shouldBeCalledOnce(); + $this->logger->info('Created link: issue-editor -> ' . $relativeEntryPath)->shouldBeCalledOnce(); $result = $this->createSynchronizer() ->synchronize('/consumer/.agents/agents', '/package/.agents/agents', '.agents/agents'); @@ -188,6 +195,7 @@ public function synchronizeWillRepairBrokenSymlink(): void $entryPath = '/package/.agents/agents/issue-editor'; $targetLink = '/consumer/.agents/agents/issue-editor'; $brokenPath = '/obsolete/.agents/agents/issue-editor'; + $relativeEntryPath = '../../../package/.agents/agents/issue-editor'; $this->mockFinder($this->createDirectory('issue-editor', $entryPath)); @@ -205,11 +213,17 @@ public function synchronizeWillRepairBrokenSymlink(): void ->willReturn(false); $this->filesystem->remove($targetLink) ->shouldBeCalledOnce(); - $this->filesystem->symlink($entryPath, $targetLink) + $this->filesystem->dirname($targetLink) + ->willReturn('/consumer/.agents/agents') + ->shouldBeCalledOnce(); + $this->filesystem->makePathRelative($entryPath, '/consumer/.agents/agents') + ->willReturn($relativeEntryPath) + ->shouldBeCalledOnce(); + $this->filesystem->symlink($relativeEntryPath, $targetLink) ->shouldBeCalledOnce(); $this->logger->notice('Existing link is broken: issue-editor (removing and recreating)') ->shouldBeCalledOnce(); - $this->logger->info('Created link: issue-editor -> ' . $entryPath) + $this->logger->info('Created link: issue-editor -> ' . $relativeEntryPath) ->shouldBeCalledOnce(); $result = $this->createSynchronizer()