@mikaelkaron/skills-cherry-pick-filter 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/run.ts ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env -S node --experimental-strip-types
2
+
3
+ import { execute } from "@oclif/core";
4
+ await execute({ dir: import.meta.url });
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@mikaelkaron/skills-cherry-pick-filter",
3
+ "version": "0.0.0",
4
+ "bin": {
5
+ "mks-cherry-pick-filter": "bin/run.ts"
6
+ },
7
+ "files": [
8
+ "bin",
9
+ "src",
10
+ "oclif.manifest.json"
11
+ ],
12
+ "type": "module",
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "scripts": {
17
+ "test": "node --experimental-strip-types --test 'test/**/*.test.ts'",
18
+ "test:types": "tsc --noEmit"
19
+ },
20
+ "dependencies": {
21
+ "@oclif/core": "^4"
22
+ },
23
+ "devDependencies": {
24
+ "@oclif/test": "^4.1.18",
25
+ "@types/node": "^22",
26
+ "typescript": "^5"
27
+ },
28
+ "oclif": {
29
+ "bin": "mks-cherry-pick-filter",
30
+ "commands": {
31
+ "globPatterns": [
32
+ "**/*.ts"
33
+ ],
34
+ "strategy": "pattern",
35
+ "target": "./src/commands"
36
+ },
37
+ "id": "cherry-pick-filter"
38
+ },
39
+ "engines": {
40
+ "node": ">=22.18"
41
+ },
42
+ "tessl": {
43
+ "tile": "mikaelkaron/cherry-pick-filter",
44
+ "version": "0.1.6"
45
+ }
46
+ }
@@ -0,0 +1,201 @@
1
+ import { Args, Command, Flags } from "@oclif/core";
2
+ import { exec, tryExec } from "../lib/git.ts";
3
+
4
+ function out(sha: string): void {
5
+ if (!process.stdout.isTTY) process.stdout.write(sha + "\n");
6
+ }
7
+
8
+ export default class CherryPickFilter extends Command {
9
+ static override summary =
10
+ "Sync a working branch to a clean target branch by cherry-picking commits that don't touch filtered path prefixes.";
11
+
12
+ static override description = `Incrementally cherry-picks commits from the current branch onto a target branch, skipping commits where all changed files match filtered path prefixes. Mixed commits (code + filtered files in the same commit) are detected by analyzing all commits first, then reported together, and the operation is halted until the user resolves them.
13
+
14
+ All human-readable output goes to stderr. stdout emits one picked commit SHA per line — but only when piped (not a TTY).`;
15
+
16
+ static override examples = [
17
+ {
18
+ description: "Sync to beta, filtering out .planning/ commits",
19
+ command: "<%= config.bin %> <%= command.id %> beta --filter .planning/",
20
+ },
21
+ {
22
+ description: "Filter multiple path prefixes",
23
+ command:
24
+ "<%= config.bin %> <%= command.id %> beta --filter .planning/ --filter .agents/",
25
+ },
26
+ {
27
+ description: "Dry run: analyse without cherry-picking",
28
+ command:
29
+ "<%= config.bin %> <%= command.id %> beta --filter .planning/ --dry-run",
30
+ },
31
+ {
32
+ description: "Capture picked SHAs",
33
+ command:
34
+ "<%= config.bin %> <%= command.id %> beta --filter .planning/ | xargs git log --oneline",
35
+ },
36
+ ];
37
+
38
+ static override args = {
39
+ targetBranch: Args.string({
40
+ description: "Branch to cherry-pick code commits onto",
41
+ required: true,
42
+ }),
43
+ };
44
+
45
+ static override flags = {
46
+ filter: Flags.string({
47
+ description:
48
+ "Path prefix to filter out. Commits where ALL files match are skipped; commits where SOME files match are aborted as mixed.",
49
+ multiple: true,
50
+ required: true,
51
+ }),
52
+ "dry-run": Flags.boolean({
53
+ description: "Analyse commits without cherry-picking",
54
+ default: false,
55
+ }),
56
+ };
57
+
58
+ async run(): Promise<void> {
59
+ const { args, flags } = await this.parse(CherryPickFilter);
60
+ const { targetBranch } = args;
61
+ const filters = flags.filter;
62
+ const dryRun = flags["dry-run"];
63
+
64
+ const currentBranch = exec("git rev-parse --abbrev-ref HEAD");
65
+
66
+ if (currentBranch === targetBranch) {
67
+ this.error(`already on '${targetBranch}'.`, { exit: 1 });
68
+ }
69
+
70
+ const localExists = tryExec(
71
+ `git show-ref --verify --quiet refs/heads/${targetBranch}`,
72
+ ).ok;
73
+
74
+ if (!localExists) {
75
+ const remoteResult = tryExec(
76
+ `git ls-remote --heads origin ${targetBranch}`,
77
+ );
78
+ if (remoteResult.ok && remoteResult.output.length > 0) {
79
+ this.logToStderr(`Checking out '${targetBranch}' from origin...`);
80
+ const checkoutResult = tryExec(
81
+ `git checkout --track origin/${targetBranch}`,
82
+ );
83
+ if (!checkoutResult.ok) {
84
+ this.logToStderr(
85
+ `Error: failed to checkout '${targetBranch}' from origin.`,
86
+ );
87
+ this.logToStderr(checkoutResult.output);
88
+ this.exit(1);
89
+ }
90
+ exec(`git checkout ${currentBranch}`);
91
+ } else {
92
+ this.error(`branch '${targetBranch}' not found locally or on origin.`, {
93
+ exit: 1,
94
+ });
95
+ }
96
+ }
97
+
98
+ const dryRunLabel = dryRun ? " (dry run)" : "";
99
+ this.logToStderr(
100
+ `\nSyncing ${currentBranch} → ${targetBranch}${dryRunLabel}`,
101
+ );
102
+ this.logToStderr(`Filter prefixes: ${filters.join(", ")}\n`);
103
+
104
+ const pathspecs = filters.map((f) => `:!${f}`).join(" ");
105
+ const logOutput = tryExec(
106
+ `git log ${targetBranch}..HEAD --reverse --format="%H %s" -- ${pathspecs}`,
107
+ );
108
+
109
+ if (!logOutput.ok || !logOutput.output) {
110
+ this.logToStderr("Already up to date.");
111
+ return;
112
+ }
113
+
114
+ const candidates = logOutput.output
115
+ .split("\n")
116
+ .filter(Boolean)
117
+ .map((line) => ({ sha: line.slice(0, 40), subject: line.slice(41) }));
118
+
119
+ if (candidates.length === 0) {
120
+ this.logToStderr("Already up to date.");
121
+ return;
122
+ }
123
+
124
+ const mixed: Array<{
125
+ sha: string;
126
+ subject: string;
127
+ filteredFiles: string[];
128
+ codeFiles: string[];
129
+ }> = [];
130
+
131
+ for (const { sha, subject } of candidates) {
132
+ const files = exec(`git diff-tree --no-commit-id -r --name-only ${sha}`)
133
+ .split("\n")
134
+ .filter(Boolean);
135
+ const filteredFiles = files.filter((f) =>
136
+ filters.some((prefix) => f.startsWith(prefix)),
137
+ );
138
+ const codeFiles = files.filter(
139
+ (f) => !filters.some((prefix) => f.startsWith(prefix)),
140
+ );
141
+ if (filteredFiles.length > 0 && codeFiles.length > 0) {
142
+ mixed.push({ sha, subject, filteredFiles, codeFiles });
143
+ }
144
+ }
145
+
146
+ if (mixed.length > 0) {
147
+ this.logToStderr("Mixed commits detected — fix these before syncing:\n");
148
+ for (const { sha, subject, filteredFiles, codeFiles } of mixed) {
149
+ this.logToStderr(` ${sha.slice(0, 9)} ${subject}`);
150
+ this.logToStderr(` In filter:`);
151
+ for (const f of filteredFiles) this.logToStderr(` ${f}`);
152
+ this.logToStderr(` Outside filter:`);
153
+ for (const f of codeFiles) this.logToStderr(` ${f}`);
154
+ this.logToStderr("");
155
+ }
156
+ this.logToStderr(`Split each commit with:`);
157
+ this.logToStderr(` git rebase -i ${mixed[0].sha.slice(0, 9)}^\n`);
158
+ this.logToStderr(
159
+ `Then re-run: git cherry-pick-filter ${targetBranch} ${filters.map((f) => `--filter ${f}`).join(" ")}`,
160
+ );
161
+ this.exit(1);
162
+ }
163
+
164
+ const verb = dryRun ? "Would pick" : "Ready to pick";
165
+ this.logToStderr(
166
+ `${verb} ${candidates.length} commit${candidates.length === 1 ? "" : "s"}:`,
167
+ );
168
+ for (const { sha, subject } of candidates) {
169
+ this.logToStderr(` ${sha.slice(0, 9)} ${subject}`);
170
+ }
171
+ this.logToStderr("");
172
+
173
+ if (dryRun) {
174
+ this.logToStderr("Dry run complete. Run without --dry-run to apply.");
175
+ return;
176
+ }
177
+
178
+ exec(`git checkout ${targetBranch}`);
179
+
180
+ let picked = 0;
181
+ for (const { sha, subject } of candidates) {
182
+ const result = tryExec(`git cherry-pick ${sha}`);
183
+ if (result.ok) {
184
+ this.logToStderr(` pick ${sha.slice(0, 9)} ${subject}`);
185
+ out(sha);
186
+ picked++;
187
+ } else {
188
+ this.logToStderr(
189
+ `\nCherry-pick failed: ${sha.slice(0, 9)} ${subject}\n`,
190
+ );
191
+ this.logToStderr("Resolve the conflict then run:");
192
+ this.logToStderr(" git cherry-pick --continue");
193
+ this.logToStderr(" git cherry-pick --abort (to cancel)");
194
+ this.exit(1);
195
+ }
196
+ }
197
+
198
+ exec(`git checkout ${currentBranch}`);
199
+ this.logToStderr(`\nDone: ${picked} picked`);
200
+ }
201
+ }
package/src/lib/git.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { execSync } from "node:child_process";
2
+
3
+ export function exec(cmd: string): string {
4
+ return execSync(cmd, { encoding: "utf8", stdio: "pipe" }).trim();
5
+ }
6
+
7
+ export function tryExec(cmd: string): { ok: boolean; output: string } {
8
+ try {
9
+ return { ok: true, output: exec(cmd) };
10
+ } catch (e: unknown) {
11
+ const err = e as { stderr?: string; message?: string };
12
+ return { ok: false, output: err.stderr ?? err.message ?? "" };
13
+ }
14
+ }