Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e6ee2a8
test(sync): cover Applied changes report grouping and change-type labels
willmruzek Apr 21, 2026
b3a9a41
feat(sync)!: hash outputs in manifest v3 and skip unchanged sync work
willmruzek Apr 21, 2026
59e07bb
perf(sync): memoize directory content hash per sourceDir
Copilot Apr 21, 2026
f29c472
refactor(sync): rename directory hash cache variable
Copilot Apr 21, 2026
0ffdefa
Merge remote-tracking branch 'origin/main' into fix/dont-show-updated
willmruzek Apr 23, 2026
bc6bb13
Merge branch 'fix/dont-show-updated' of github.com:willmruzek/dry-ai …
willmruzek Apr 23, 2026
017009c
feat(sync): detect unchanged outputs from disk instead of manifest ha…
willmruzek Apr 24, 2026
1fd2a9d
Update tests/commands/sync.test.ts
willmruzek Apr 24, 2026
60f19e3
Update tests/commands/sync.test.ts
willmruzek Apr 24, 2026
4ab8e43
Update src/lib/sync.ts
willmruzek Apr 24, 2026
b870adf
fix(sync): recover manifest paths for cleanup when the file is non-ca…
willmruzek Apr 24, 2026
d1e1bb8
Merge branch 'fix/dont-show-updated' of github.com:willmruzek/dry-ai …
willmruzek Apr 24, 2026
f9c96ba
docs: Update src/lib/sync.ts
willmruzek Apr 24, 2026
92079ff
docs(sync): clarify manifest fallback cleanup warnings
Copilot Apr 24, 2026
72a15f1
docs(sync): tighten fallback warning wording
Copilot Apr 24, 2026
e47f246
test(sync): decouple fixtures from production agent and manifest exports
willmruzek Apr 24, 2026
ca1ec5b
fix(sync): warn when strict-invalid manifest has no output rows
Copilot Apr 24, 2026
58d90e0
test: update tests/commands/sync.test.ts
willmruzek Apr 24, 2026
eb3f6d5
ci: drop commitlint from pack job dependencies
willmruzek Apr 24, 2026
15fc7bb
Merge branch 'fix/dont-show-updated' of github.com:willmruzek/dry-ai …
willmruzek Apr 24, 2026
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
8 changes: 8 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export default [
rules: {
// Toolkit `recommended` prefers `interface`; this project prefers `type`.
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
{
Expand Down
253 changes: 202 additions & 51 deletions src/lib/sync.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import path from 'node:path';

import { Chalk } from 'chalk';
Expand Down Expand Up @@ -32,8 +33,12 @@ import {
ruleFrontmatterSchema,
validateFrontmatter,
} from './frontmatter.js';
import { computeDirectoryHashes } from './skills.js';

type SyncAppliedChangeType = 'installed' | 'updated';
/** Written to `sync-manifest.json`; bump when the manifest shape changes. */
export const SYNC_MANIFEST_VERSION = 2 as const;

type SyncAppliedChangeType = 'installed' | 'updated' | 'unchanged';
Comment thread
willmruzek marked this conversation as resolved.
type SyncChangeType = SyncAppliedChangeType | 'removed';

const chalk = new Chalk({ level: 3 });
Expand All @@ -53,7 +58,7 @@ const syncManifestEntrySchema = z.object({
});
Comment thread
willmruzek marked this conversation as resolved.
Comment thread
willmruzek marked this conversation as resolved.

const syncManifestSchema = z.object({
version: z.literal(2),
version: z.literal(SYNC_MANIFEST_VERSION),
outputs: z.array(syncManifestEntrySchema),
});

Expand All @@ -65,6 +70,7 @@ type SyncItem = {
};

type ItemSyncChange = {
target: SyncTarget;
agent: SyncAgent;
changeType: SyncAppliedChangeType;
};
Expand Down Expand Up @@ -123,9 +129,10 @@ export async function syncToTargets(
const { syncableItems, skippedItems } =
collectConflictFilterResult(syncItems);
const skippedOwnershipKeys = collectSkippedOwnershipKeys(skippedItems);
const desiredManifestEntries = collectManifestEntries(syncableItems);
const desiredOutputPaths = new Set(
desiredManifestEntries.map((entry) => entry.outputPath),
syncableItems.flatMap((syncItem) =>
syncItem.targets.map((target) => target.outputPath),
),
);
const removedEntries = collectRemovedManifestEntries(
previousManifest.outputs,
Expand All @@ -143,6 +150,8 @@ export async function syncToTargets(
appliedItems.push(await applySyncItem(syncItem));
}

const desiredManifestEntries =
collectManifestEntriesFromApplied(appliedItems);
const preservedEntries = collectPreservedManifestEntries(
previousManifest.outputs,
{
Expand Down Expand Up @@ -196,17 +205,30 @@ async function ensureTargetDirectories(
}

/**
* Reads the sync manifest from disk, or returns an empty manifest if none exists yet.
* Reads the sync manifest from disk, or returns an empty manifest if none
* exists yet. Any failure to read, parse, or validate (including reading a
* manifest from an older version of the schema) falls back to an empty
* manifest, which causes the next sync to re-report every current output
* as `(installed)` instead of `(unchanged)`.
Comment thread
willmruzek marked this conversation as resolved.
Outdated
*/
async function loadSyncManifest(manifestPath: string): Promise<SyncManifest> {
if (!(await fs.pathExists(manifestPath))) {
return createSyncManifest([]);
}

const rawManifest = await fs.readFile(manifestPath, 'utf8');
const parsedManifest: unknown = JSON.parse(rawManifest);
try {
const rawManifest = await fs.readFile(manifestPath, 'utf8');
const parsedManifest: unknown = JSON.parse(rawManifest);
const result = syncManifestSchema.safeParse(parsedManifest);

if (result.success) {
return result.data;
}
} catch {
// Fall through to an empty manifest on any read/parse failure.
}

return syncManifestSchema.parse(parsedManifest);
return createSyncManifest([]);
}
Comment thread
willmruzek marked this conversation as resolved.

/**
Expand Down Expand Up @@ -235,7 +257,7 @@ function createSyncManifest(entries: SyncManifestEntry[]): SyncManifest {
}

return {
version: 2,
version: SYNC_MANIFEST_VERSION,
outputs: [...entriesByOutputPath.values()].sort(compareManifestEntries),
};
}
Expand Down Expand Up @@ -263,27 +285,154 @@ async function writeMarkdownFile<Metadata extends Record<string, unknown>>(
}

/**
* Detects whether each agent target for one item will be installed or updated.
* Computes a content hash for one sync target that identifies the bytes
* that WOULD be written on the next sync. Markdown targets hash the exact
* rendered output (frontmatter + body). Directory targets hash a sorted,
* serialized snapshot of per-file SHA-256 hashes under the source
* directory. The hash is stable across runs as long as the effective
* content is unchanged, and is used to detect the `unchanged` branch.
*/
async function detectSyncChanges(
syncItem: SyncItem,
): Promise<ItemSyncChange[]> {
return Promise.all(
syncItem.targets.map(async (target) => ({
agent: parseSyncAgent(target.agent),
changeType: (await fs.pathExists(target.outputPath))
? 'updated'
: 'installed',
})),
async function computeTargetContentHash(target: SyncTarget): Promise<string> {
if (target.targetType === 'markdown') {
const content = renderMarkdown({
metadata: target.metadata,
body: target.body,
});
return createHash('sha256').update(content).digest('hex');
}

const fileHashes = await computeDirectoryHashes(target.sourceDir);
const serialized = JSON.stringify(
Object.entries(fileHashes).sort(([left], [right]) =>
left.localeCompare(right),
),
);
return createHash('sha256').update(serialized).digest('hex');
}

/**
* SHA-256 of the bytes currently on disk for this target, using the same
* serialization as {@link computeTargetContentHash} so it can be compared
* to the would-be-written hash. Returns `undefined` if the artifact is
* missing or cannot be read.
*/
async function computeOnDiskContentHash(
target: SyncTarget,
): Promise<string | undefined> {
if (target.targetType === 'markdown') {
const filePath = target.writePath;
try {
if (!(await fs.pathExists(filePath))) {
return undefined;
}
const content = await fs.readFile(filePath, 'utf8');
return createHash('sha256').update(content).digest('hex');
} catch {
return undefined;
}
}

try {
if (!(await fs.pathExists(target.outputPath))) {
return undefined;
}
const fileHashes = await computeDirectoryHashes(target.outputPath);
const serialized = JSON.stringify(
Comment thread
willmruzek marked this conversation as resolved.
Object.entries(fileHashes).sort(([left], [right]) =>
left.localeCompare(right),
),
);
return createHash('sha256').update(serialized).digest('hex');
} catch {
return undefined;
}
}

/**
* On-disk path that must exist for a target to be treated as already materialized.
* Matches what `writeSyncTarget` creates: markdown targets use `writePath` (the file),
* which can differ from manifest `outputPath` when that row names a parent directory
* (e.g. Cursor commands). Directory targets use `outputPath` as the copy root.
*/
function getSyncTargetArtifactPath(target: SyncTarget): string {
return target.targetType === 'markdown'
? target.writePath
: target.outputPath;
}

/**
* Determines the applied change type by comparing on-disk bytes to the
* would-be-written hash (manifest does not store content hashes).
*
* - `unchanged`: the artifact path exists and on-disk content hashes to the
* desired value.
* - `installed`: the artifact path does not exist on disk.
* - `updated`: the artifact exists but on-disk content does not match.
*/
async function detectAppliedChangeType(input: {
target: SyncTarget;
desiredContentHash: string;
}): Promise<SyncAppliedChangeType> {
const artifactExists = await fs.pathExists(
getSyncTargetArtifactPath(input.target),
);

if (!artifactExists) {
return 'installed';
}

const onDiskHash = await computeOnDiskContentHash(input.target);
if (onDiskHash === input.desiredContentHash) {
return 'unchanged';
}

return 'updated';
}
Comment thread
willmruzek marked this conversation as resolved.

/**
* Applies one sync item and records the change type for each agent target.
* Applies one sync item: computes a content hash per target, decides the
* applied change type, and writes the output iff the change type is not
* `unchanged`.
*/
async function applySyncItem(syncItem: SyncItem): Promise<AppliedSyncItem> {
const changes = await detectSyncChanges(syncItem);
await writeSyncItem(syncItem);
const directoryHashCache = new Map<string, Promise<string>>();

const changes = await Promise.all(
syncItem.targets.map(async (target): Promise<ItemSyncChange> => {
let desiredContentHash: string;
if (target.targetType === 'directory') {
const cachedHashPromise = directoryHashCache.get(target.sourceDir);
const contentHashPromise =
cachedHashPromise ?? computeTargetContentHash(target);

if (!cachedHashPromise) {
directoryHashCache.set(target.sourceDir, contentHashPromise);
}

desiredContentHash = await contentHashPromise;
} else {
desiredContentHash = await computeTargetContentHash(target);
}

Comment thread
willmruzek marked this conversation as resolved.
const changeType = await detectAppliedChangeType({
target,
desiredContentHash,
});

return {
target,
agent: parseSyncAgent(target.agent),
changeType,
};
}),
);

for (const change of changes) {
if (change.changeType === 'unchanged') {
continue;
}
await writeSyncTarget(change.target);
}

return {
item: syncItem,
Expand All @@ -303,15 +452,6 @@ async function writeSyncTarget(target: SyncTarget): Promise<void> {
await copyDirectoryContents(target.sourceDir, target.outputPath);
}

/**
* Writes one sync item to all of its target outputs.
*/
async function writeSyncItem(syncItem: SyncItem): Promise<void> {
for (const target of syncItem.targets) {
await writeSyncTarget(target);
}
}

/**
* Collects sync operations for command sources after validating their frontmatter.
*/
Expand Down Expand Up @@ -559,15 +699,17 @@ function collectSkippedOwnershipKeys(
}

/**
* Converts sync items into manifest entries for the current desired outputs.
* Converts applied sync items into manifest entries for desired outputs.
*/
function collectManifestEntries(syncItems: SyncItem[]): SyncManifestEntry[] {
return syncItems.flatMap((syncItem) =>
syncItem.targets.map((target) => ({
agent: parseSyncAgent(target.agent),
kind: syncItem.kind,
name: syncItem.name,
outputPath: target.outputPath,
function collectManifestEntriesFromApplied(
appliedItems: AppliedSyncItem[],
): SyncManifestEntry[] {
return appliedItems.flatMap((appliedItem) =>
appliedItem.changes.map((change) => ({
agent: change.agent,
kind: appliedItem.item.kind,
name: appliedItem.item.name,
outputPath: change.target.outputPath,
})),
Comment thread
willmruzek marked this conversation as resolved.
);
}
Expand Down Expand Up @@ -627,16 +769,17 @@ function renderSyncReport(
removedEntries: SyncManifestEntry[],
skippedItems: SkippedSyncItem[],
): string {
const sections = [chalk.bold.cyan('Applied changes:')];
const agentSections = SYNC_AGENTS.map((agent) =>
renderAgentSyncSection(
getAgentLabel(agent),
collectAgentReportedSyncChanges(appliedItems, removedEntries, agent),
),
).filter((section): section is string => section !== undefined);

for (const agent of SYNC_AGENTS) {
sections.push(
renderAgentSyncSection(
getAgentLabel(agent),
collectAgentReportedSyncChanges(appliedItems, removedEntries, agent),
),
);
}
const sections =
agentSections.length === 0
? [`${chalk.bold.cyan('Applied changes:')} ${chalk.green('None')}`]
: [chalk.bold.cyan('Applied changes:'), ...agentSections];

if (skippedItems.length === 0) {
sections.push(
Expand Down Expand Up @@ -674,7 +817,9 @@ function collectAgentReportedSyncChanges(
): ReportedAgentSyncChange[] {
const appliedChanges = appliedItems.flatMap((appliedItem) =>
appliedItem.changes
.filter((change) => change.agent === agent)
.filter(
(change) => change.agent === agent && change.changeType !== 'unchanged',
)
.map((change) => ({
kind: appliedItem.item.kind,
name: appliedItem.item.name,
Expand All @@ -694,17 +839,23 @@ function collectAgentReportedSyncChanges(

/**
* Renders the synced items for one agent grouped by item kind.
* Returns `undefined` when there is nothing to report for this agent
* (so empty agent headings are omitted from the summary).
*/
function renderAgentSyncSection(
agentLabel: string,
reportedChanges: ReportedAgentSyncChange[],
): string {
): string | undefined {
const kindSections = [
renderKindSyncLine('commands', 'command', reportedChanges),
renderKindSyncLine('rules', 'rule', reportedChanges),
renderKindSyncLine('skills', 'skill', reportedChanges),
].filter((section) => section !== undefined);

if (kindSections.length === 0) {
return undefined;
}

return [`- ${colorAgentLabel(agentLabel)}`, ...kindSections].join('\n');
}

Expand Down
Loading