Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .github/wiki
Submodule wiki updated from 79fb0c to 0e0484
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/commands/agents.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/commands/skills.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down Expand Up @@ -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.
5 changes: 3 additions & 2 deletions docs/usage/syncing-packaged-agents.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions docs/usage/syncing-packaged-skills.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down
21 changes: 18 additions & 3 deletions src/GitAttributes/ExportIgnoreFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<string> $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.
*
Expand Down
19 changes: 17 additions & 2 deletions src/GitAttributes/Merger.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand Down Expand Up @@ -188,6 +188,21 @@ private function keepInExportLookup(array $keepInExportPaths): array
return $lookup;
}

/**
* @param string $pathKey
* @param array<string, true> $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.
*
Expand Down
4 changes: 4 additions & 0 deletions src/GitAttributes/Writer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions src/Sync/PackagedDirectorySynchronizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
16 changes: 16 additions & 0 deletions tests/GitAttributes/ExportIgnoreFilterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
22 changes: 22 additions & 0 deletions tests/GitAttributes/MergerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
12 changes: 12 additions & 0 deletions tests/GitAttributes/WriterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
22 changes: 18 additions & 4 deletions tests/Sync/PackagedDirectorySynchronizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand All @@ -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');
Expand Down Expand Up @@ -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));

Expand All @@ -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()
Expand Down
Loading