Skip to content

Commit 8c211e4

Browse files
committed
feat: add task caching with hash-based cache invalidation
1 parent 0c3adeb commit 8c211e4

File tree

6 files changed

+186
-15
lines changed

6 files changed

+186
-15
lines changed

deno.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
"command": "deno test packages/cli/**/*.spec.ts --allow-all"
88
},
99
"compile": {
10-
"command": "deno compile --allow-read --allow-env --allow-run --output ./dist/cli packages/cli/mod.ts"
10+
"command": "deno compile --allow-write --allow-read --allow-env --allow-run --output ./dist/cli packages/cli/mod.ts"
1111
},
1212
"install": {
13-
"command": "deno install --allow-read --allow-env --allow-run -g -N -R -f -n runx jsr:@runx/cli"
13+
"command": "deno install --allow-write --allow-read --allow-env --allow-run -g -N -R -f -n runx jsr:@runx/cli"
1414
}
1515
},
1616
"workspace": [

deno.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"@std/fs": "jsr:@std/[email protected]",
88
"@cliffy/command": "jsr:@cliffy/[email protected]",
99
"@david/dax": "jsr:@david/[email protected]",
10-
"@std/path": "jsr:@std/[email protected]"
10+
"@std/path": "jsr:@std/[email protected]",
11+
"@std/crypto": "jsr:@std/[email protected]"
1112
}
1213
}

packages/cli/lib/cache.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { crypto } from '@std/crypto';
2+
import { join } from '@std/path';
3+
import { ensureDir } from '@std/fs';
4+
5+
interface TaskCache {
6+
hash: string;
7+
timestamp: number;
8+
output: string;
9+
exitCode: number;
10+
}
11+
12+
export async function calculateTaskHash(
13+
packageName: string,
14+
taskName: string,
15+
dependencies: {
16+
files: string[];
17+
packageJson: Record<string, unknown>;
18+
},
19+
): Promise<string> {
20+
console.log(`Calculating hash for ${packageName} ${taskName}`);
21+
22+
console.log(`Package name: ${packageName}`);
23+
console.log(`Task name: ${taskName}`);
24+
console.log(`Dependencies: ${JSON.stringify(dependencies)}`);
25+
26+
// Create a string that includes all the inputs that affect the task
27+
const inputString = JSON.stringify({
28+
packageName,
29+
taskName,
30+
dependencies,
31+
});
32+
33+
// Calculate SHA-256 hash
34+
const hashBuffer = await crypto.subtle.digest(
35+
'SHA-256',
36+
new TextEncoder().encode(inputString),
37+
);
38+
39+
// Convert to hex string
40+
const hash = Array.from(new Uint8Array(hashBuffer))
41+
.map((b) => b.toString(16).padStart(2, '0'))
42+
.join('');
43+
44+
console.log(`Hash for ${packageName} ${taskName}: ${hash}`);
45+
46+
return hash;
47+
}
48+
49+
export class TaskCacheManager {
50+
private cacheDir: string;
51+
52+
constructor(workspaceRoot: string) {
53+
this.cacheDir = join(workspaceRoot, '.runx', 'cache');
54+
}
55+
56+
private getCachePath(hash: string): string {
57+
return join(this.cacheDir, `${hash}.json`);
58+
}
59+
60+
async init(): Promise<void> {
61+
await ensureDir(this.cacheDir);
62+
}
63+
64+
async saveCache(
65+
hash: string,
66+
output: string,
67+
exitCode: number,
68+
): Promise<void> {
69+
const cache: TaskCache = {
70+
hash,
71+
timestamp: Date.now(),
72+
output,
73+
exitCode,
74+
};
75+
76+
await Deno.writeTextFile(
77+
this.getCachePath(hash),
78+
JSON.stringify(cache, null, 2),
79+
);
80+
}
81+
82+
async getCache(hash: string): Promise<TaskCache | null> {
83+
try {
84+
const cachePath = this.getCachePath(hash);
85+
const cacheContent = await Deno.readTextFile(cachePath);
86+
return JSON.parse(cacheContent) as TaskCache;
87+
} catch {
88+
return null;
89+
}
90+
}
91+
}

