@senomas/pi-git-hat 0.2.5 → 0.2.6
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/git-hat.ts +121 -979
- package/lib/branch-history.ts +143 -0
- package/lib/config.ts +103 -0
- package/lib/git-ui.ts +217 -0
- package/lib/paths.ts +79 -0
- package/lib/role-file.ts +194 -0
- package/lib/todo-utils.ts +244 -0
- package/lib/types.ts +87 -0
- package/package.json +1 -1
- package/roles/_default.md +26 -0
- package/roles/roles.json +4 -2
|
@@ -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
|
+
}
|