@senomas/pi-git-hat 0.2.5 → 0.2.7

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.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Branch history persistence for git-hat.
3
+ *
4
+ * Tracks which branch was last used for each role, and maintains a
5
+ * chronological switch log capped at 50 entries.
6
+ */
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import { writeFile, mkdir } from "node:fs/promises";
10
+ import { execFileSync } from "node:child_process";
11
+ import { resolve } from "node:path";
12
+ import os from "node:os";
13
+
14
+ import type { MergedConfig, BranchHistory, SwitchLogEntry, BranchEntry } from "./types.js";
15
+ import { detectRole } from "./config.js";
16
+
17
+ // -- Persistence paths ------------------------------------------------
18
+
19
+ const AGENT_DIR = resolve(os.homedir(), ".pi", "agent");
20
+ const BRANCH_HISTORY_FILE = resolve(AGENT_DIR, "role_branch.json");
21
+
22
+ /** Load branch history from disk. Handles backward compat: old flat format {role: branch}
23
+ * is converted to new format with a history array.
24
+ * Returns the role→branch mapping and the switch history entries separately. */
25
+ export function loadBranchHistory(): { mapping: BranchHistory; history: SwitchLogEntry[] } {
26
+ try {
27
+ const raw = JSON.parse(readFileSync(BRANCH_HISTORY_FILE, "utf-8"));
28
+ // Detect new format (has "history" array) vs old format (flat {role: branch})
29
+ if (raw && typeof raw === "object" && !Array.isArray(raw) && "history" in raw) {
30
+ const { history, ...mapping } = raw;
31
+ return {
32
+ mapping: mapping as BranchHistory,
33
+ history: Array.isArray(history) ? (history as SwitchLogEntry[]) : [],
34
+ };
35
+ }
36
+ // Old format: flat object — wrap into new shape with empty history
37
+ return { mapping: raw as BranchHistory, history: [] };
38
+ } catch {
39
+ return { mapping: {}, history: [] };
40
+ }
41
+ }
42
+
43
+ /** Persist mapping + history to disk. */
44
+ export async function saveBranchHistory(mapping: BranchHistory, history: SwitchLogEntry[]): Promise<void> {
45
+ await mkdir(AGENT_DIR, { recursive: true });
46
+ await writeFile(
47
+ BRANCH_HISTORY_FILE,
48
+ JSON.stringify({ ...mapping, history }, null, 2),
49
+ "utf-8",
50
+ );
51
+ }
52
+
53
+ /** Record a branch switch for a role. Persisted immediately. */
54
+ export async function recordBranchUsage(role: string, branch: string): Promise<void> {
55
+ if (!role || !branch) return;
56
+ const { mapping, history } = loadBranchHistory();
57
+ mapping[role] = branch;
58
+ history.push({ ts: new Date().toISOString(), role, branch });
59
+ // Cap at 50 entries (trim oldest)
60
+ if (history.length > 50) {
61
+ history.splice(0, history.length - 50);
62
+ }
63
+ await saveBranchHistory(mapping, history);
64
+ }
65
+
66
+ /** List all git branches and match them to roles. */
67
+ export async function listBranchesByRole(config: MergedConfig, cwd: string): Promise<{
68
+ entries: BranchEntry[];
69
+ grouped: Record<string, BranchEntry[]>;
70
+ roleOrder: string[];
71
+ unmatched: string[];
72
+ }> {
73
+ const { mapping: history } = loadBranchHistory();
74
+ const entries: BranchEntry[] = [];
75
+ const grouped: Record<string, BranchEntry[]> = {};
76
+ const unmatched: string[] = [];
77
+
78
+ // Get current branch
79
+ let currentBranch: string | null = null;
80
+ try {
81
+ const stdout = execFileSync("git", ["branch", "--show-current"], {
82
+ cwd,
83
+ encoding: "utf-8",
84
+ }) as string;
85
+ currentBranch = stdout.trim() || null;
86
+ } catch {
87
+ /* not a git repo */
88
+ }
89
+
90
+ // Get all branches
91
+ let branches: string[] = [];
92
+ try {
93
+ const stdout = execFileSync("git", ["branch", "--format", "%(refname:short)"], {
94
+ cwd,
95
+ encoding: "utf-8",
96
+ }) as string;
97
+ branches = stdout.trim().split("\n").filter(Boolean);
98
+ } catch {
99
+ return { entries, grouped, roleOrder: [], unmatched };
100
+ }
101
+
102
+ // Match each branch to a role
103
+ for (const branch of branches) {
104
+ const role = detectRole(branch, config);
105
+ if (!role) {
106
+ unmatched.push(branch);
107
+ continue;
108
+ }
109
+
110
+ const isCurrent = branch === currentBranch;
111
+ const isLastUsed = history[role] === branch;
112
+ const entry: BranchEntry = { branch, role, isCurrent, isLastUsed };
113
+ entries.push(entry);
114
+ if (!grouped[role]) grouped[role] = [];
115
+ grouped[role].push(entry);
116
+ }
117
+
118
+ // Sort within each role: current -> last-used -> rest (alpha)
119
+ for (const role of Object.keys(grouped)) {
120
+ grouped[role].sort((a, b) => {
121
+ if (a.isCurrent) return -1;
122
+ if (b.isCurrent) return 1;
123
+ if (a.isLastUsed) return -1;
124
+ if (b.isLastUsed) return 1;
125
+ return a.branch.localeCompare(b.branch);
126
+ });
127
+ }
128
+
129
+ // Role order: roles with current branch first, then last-used, then alpha
130
+ const roleOrder = [...new Set(entries.map((e) => e.role))].sort((a, b) => {
131
+ const aHasCurrent = grouped[a]?.some((e) => e.isCurrent);
132
+ const bHasCurrent = grouped[b]?.some((e) => e.isCurrent);
133
+ if (aHasCurrent && !bHasCurrent) return -1;
134
+ if (!aHasCurrent && bHasCurrent) return 1;
135
+ const aHasLast = grouped[a]?.some((e) => e.isLastUsed);
136
+ const bHasLast = grouped[b]?.some((e) => e.isLastUsed);
137
+ if (aHasLast && !bHasLast) return -1;
138
+ if (!aHasLast && bHasLast) return 1;
139
+ return a.localeCompare(b);
140
+ });
141
+
142
+ return { entries, grouped, roleOrder, unmatched };
143
+ }
package/lib/config.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Config loading for git-hat.
3
+ *
4
+ * Loads roles.json from project .pi/ or global ~/.pi/agent/,
5
+ * then walks up from CWD for .pi-project.json overrides.
6
+ */
7
+
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { resolve, dirname } from "node:path";
10
+ import os from "node:os";
11
+
12
+ import type { MergedConfig, RolesConfig } from "./types.js";
13
+
14
+ const HOME_PI_DIR = resolve(os.homedir(), ".pi");
15
+
16
+ /** Load and merge roles configuration from disk. */
17
+ export function loadConfig(): MergedConfig {
18
+ const merged: MergedConfig = {
19
+ roles: {},
20
+ fileDir: ".pi",
21
+ caseInsensitive: true,
22
+
23
+ postSwitchLog: true,
24
+ };
25
+
26
+ // Search paths for roles.json (first found wins):
27
+ // 1. {PROJECT_ROOT}/.pi/roles.json (project-specific, inside .pi/)
28
+ // 2. ~/.pi/agent/roles.json (global fallback, inside git repo)
29
+ const roleFileCandidates = [
30
+ resolve(process.cwd(), ".pi", "roles.json"),
31
+ resolve(HOME_PI_DIR, "agent", "roles.json"),
32
+ ];
33
+
34
+ let loadedRoles = false;
35
+ for (const candidatePath of roleFileCandidates) {
36
+ if (existsSync(candidatePath)) {
37
+ try {
38
+ const raw = JSON.parse(readFileSync(candidatePath, "utf-8")) as RolesConfig;
39
+ if (raw.roles) {
40
+ Object.assign(merged.roles, raw.roles);
41
+ loadedRoles = true;
42
+ }
43
+ if (raw.fileDir) merged.fileDir = raw.fileDir;
44
+ if (raw.caseInsensitive !== undefined) merged.caseInsensitive = raw.caseInsensitive;
45
+
46
+ if (raw.postSwitchLog !== undefined) merged.postSwitchLog = raw.postSwitchLog;
47
+ if (raw.default) {
48
+ if (raw.default["pre-tool"] !== undefined) merged.preTool = raw.default["pre-tool"];
49
+ if (raw.default["post-tool"] !== undefined) merged.postTool = raw.default["post-tool"];
50
+ }
51
+ } catch {
52
+ // ignore parse errors
53
+ }
54
+ merged.configFile = candidatePath;
55
+ break; // first matching path wins
56
+ }
57
+ }
58
+
59
+ if (!loadedRoles) {
60
+ const searched = roleFileCandidates.join(", ");
61
+ throw new Error(
62
+ `roles.json not found. Searched:\n ${searched}\n\n` +
63
+ `Create one at the project root (~/.pi/agent/roles.json) or inside `.pi/` ({project}/.pi/roles.json).`
64
+ );
65
+ }
66
+
67
+ // Walk up from CWD to find .pi-project.json for project overrides
68
+ // Project-level roles fully override matching base roles
69
+ let dir = process.cwd();
70
+ while (true) {
71
+ const candidate = resolve(dir, ".pi-project.json");
72
+ if (existsSync(candidate)) {
73
+ try {
74
+ const projectRaw = JSON.parse(readFileSync(candidate, "utf-8")) as RolesConfig;
75
+ if (projectRaw.roles) {
76
+ for (const [name, def] of Object.entries(projectRaw.roles)) {
77
+ merged.roles[name] = def;
78
+ }
79
+ }
80
+ } catch {
81
+ // ignore parse errors
82
+ }
83
+ break;
84
+ }
85
+ const parent = dirname(dir);
86
+ if (parent === dir) break;
87
+ dir = parent;
88
+ }
89
+
90
+ return merged;
91
+ }
92
+
93
+ /** Detect the role for a given branch name by iterating merged roles. */
94
+ export function detectRole(branch: string, config: MergedConfig): string | null {
95
+ for (const [roleName, roleDef] of Object.entries(config.roles)) {
96
+ try {
97
+ if (new RegExp(roleDef.pattern).test(branch)) return roleName;
98
+ } catch {
99
+ // skip invalid regex
100
+ }
101
+ }
102
+ return null; // no match -> "unknown"
103
+ }
package/lib/git-ui.ts ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Git UI helpers for git-hat.
3
+ *
4
+ * Functions for displaying colored git logs, role icons,
5
+ * ANSI colorization of ref names, and working tree status.
6
+ */
7
+
8
+ /**
9
+ * Exec function signature matching ExtensionAPI.exec().
10
+ * Accepts a command and args array, returns { stdout, stderr, code }.
11
+ */
12
+ export type ExecFunction = (
13
+ command: string,
14
+ args: string[],
15
+ ) => Promise<{ stdout: string; stderr: string; code: number }>;
16
+
17
+ /** Role icon for status bar display. */
18
+ export function roleIcon(role: string): string {
19
+ const lower = role.toLowerCase();
20
+ if (lower === "planner") return "\uD83D\uDCCB";
21
+ if (lower === "implementor") return "\uD83D\uDEE0";
22
+ if (lower === "reviewer") return "\uD83D\uDD0D";
23
+ if (lower === "admin") return "\u2699";
24
+ if (lower === "researcher") return "\uD83D\uDD2C";
25
+ return "\uD83E\uDDE2";
26
+ }
27
+
28
+ // -- ANSI color palette for git ref log decoration ----------------
29
+
30
+ const REF_COLORS = [
31
+ "38;5;51", // cyan
32
+ "38;5;118", // green
33
+ "38;5;226", // yellow
34
+ "38;5;207", // magenta
35
+ "38;5;196", // red
36
+ "38;5;75", // blue
37
+ "38;5;214", // orange
38
+ "38;5;201", // purple
39
+ ];
40
+
41
+ const HEAD_ANSI = "1;37"; // bold bright white
42
+
43
+ /**
44
+ * Colorize ref names in `git log --oneline --decorate` output.
45
+ * Parses `(ref-list)` patterns and wraps each ref in distinct ANSI
46
+ * 256-color codes. HEAD gets a fixed bold white. All other refs
47
+ * (branches, tags) rotate through an 8-color palette consistently.
48
+ */
49
+ export function colorizeLog(log: string): string {
50
+ // First pass: collect all unique ref names for consistent color assignment
51
+ const refNames = new Set<string>();
52
+ const refListRe = /\(([^)]+)\)/g;
53
+ let m: RegExpExecArray | null;
54
+ while ((m = refListRe.exec(log)) !== null) {
55
+ for (const part of m[1].split(/,\s*/)) {
56
+ const trimmed = part
57
+ .replace(/^(HEAD -> |HEAD\b|tag: |tags: )/g, "")
58
+ .trim();
59
+ if (trimmed) refNames.add(trimmed);
60
+ }
61
+ }
62
+
63
+ // Assign a rotating color to each unique ref name
64
+ const colorMap = new Map<string, string>();
65
+ let idx = 0;
66
+ for (const name of refNames) {
67
+ colorMap.set(name, REF_COLORS[idx % REF_COLORS.length]);
68
+ idx++;
69
+ }
70
+
71
+ // Second pass: wrap each ref in the `(...)` list with ANSI codes
72
+ return log
73
+ .split("\n")
74
+ .map((line) => {
75
+ return line.replace(
76
+ /\(([^)]+)\)/g,
77
+ (_, refList: string) => {
78
+ const colored = refList
79
+ .split(/,\s*/)
80
+ .map((ref: string) => {
81
+ // "HEAD -> branchname"
82
+ const headArrow = ref.match(/^(HEAD)\s*->\s*(.+)$/);
83
+ if (headArrow) {
84
+ const head = `\x1b[${HEAD_ANSI}mHEAD\x1b[0m`;
85
+ const branchColor = colorMap.get(headArrow[2]) || REF_COLORS[0];
86
+ const branch = `\x1b[${branchColor}m${headArrow[2]}\x1b[0m`;
87
+ return `${head} -> ${branch}`;
88
+ }
89
+ // bare HEAD (detached)
90
+ if (ref === "HEAD") {
91
+ return `\x1b[${HEAD_ANSI}mHEAD\x1b[0m`;
92
+ }
93
+ // "tag: tagname"
94
+ const tagMatch = ref.match(/^tag:\s*(.+)$/);
95
+ if (tagMatch) {
96
+ const color = colorMap.get(tagMatch[1]) || REF_COLORS[0];
97
+ return `\x1b[${color}m${ref}\x1b[0m`;
98
+ }
99
+ // plain ref (branch, remote)
100
+ const color = colorMap.get(ref) || REF_COLORS[0];
101
+ return `\x1b[${color}m${ref}\x1b[0m`;
102
+ })
103
+ .join(", ");
104
+ return `(${colored})`;
105
+ },
106
+ );
107
+ })
108
+ .join("\n");
109
+ }
110
+
111
+ /**
112
+ * Run a colored git log and display it in the TUI.
113
+ * Shared between /hatl and the post-switch log in /hat.
114
+ *
115
+ * @param ctx - TUI context with ui.notify
116
+ * @param count - Number of log lines (default 10)
117
+ * @param execFn - Optional exec function (e.g. pi.exec). If omitted, falls back
118
+ * to child_process.execFileSync for backward compatibility.
119
+ */
120
+ export async function showGitLog(
121
+ ctx: { ui: { notify: (msg: string, level: string) => void } },
122
+ count: number = 10,
123
+ execFn?: ExecFunction,
124
+ ): Promise<void> {
125
+ const validCount = Number.isFinite(count) && count > 0 ? count : 10;
126
+
127
+ try {
128
+ let raw: string;
129
+
130
+ if (execFn) {
131
+ // Use the provided exec function (e.g. pi.exec from the extension API)
132
+ const result = await execFn("git", [
133
+ "log",
134
+ "--graph",
135
+ "--oneline",
136
+ "--decorate",
137
+ "--all",
138
+ `-${validCount}`,
139
+ ]);
140
+ raw = result.stdout.trim();
141
+ } else {
142
+ // Fallback to child_process.execFileSync for backward compatibility
143
+ const result = await import("child_process").then((cp) =>
144
+ cp.execFileSync("git", [
145
+ "log",
146
+ "--graph",
147
+ "--oneline",
148
+ "--decorate",
149
+ "--all",
150
+ `-${validCount}`,
151
+ ], { encoding: "utf-8" }),
152
+ ) as string;
153
+ raw = result.trim();
154
+ }
155
+
156
+ if (!raw) {
157
+ return; // silently ignore empty log
158
+ }
159
+
160
+ const colored = colorizeLog(raw);
161
+ const footer = `\n\x1b[90m\u2022 /hatl N for more lines\x1b[0m`;
162
+ ctx.ui.notify(colored + footer, "info");
163
+ } catch {
164
+ // non-critical — silently ignore
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Display git working tree status.
170
+ *
171
+ * - If the working tree is clean: shows a green checkmark summary.
172
+ * - If dirty: runs full `git status` and displays the raw output.
173
+ *
174
+ * @param ctx - TUI context with ui.notify
175
+ * @param branch - Current branch name
176
+ * @param execFn - Optional exec function (e.g. pi.exec). If omitted, falls back
177
+ * to child_process.execFileSync for backward compatibility.
178
+ */
179
+ export async function showGitStatus(
180
+ ctx: { ui: { notify: (msg: string, level: string) => void } },
181
+ branch: string,
182
+ execFn?: ExecFunction,
183
+ ): Promise<void> {
184
+ try {
185
+ let shortOutput: string;
186
+
187
+ if (execFn) {
188
+ const result = await execFn("git", ["status", "--short"]);
189
+ shortOutput = result.stdout;
190
+ } else {
191
+ const result = await import("child_process").then((cp) =>
192
+ cp.execFileSync("git", ["status", "--short"], { encoding: "utf-8" }),
193
+ ) as string;
194
+ shortOutput = result as string;
195
+ }
196
+
197
+ if (shortOutput.trim().length === 0) {
198
+ // Clean working tree
199
+ ctx.ui.notify(`\x1b[32m\u2713\x1b[0m working tree clean (branch: ${branch})`, "info");
200
+ } else {
201
+ // Dirty — show full status
202
+ let fullOutput: string;
203
+ if (execFn) {
204
+ const result = await execFn("git", ["status"]);
205
+ fullOutput = result.stdout;
206
+ } else {
207
+ const result = await import("child_process").then((cp) =>
208
+ cp.execFileSync("git", ["status"], { encoding: "utf-8" }),
209
+ ) as string;
210
+ fullOutput = result as string;
211
+ }
212
+ ctx.ui.notify(fullOutput.trim(), "info");
213
+ }
214
+ } catch {
215
+ // non-critical — silently ignore
216
+ }
217
+ }
package/lib/paths.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Path utilities for git-hat.
3
+ *
4
+ * Helpers for normalising file paths, stripping cd prefixes,
5
+ * checking if a path is inside allowed directories, and matching
6
+ * writable path entries.
7
+ */
8
+
9
+ import { resolve } from "node:path";
10
+
11
+ import type { WritablePathEntry } from "./types.js";
12
+
13
+ /** Normalise a file path argument: strip leading ./, absolute -> relative. */
14
+ export function normalisePath(raw: string, cwd: string, cwdAbsolute: string): string {
15
+ let path = raw;
16
+ if (path.startsWith(cwdAbsolute + "/")) path = path.slice(cwdAbsolute.length + 1);
17
+ if (path.startsWith("./")) path = path.slice(2);
18
+ return path;
19
+ }
20
+
21
+ /**
22
+ * Strip a leading `cd <dir> && ` prefix from a bash command if <dir> resolves
23
+ * to a path inside the project. Supports relative paths (resolved against cwd).
24
+ *
25
+ * Examples:
26
+ * "cd docs && ls -la" → "ls -la"
27
+ * "cd ../outside && ls" → "cd ../outside && ls" (unchanged — outside project)
28
+ * "ls -la" → "ls -la" (unchanged — no cd prefix)
29
+ */
30
+ export function stripCdPrefix(cmd: string, cwd: string, cwdAbsolute: string): string {
31
+ const match = cmd.match(/^cd\s+(\S+)\s*&&\s*/);
32
+ if (!match) return cmd;
33
+ const dir = match[1];
34
+ const rest = cmd.slice(match[0].length);
35
+ // Resolve the directory: absolute path, relative path, or ~
36
+ let resolved: string;
37
+ if (dir.startsWith("/")) {
38
+ resolved = dir;
39
+ } else if (dir.startsWith("~")) {
40
+ return cmd; // home dir — can't guarantee it's inside project, keep as-is
41
+ } else {
42
+ resolved = resolve(cwd, dir);
43
+ }
44
+ // Check if resolved path is inside the project
45
+ if (resolved.startsWith(cwdAbsolute)) {
46
+ return rest;
47
+ }
48
+ return cmd;
49
+ }
50
+
51
+ /** Check if a path is inside one of the given directories. */
52
+ export function isInside(path: string, dirs: string[]): boolean {
53
+ return dirs.some((d) => path === d || path.startsWith(d + "/"));
54
+ }
55
+
56
+ /**
57
+ * Check if a path matches one of the given writable path entries.
58
+ *
59
+ * - If `entry.path` is empty string, only root-level files match.
60
+ * - If `entry.extension` is set, the path must end with that extension.
61
+ * - A trailing slash is appended automatically when matching directories.
62
+ */
63
+ export function isWritablePath(path: string, writablePaths: WritablePathEntry[]): boolean {
64
+ return writablePaths.some((entry) => {
65
+ const dir = entry.path;
66
+ const ext = entry.extension;
67
+ // Check directory match
68
+ const inDir =
69
+ dir === ""
70
+ ? !path.includes("/") // root level only
71
+ : path === dir || path.startsWith(dir + "/");
72
+ if (!inDir) return false;
73
+ // Check extension filter (if set)
74
+ if (ext) {
75
+ return path.endsWith("." + ext);
76
+ }
77
+ return true; // no extension filter = allow any file in this dir
78
+ });
79
+ }