packages/cli/lib/git.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,6 @@ export function getAffectedPackages(
3535
changedFiles: string[],
3636
packages: { packageJson: { name: string }; cwd: string }[],
3737
): Set<string> {
38-
console.log('changedFiles', changedFiles);
39-
console.log('packages', packages);
40-
4138
const affectedPackages = new Set<string>();
4239

4340
for (const pkg of packages) {

packages/cli/mod.ts

Lines changed: 86 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { expandGlob } from '@std/fs';
1+
import { exists, expandGlob } from '@std/fs';
22
import { Command } from '@cliffy/command';
33
import { parseFullCommand } from './lib/parse-command.ts';
44
import { dirname, join } from '@std/path';
55
import $ from '@david/dax';
66
import { Graph, type PackageJson } from './lib/graph.ts';
77
import { getAffectedPackages, getChangedFiles } from './lib/git.ts';
8+
import { calculateTaskHash, TaskCacheManager } from './lib/cache.ts';
89

910
await new Command()
1011
.name('runx')
@@ -13,14 +14,23 @@ await new Command()
1314
.option(
1415
'-a, --affected [base:string]',
1516
'Run command only for affected packages',
16-
(value) => value || value === '',
17+
{
18+
value: (value) => value || 'main',
19+
default: 'main',
20+
},
21+
)
22+
.option(
23+
'--no-cache',
24+
'Disable task caching',
25+
{ default: true },
1726
)
1827
.arguments('<task-name> [...project]')
19-
.action(async ({ affected }, taskName, ...projects: string[]) => {
28+
.action(async ({ affected, cache }, taskName, ...projects: string[]) => {
2029
const startTime = performance.now();
2130
console.log(`Running command ${taskName} with options:`, {
2231
affected,
2332
projects,
33+
cache,
2434
});
2535

2636
console.log(`\n> Starting execution of '${taskName}' command...`);
@@ -32,6 +42,12 @@ await new Command()
3242

3343
const workspacePatterns = rootPackageJson.workspaces || ['**/package.json'];
3444

45+
// Initialize cache manager if caching is enabled
46+
const cacheManager = cache ? new TaskCacheManager(Deno.cwd()) : null;
47+
if (cacheManager) {
48+
await cacheManager.init();
49+
}
50+
3551
// Collect all package.json files from workspace patterns
3652
const packageFileSpecs = await Promise.all(
3753
workspacePatterns.map((pattern) =>
@@ -155,12 +171,73 @@ await new Command()
155171
const executable = join(Deno.cwd(), which);
156172

157173
try {
158-
await $`${executable} ${args.join(' ')}`.cwd(
159-
packageInfo.cwd,
160-
).env({
161-
...Deno.env.toObject(),
162-
...(env ?? {}),
163-
});
174+
// If caching is enabled, try to use cached result
175+
if (cacheManager) {
176+
const exclude = [];
177+
178+
const gitignorePath = join(Deno.cwd(), '.gitignore');
179+
180+
if (await exists(gitignorePath)) {
181+
const gitignore = await Deno.readTextFile(gitignorePath);
182+
exclude.push(
183+
...gitignore.split('\n').map((file) => file.trim()).filter(
184+
Boolean,
185+
),
186+
);
187+
}
188+
189+
// Get all files in the package directory
190+
const packageFiles = await Array.fromAsync(
191+
expandGlob('**/*', {
192+
root: packageInfo.cwd,
193+
exclude,
194+
}),
195+
).then((files) =>
196+
files.map((file) => file.path.replace(Deno.cwd() + '/', ''))
197+
);
198+
199+
const hash = await calculateTaskHash(packageName, taskName, {
200+
files: packageFiles,
201+
packageJson: packageInfo.packageJson,
202+
});
203+
204+
const cache = await cacheManager.getCache(hash);
205+
206+
if (cache) {
207+
console.log(
208+
`✓ Using cached result for ${packageName} (hash: ${hash})`,
209+
);
210+
console.log(cache.output);
211+
if (cache.exitCode !== 0) {
212+
throw new Error(`Task failed with exit code ${cache.exitCode}`);
213+
}
214+
continue;
215+
}
216+
217+
const result = await $`${executable} ${args.join(' ')}`.cwd(
218+
packageInfo.cwd,
219+
).env({
220+
...Deno.env.toObject(),
221+
...(env ?? {}),
222+
}).stdout('inheritPiped');
223+
224+
console.log(result.stdout);
225+
226+
// Save the result to cache
227+
await cacheManager.saveCache(hash, result.stdout, result.code);
228+
229+
if (result.code !== 0) {
230+
throw new Error(`Task failed with exit code ${result.code}`);
231+
}
232+
} else {
233+
// Run without caching
234+
await $`${executable} ${args.join(' ')}`.cwd(
235+
packageInfo.cwd,
236+
).env({
237+
...Deno.env.toObject(),
238+
...(env ?? {}),
239+
});
240+
}
164241

165242
const packageDuration =
166243
((performance.now() - packageStartTime) / 1000).toFixed(2);

0 commit comments

Comments
 (0)