@chlrc/aiw 0.1.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/src/git.mjs ADDED
@@ -0,0 +1,210 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { capture, tryCapture } from "./run.mjs";
4
+ import { pickFromList } from "./prompt.mjs";
5
+
6
+ const SKIP_DIRS = new Set([
7
+ ".git",
8
+ "node_modules",
9
+ ".pnpm-store",
10
+ ".turbo",
11
+ ".next",
12
+ "dist",
13
+ "build",
14
+ "coverage"
15
+ ]);
16
+
17
+ export function gitRoot(cwd = process.cwd()) {
18
+ const result = tryCapture("git", ["rev-parse", "--show-toplevel"], { cwd });
19
+ return result.ok ? result.stdout : "";
20
+ }
21
+
22
+ export function assertGitRoot(cwd = process.cwd()) {
23
+ const root = gitRoot(cwd);
24
+ if (root) {
25
+ return root;
26
+ }
27
+ const error = new Error(`not inside a Git repository: ${cwd}`);
28
+ error.exitCode = 3;
29
+ throw error;
30
+ }
31
+
32
+ export function isDirty(repo) {
33
+ const result = tryCapture("git", ["status", "--porcelain"], { cwd: repo });
34
+ return result.ok && result.stdout.length > 0;
35
+ }
36
+
37
+ export async function resolveRepo(cwd, codeRoot, explicitRepo, options = {}) {
38
+ if (explicitRepo) {
39
+ const root = gitRoot(explicitRepo);
40
+ if (!root) {
41
+ throw withExit(`not a Git repository: ${explicitRepo}`, 3);
42
+ }
43
+ return root;
44
+ }
45
+
46
+ if (options.pickRepo) {
47
+ return pickRepoFromRoot(codeRoot);
48
+ }
49
+
50
+ const current = gitRoot(cwd);
51
+ if (current) {
52
+ return current;
53
+ }
54
+
55
+ const normalizedCodeRoot = path.resolve(codeRoot);
56
+ const normalizedCwd = path.resolve(cwd);
57
+ if (!isInside(normalizedCwd, normalizedCodeRoot)) {
58
+ throw withExit(`not inside a Git repository and not under code root: ${cwd}`, 3);
59
+ }
60
+
61
+ const repos = discoverRepos(normalizedCwd === normalizedCodeRoot ? normalizedCodeRoot : normalizedCwd, 3);
62
+ if (repos.length === 0 && normalizedCwd !== normalizedCodeRoot) {
63
+ repos.push(...discoverRepos(normalizedCodeRoot, 2));
64
+ }
65
+ if (repos.length === 0) {
66
+ throw withExit(`no Git repositories found under ${normalizedCwd}`, 3);
67
+ }
68
+ return pickFromList("Select repository", repos);
69
+ }
70
+
71
+ export async function pickRepoFromRoot(codeRoot, defaultRepo = "") {
72
+ const normalizedCodeRoot = path.resolve(codeRoot);
73
+ const repos = discoverRepos(normalizedCodeRoot, 3);
74
+ if (defaultRepo && !repos.includes(defaultRepo)) {
75
+ repos.unshift(defaultRepo);
76
+ }
77
+ if (repos.length === 0) {
78
+ throw withExit(`no Git repositories found under ${normalizedCodeRoot}`, 3);
79
+ }
80
+ return pickFromList("Select repository", repos, { defaultItem: defaultRepo });
81
+ }
82
+
83
+ export function currentBranch(repo) {
84
+ const result = tryCapture("git", ["branch", "--show-current"], { cwd: repo });
85
+ return result.ok ? result.stdout : "";
86
+ }
87
+
88
+ export function branchExists(repo, branch) {
89
+ if (!branch) {
90
+ return false;
91
+ }
92
+ const local = tryCapture("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: repo });
93
+ if (local.ok) {
94
+ return true;
95
+ }
96
+ const remote = tryCapture("git", ["show-ref", "--verify", "--quiet", `refs/remotes/${branch}`], { cwd: repo });
97
+ if (remote.ok) {
98
+ return true;
99
+ }
100
+ const originRemote = tryCapture("git", ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branch}`], { cwd: repo });
101
+ return originRemote.ok;
102
+ }
103
+
104
+ export function listBranches(repo) {
105
+ const local = listRefs(repo, "refs/heads");
106
+ const remoteRaw = listRefs(repo, "refs/remotes").filter((name) => !name.endsWith("/HEAD"));
107
+ const remote = remoteRaw.map((name) => {
108
+ if (name.startsWith("origin/")) {
109
+ return name.slice("origin/".length);
110
+ }
111
+ return name;
112
+ });
113
+ return [...new Set([...local, ...remote])].sort();
114
+ }
115
+
116
+ export async function selectBranch(repo, requestedBranch, options = {}) {
117
+ const current = currentBranch(repo);
118
+ if (requestedBranch) {
119
+ const create = options.forceCreate || !branchExists(repo, requestedBranch);
120
+ return {
121
+ branch: requestedBranch,
122
+ create,
123
+ targetBranch: create ? current : ""
124
+ };
125
+ }
126
+
127
+ const branches = listBranches(repo).filter((branch) => branch !== current);
128
+ const createChoice = "Create new branch from current HEAD...";
129
+ const localChoice = "Open current checkout...";
130
+ const selected = await pickFromList("Select worktree", [createChoice, localChoice, ...branches], {
131
+ defaultItem: createChoice
132
+ });
133
+ if (selected === localChoice) {
134
+ return {
135
+ local: true
136
+ };
137
+ }
138
+ if (selected !== createChoice) {
139
+ return {
140
+ branch: selected,
141
+ create: false
142
+ };
143
+ }
144
+
145
+ const { askInput } = await import("./prompt.mjs");
146
+ const branch = await askInput("New branch");
147
+ if (!branch) {
148
+ throw withExit("branch is required", 4);
149
+ }
150
+ return {
151
+ branch,
152
+ create: true,
153
+ targetBranch: current
154
+ };
155
+ }
156
+
157
+ export function discoverRepos(root, maxDepth = 3) {
158
+ const repos = [];
159
+ walk(path.resolve(root), 0, maxDepth, repos);
160
+ return [...new Set(repos)].sort();
161
+ }
162
+
163
+ function walk(dir, depth, maxDepth, repos) {
164
+ if (depth > maxDepth) {
165
+ return;
166
+ }
167
+ if (hasGitDir(dir)) {
168
+ repos.push(dir);
169
+ return;
170
+ }
171
+ let entries = [];
172
+ try {
173
+ entries = fs.readdirSync(dir, { withFileTypes: true });
174
+ } catch {
175
+ return;
176
+ }
177
+ for (const entry of entries) {
178
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) {
179
+ continue;
180
+ }
181
+ if (entry.name.startsWith(".") && entry.name !== ".config") {
182
+ continue;
183
+ }
184
+ walk(path.join(dir, entry.name), depth + 1, maxDepth, repos);
185
+ }
186
+ }
187
+
188
+ function hasGitDir(dir) {
189
+ return fs.existsSync(path.join(dir, ".git"));
190
+ }
191
+
192
+ function listRefs(repo, refPath) {
193
+ try {
194
+ const output = capture("git", ["for-each-ref", "--format=%(refname:short)", refPath], { cwd: repo });
195
+ return output ? output.split(/\r?\n/).filter(Boolean) : [];
196
+ } catch {
197
+ return [];
198
+ }
199
+ }
200
+
201
+ function isInside(child, parent) {
202
+ const relative = path.relative(parent, child);
203
+ return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
204
+ }
205
+
206
+ function withExit(message, exitCode) {
207
+ const error = new Error(message);
208
+ error.exitCode = exitCode;
209
+ return error;
210
+ }
package/src/hooks.mjs ADDED
@@ -0,0 +1,252 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { expandHome, parseToml } from "./config.mjs";
4
+ import { quoteShell, runInherit, tryCapture } from "./run.mjs";
5
+
6
+ const PROJECT_CONFIG = ".aiw.toml";
7
+
8
+ export function workspaceHookPlan(config, event, context = {}) {
9
+ const repo = context.repo || context.cwd || process.cwd();
10
+ const cwd = context.cwd || repo;
11
+ const project = projectInfo(repo);
12
+ const sources = [
13
+ {
14
+ kind: "global",
15
+ path: config.aiwPath,
16
+ workspace: config.workspace || {}
17
+ },
18
+ ...globalProjectHookSources(config.workspace || {}, config.aiwPath, project),
19
+ projectHookSource(repo, config.aiwPath)
20
+ ].filter(Boolean);
21
+
22
+ const commands = [];
23
+ for (const source of sources) {
24
+ for (const command of hookCommands(source.workspace, event, source.path)) {
25
+ commands.push({
26
+ event,
27
+ command,
28
+ cwd,
29
+ source: source.kind,
30
+ configPath: source.path,
31
+ project,
32
+ rule: source.rule || ""
33
+ });
34
+ }
35
+ }
36
+ return commands;
37
+ }
38
+
39
+ export async function runWorkspaceHook(config, event, context = {}) {
40
+ const plan = workspaceHookPlan(config, event, context);
41
+ if (plan.length === 0) {
42
+ return;
43
+ }
44
+ if (context.dryRun) {
45
+ printWorkspaceHookPlan(plan);
46
+ return;
47
+ }
48
+ for (const item of plan) {
49
+ const label = item.rule ? `${item.source}/${item.rule}` : item.source;
50
+ console.log(`[aiw hook:${event}] ${label} ${item.command}`);
51
+ try {
52
+ await runInherit("sh", ["-lc", item.command], {
53
+ cwd: item.cwd,
54
+ env: hookEnv(item, context)
55
+ });
56
+ } catch (error) {
57
+ error.message = `workspace hook '${event}' failed from ${item.configPath}: ${error.message}`;
58
+ throw error;
59
+ }
60
+ }
61
+ }
62
+
63
+ export function printWorkspaceHookPlan(plan) {
64
+ for (const item of plan) {
65
+ const label = item.rule ? `${item.source}/${item.rule}` : item.source;
66
+ console.log(`# hook ${item.event} (${label}: ${item.configPath})`);
67
+ console.log(`cd ${quoteShell(item.cwd)} && sh -lc ${quoteShell(item.command)}`);
68
+ }
69
+ }
70
+
71
+ function globalProjectHookSources(workspace, configPath, project) {
72
+ const projects = workspace?.hooks?.projects;
73
+ if (projects === undefined) {
74
+ return [];
75
+ }
76
+ if (!projects || typeof projects !== "object" || Array.isArray(projects)) {
77
+ const error = new Error(`workspace hook projects in ${configPath} must be a table`);
78
+ error.exitCode = 2;
79
+ throw error;
80
+ }
81
+ return Object.entries(projects)
82
+ .filter(([name, entry]) => projectHookEntryMatches(name, entry, project, configPath))
83
+ .map(([name, entry]) => ({
84
+ kind: "global-project",
85
+ rule: name,
86
+ path: configPath,
87
+ workspace: entry
88
+ }));
89
+ }
90
+
91
+ function projectHookSource(repo, globalConfigPath) {
92
+ const configPath = path.join(repo, PROJECT_CONFIG);
93
+ if (configPath === globalConfigPath || !fs.existsSync(configPath)) {
94
+ return null;
95
+ }
96
+ const parsed = parseToml(fs.readFileSync(configPath, "utf8"));
97
+ return {
98
+ kind: "project",
99
+ path: configPath,
100
+ workspace: parsed.workspace || {}
101
+ };
102
+ }
103
+
104
+ function projectHookEntryMatches(name, entry, project, sourcePath) {
105
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
106
+ const error = new Error(`workspace project hook entry in ${sourcePath} must be a table`);
107
+ error.exitCode = 2;
108
+ throw error;
109
+ }
110
+ const namePatterns = [
111
+ ...stringList(entry.match, "match", sourcePath),
112
+ ...stringList(entry.matches, "matches", sourcePath),
113
+ ...stringList(entry.project, "project", sourcePath),
114
+ ...stringList(entry.projects, "projects", sourcePath)
115
+ ];
116
+ const pathPatterns = [
117
+ ...stringList(entry.path, "path", sourcePath),
118
+ ...stringList(entry.paths, "paths", sourcePath),
119
+ ...stringList(entry.repo, "repo", sourcePath),
120
+ ...stringList(entry.repos, "repos", sourcePath)
121
+ ];
122
+ const effectiveNamePatterns = namePatterns.length > 0 ? namePatterns : [name];
123
+ return effectiveNamePatterns.some((pattern) => matchProjectPattern(pattern, project)) ||
124
+ pathPatterns.some((pattern) => matchProjectPath(pattern, project));
125
+ }
126
+
127
+ function hookCommands(workspace, event, sourcePath) {
128
+ const hooks = workspace?.hooks || {};
129
+ const raw = hooks[event] ?? workspace[event];
130
+ if (raw === undefined) {
131
+ return [];
132
+ }
133
+ if (typeof raw === "string") {
134
+ return raw.trim() ? [raw] : [];
135
+ }
136
+ if (Array.isArray(raw) && raw.every((item) => typeof item === "string")) {
137
+ return raw.map((item) => item.trim()).filter(Boolean);
138
+ }
139
+ const error = new Error(`workspace hook '${event}' in ${sourcePath} must be a string or an array of strings`);
140
+ error.exitCode = 2;
141
+ throw error;
142
+ }
143
+
144
+ function hookEnv(item, context) {
145
+ return {
146
+ ...process.env,
147
+ AIW_HOOK_EVENT: item.event,
148
+ AIW_HOOK_SOURCE: item.source,
149
+ AIW_HOOK_CONFIG: item.configPath,
150
+ AIW_HOOK_CWD: item.cwd,
151
+ AIW_HOOK_RULE: item.rule || "",
152
+ AIW_REPO: context.repo || "",
153
+ AIW_PROJECT_PATH: item.project.commonPath || item.project.currentPath || "",
154
+ AIW_PROJECT_NAME: item.project.commonName || item.project.currentName || "",
155
+ AIW_WORKSPACE_PATH: context.workspacePath || item.cwd,
156
+ AIW_WORKSPACE_BRANCH: context.branch || "",
157
+ AIW_WORKSPACE_TARGET: context.target || context.branch || context.workspacePath || "",
158
+ AIW_AGENT: context.agent || ""
159
+ };
160
+ }
161
+
162
+ function stringList(value, key, sourcePath) {
163
+ if (value === undefined) {
164
+ return [];
165
+ }
166
+ if (typeof value === "string") {
167
+ return value.trim() ? [value.trim()] : [];
168
+ }
169
+ if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
170
+ return value.map((item) => item.trim()).filter(Boolean);
171
+ }
172
+ const error = new Error(`workspace project hook '${key}' in ${sourcePath} must be a string or an array of strings`);
173
+ error.exitCode = 2;
174
+ throw error;
175
+ }
176
+
177
+ function projectInfo(repo) {
178
+ const currentPath = normalizePath(repo);
179
+ const commonPath = gitCommonRoot(currentPath) || currentPath;
180
+ return {
181
+ currentPath,
182
+ currentName: path.basename(currentPath),
183
+ commonPath,
184
+ commonName: path.basename(commonPath)
185
+ };
186
+ }
187
+
188
+ function gitCommonRoot(repo) {
189
+ const result = tryCapture("git", ["rev-parse", "--git-common-dir"], { cwd: repo });
190
+ if (!result.ok || !result.stdout) {
191
+ return "";
192
+ }
193
+ const commonDir = normalizePath(path.isAbsolute(result.stdout)
194
+ ? result.stdout
195
+ : path.resolve(repo, result.stdout));
196
+ return path.basename(commonDir) === ".git" ? path.dirname(commonDir) : "";
197
+ }
198
+
199
+ function matchProjectPattern(pattern, project) {
200
+ const normalizedPattern = normalizeMaybePathPattern(pattern);
201
+ return projectIdentities(project).some((identity) => wildcardMatch(normalizedPattern, identity));
202
+ }
203
+
204
+ function matchProjectPath(pattern, project) {
205
+ const normalizedPattern = normalizeMaybePathPattern(pattern);
206
+ return [project.currentPath, project.commonPath].some((identity) => wildcardMatch(normalizedPattern, identity));
207
+ }
208
+
209
+ function projectIdentities(project) {
210
+ return [
211
+ project.currentName,
212
+ project.commonName,
213
+ project.currentPath,
214
+ project.commonPath
215
+ ].filter(Boolean);
216
+ }
217
+
218
+ function normalizeMaybePathPattern(pattern) {
219
+ const expanded = expandHome(pattern);
220
+ if (!looksLikePath(expanded)) {
221
+ return expanded;
222
+ }
223
+ if (expanded.includes("*")) {
224
+ return path.resolve(expanded);
225
+ }
226
+ return normalizePath(expanded);
227
+ }
228
+
229
+ function looksLikePath(value) {
230
+ return value === "~" || value.startsWith("~/") || value.startsWith("/") || value.startsWith(".");
231
+ }
232
+
233
+ function wildcardMatch(pattern, value) {
234
+ if (pattern === value) {
235
+ return true;
236
+ }
237
+ if (!pattern.includes("*")) {
238
+ return false;
239
+ }
240
+ const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
241
+ return new RegExp(`^${escaped}$`).test(value);
242
+ }
243
+
244
+ function normalizePath(value) {
245
+ const expanded = expandHome(value);
246
+ const resolved = path.resolve(expanded);
247
+ try {
248
+ return fs.realpathSync.native(resolved);
249
+ } catch {
250
+ return resolved;
251
+ }
252
+ }