@trygocode/notify 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.
@@ -0,0 +1,215 @@
1
+ // gocode-notify MCP server mode (`gocode-notify mcp`) — PRD §4.3.
2
+ //
3
+ // A minimal stdio MCP server exposing EXACTLY two tools (keep it tiny — more
4
+ // tools = more agent confusion):
5
+ // - gocode_notify → on-demand push (thin wrapper over internal send())
6
+ // - gocode_notify_status → creds-present + server-reachable self-diagnosis
7
+ //
8
+ // Both handlers funnel through the SAME internals as the CLI (`send()` /
9
+ // `gatherStatus()`), so behaviour is identical across every trigger (hook /
10
+ // loop / MCP / CLI). We use the official MCP TypeScript SDK low-level `Server`
11
+ // with raw JSON-Schema tool definitions — no zod in our OWN code — so the
12
+ // package's direct dependency stays just `@modelcontextprotocol/sdk`.
13
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
14
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
16
+ import { VERSION } from "./version.js";
17
+ import { resolveServerUrl } from "./creds.js";
18
+ import { send, isNotifyKind, NOTIFY_KINDS, } from "./send.js";
19
+ import { gatherStatus } from "./status.js";
20
+ /** Server identity advertised in the MCP `initialize` handshake. */
21
+ export const SERVER_NAME = "gocode-notify";
22
+ /**
23
+ * Public website for this MCP server, surfaced in the MCP `initialize`
24
+ * handshake (`Implementation.websiteUrl`, SEP-973). Clients that support it
25
+ * (MCP Inspector today; more clients over time) link the server to its docs.
26
+ */
27
+ export const SERVER_WEBSITE_URL = "https://github.com/joseph-lewis/gocode-notify";
28
+ /**
29
+ * Icons advertised in the MCP handshake (`Implementation.icons`, SEP-973) so
30
+ * clients can show the GoCode mark next to this server in their UI. Served from
31
+ * the public repo's `assets/` over GitHub's raw CDN so there's nothing to host.
32
+ *
33
+ * NOTE: as of 2026, Cursor does not yet RENDER custom MCP server icons (it uses
34
+ * hardcoded marks for a few popular servers), so this stays invisible there for
35
+ * now — but it already shows in MCP Inspector and other spec-compliant clients,
36
+ * and will light up in Cursor automatically once it adds rendering. Cheap +
37
+ * future-proof; no behaviour depends on it.
38
+ */
39
+ export const SERVER_ICONS = [
40
+ {
41
+ src: "https://raw.githubusercontent.com/joseph-lewis/gocode-notify/main/assets/icon-128.png",
42
+ mimeType: "image/png",
43
+ sizes: ["128x128"],
44
+ },
45
+ {
46
+ src: "https://raw.githubusercontent.com/joseph-lewis/gocode-notify/main/assets/icon.svg",
47
+ mimeType: "image/svg+xml",
48
+ sizes: ["any"],
49
+ },
50
+ ];
51
+ /** Tool name an agent calls to send an on-demand push. */
52
+ export const NOTIFY_TOOL = "gocode_notify";
53
+ /** Tool name an agent calls to self-diagnose pairing/reachability. */
54
+ export const STATUS_TOOL = "gocode_notify_status";
55
+ /**
56
+ * The two tools this server exposes (PRD §4.3). Declared as a plain constant so
57
+ * the handshake smoke test can assert the exact shape without spinning the
58
+ * transport. `gocode_notify`'s schema mirrors the documented args
59
+ * `{ kind?, title, body?, project? }`; only `title` is required.
60
+ */
61
+ export const TOOLS = [
62
+ {
63
+ name: NOTIFY_TOOL,
64
+ description: "Send a push notification to the user's phone via the GoCode app. Call " +
65
+ "this ONLY when the user has EXPLICITLY asked to be pinged when something " +
66
+ "finishes (e.g. \"text me when the build is done\"). Routine end-of-turn " +
67
+ "notifications are delivered automatically by client hooks — do NOT call " +
68
+ "this for those, or the user gets double-pinged.",
69
+ inputSchema: {
70
+ type: "object",
71
+ properties: {
72
+ kind: {
73
+ type: "string",
74
+ enum: [...NOTIFY_KINDS],
75
+ description: 'Notification kind. Defaults to "finished".',
76
+ },
77
+ title: {
78
+ type: "string",
79
+ description: "Short notification title (required).",
80
+ },
81
+ body: { type: "string", description: "Optional longer body text." },
82
+ project: {
83
+ type: "string",
84
+ description: "Optional project name shown for context.",
85
+ },
86
+ },
87
+ required: ["title"],
88
+ additionalProperties: false,
89
+ },
90
+ },
91
+ {
92
+ name: STATUS_TOOL,
93
+ description: "Report whether GoCode credentials are present and the notify server is " +
94
+ "reachable. Use this to self-diagnose: if it reports NOT paired, tell the " +
95
+ "user to run `gocode-notify login`.",
96
+ inputSchema: {
97
+ type: "object",
98
+ properties: {},
99
+ additionalProperties: false,
100
+ },
101
+ },
102
+ ];
103
+ function textResult(text) {
104
+ return { content: [{ type: "text", text }] };
105
+ }
106
+ function errorResult(text) {
107
+ return { content: [{ type: "text", text }], isError: true };
108
+ }
109
+ /**
110
+ * Handle a `gocode_notify` tool call. Validates `title` (required) and `kind`
111
+ * (defaults to `finished`, must be a canonical kind), then funnels through the
112
+ * shared internal {@link send}. Unlike the fire-and-forget hook path, an
113
+ * on-demand agent ping does NOT queue to the offline outbox — a stale push
114
+ * surfacing later would confuse the user; instead a failure is reported back so
115
+ * the agent can tell the user to re-pair.
116
+ */
117
+ export async function handleNotify(args, deps = {}) {
118
+ const a = args ?? {};
119
+ const title = typeof a.title === "string" ? a.title.trim() : "";
120
+ if (title === "") {
121
+ return errorResult("gocode_notify: `title` is required.");
122
+ }
123
+ const kind = a.kind === undefined ? "finished" : a.kind;
124
+ if (!isNotifyKind(kind)) {
125
+ return errorResult(`gocode_notify: invalid kind "${String(a.kind)}" (expected one of: ${NOTIFY_KINDS.join(", ")}).`);
126
+ }
127
+ const payload = { kind, title, source: "mcp" };
128
+ if (typeof a.body === "string" && a.body !== "")
129
+ payload.body = a.body;
130
+ if (typeof a.project === "string" && a.project !== "")
131
+ payload.project = a.project;
132
+ const server = await resolveServerUrl(deps.serverFlag, deps);
133
+ const sendOpts = {
134
+ home: deps.home,
135
+ fetchImpl: deps.fetchImpl,
136
+ timeoutMs: deps.timeoutMs,
137
+ server,
138
+ };
139
+ const result = await send(payload, sendOpts);
140
+ if (result.ok) {
141
+ const where = typeof result.sent === "number"
142
+ ? ` (delivered to ${result.sent} device${result.sent === 1 ? "" : "s"})`
143
+ : "";
144
+ return textResult(`Sent ${payload.kind} notification "${title}"${where}.`);
145
+ }
146
+ return errorResult(`Could not send notification: ${result.error}. ` +
147
+ "If this machine is not paired, ask the user to run `gocode-notify login`.");
148
+ }
149
+ /**
150
+ * Handle a `gocode_notify_status` tool call (no args). Returns a short report of
151
+ * credential presence + server reachability so the agent can self-diagnose.
152
+ * `isError` is set when not paired OR unreachable, so the agent surfaces the
153
+ * problem rather than silently assuming notifications will arrive.
154
+ */
155
+ export async function handleStatus(deps = {}) {
156
+ const report = await gatherStatus({
157
+ home: deps.home,
158
+ serverFlag: deps.serverFlag,
159
+ fetchImpl: deps.fetchImpl,
160
+ timeoutMs: deps.timeoutMs,
161
+ });
162
+ const c = report.credentials;
163
+ const lines = [];
164
+ if (c.present)
165
+ lines.push(`Credentials: paired as ${c.user_id} (${c.label}).`);
166
+ else if (c.error)
167
+ lines.push(`Credentials: unreadable — ${c.error}.`);
168
+ else
169
+ lines.push("Credentials: NOT paired — run `gocode-notify login`.");
170
+ lines.push(`Server: ${report.server.url} — ${report.server.reachable ? "reachable" : "unreachable"} (${report.server.detail}).`);
171
+ const ok = c.present && report.server.reachable;
172
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: !ok };
173
+ }
174
+ /**
175
+ * Build the MCP {@link Server} with the two tools registered. Pure (does not
176
+ * touch the network or connect a transport) so the handshake smoke test can
177
+ * drive it over an in-memory transport.
178
+ */
179
+ export function createMcpServer(deps = {}) {
180
+ const server = new Server({
181
+ name: SERVER_NAME,
182
+ version: VERSION,
183
+ // SEP-973 server metadata: icons + website for client UIs that support it
184
+ // (MCP Inspector now; more clients over time). Ignored by clients that
185
+ // don't read them.
186
+ icons: [...SERVER_ICONS],
187
+ websiteUrl: SERVER_WEBSITE_URL,
188
+ }, { capabilities: { tools: {} } });
189
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...TOOLS] }));
190
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
191
+ const { name, arguments: args } = req.params;
192
+ if (name === NOTIFY_TOOL) {
193
+ return handleNotify(args, deps);
194
+ }
195
+ if (name === STATUS_TOOL) {
196
+ return handleStatus(deps);
197
+ }
198
+ return errorResult(`Unknown tool: ${name}`);
199
+ });
200
+ return server;
201
+ }
202
+ /**
203
+ * Run the MCP server over stdio until the client disconnects (stdin EOF). The
204
+ * returned promise resolves only once the connection closes, so the CLI bin
205
+ * stays alive for the lifetime of the session instead of exiting immediately
206
+ * after the handshake.
207
+ */
208
+ export async function serveStdio(deps = {}) {
209
+ const server = createMcpServer(deps);
210
+ const transport = new StdioServerTransport();
211
+ await server.connect(transport);
212
+ await new Promise((resolve) => {
213
+ server.onclose = () => resolve();
214
+ });
215
+ }
@@ -0,0 +1,150 @@
1
+ // Offline outbox for gocode-notify (PRD §4.4).
2
+ //
3
+ // When a `send` cannot reach the server, the notification is enqueued to
4
+ // `~/.gocode/outbox/` and flushed opportunistically on the next `send`.
5
+ // The contract (PRD §4.4) is deliberately SIMPLE and best-effort:
6
+ // - Cap the queue at {@link MAX_OUTBOX_ENTRIES}; when full, drop the OLDEST.
7
+ // - A missed "done" ping is acceptable; a blocked agent is not — so every
8
+ // operation here is best-effort and never throws for routine I/O issues.
9
+ //
10
+ // Each queued notification is one JSON file named with a zero-padded, strictly
11
+ // increasing sequence so that lexicographic filename order === enqueue order.
12
+ // That makes "oldest" unambiguous for both drop-oldest and flush ordering
13
+ // without needing a wall-clock that could collide or run backwards.
14
+ //
15
+ // Zero runtime deps — only Node built-ins, to match the package's zero-dep rule.
16
+ import { promises as fs } from "node:fs";
17
+ import path from "node:path";
18
+ import { gocodeDir } from "./creds.js";
19
+ /** Maximum notifications retained in the outbox; older ones are dropped. */
20
+ export const MAX_OUTBOX_ENTRIES = 100;
21
+ /** Width of the zero-padded sequence in entry filenames (sortable to 1e12-1). */
22
+ const SEQ_WIDTH = 12;
23
+ /** Only files matching this are treated as outbox entries. */
24
+ const ENTRY_RE = /^(\d{12})\.json$/;
25
+ /** Absolute path to the `~/.gocode/outbox/` directory. */
26
+ export function outboxDir(opts) {
27
+ return path.join(gocodeDir(opts), "outbox");
28
+ }
29
+ /** Pad a sequence number to a fixed, lexicographically-sortable width. */
30
+ function seqToName(seq) {
31
+ return `${String(seq).padStart(SEQ_WIDTH, "0")}.json`;
32
+ }
33
+ /**
34
+ * List entry filenames in enqueue order (oldest first). Returns [] when the
35
+ * outbox dir is absent. Non-entry files are ignored.
36
+ */
37
+ export async function listOutbox(opts) {
38
+ let names;
39
+ try {
40
+ names = await fs.readdir(outboxDir(opts));
41
+ }
42
+ catch (err) {
43
+ if (err.code === "ENOENT")
44
+ return [];
45
+ throw err;
46
+ }
47
+ return names.filter((n) => ENTRY_RE.test(n)).sort();
48
+ }
49
+ /** Parse the numeric sequence out of an entry filename. */
50
+ function seqOf(name) {
51
+ const m = ENTRY_RE.exec(name);
52
+ return m ? Number(m[1]) : 0;
53
+ }
54
+ /**
55
+ * Append `payload` to the outbox. Enforces the cap by dropping the OLDEST
56
+ * entries first, then writes the new entry with the next sequence. Best-effort:
57
+ * routine I/O failures are swallowed (a queued ping must never block the agent).
58
+ * Returns the entry filename on success, or null if it could not be written.
59
+ */
60
+ export async function enqueue(payload, opts = {}) {
61
+ const max = opts.max ?? MAX_OUTBOX_ENTRIES;
62
+ const dir = outboxDir(opts);
63
+ try {
64
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
65
+ const existing = await listOutbox(opts);
66
+ // Drop oldest until there's room for one more (cap includes the new entry).
67
+ let entries = existing;
68
+ while (entries.length >= max && entries.length > 0) {
69
+ const oldest = entries[0];
70
+ await fs.rm(path.join(dir, oldest), { force: true });
71
+ entries = entries.slice(1);
72
+ }
73
+ // If the cap is 0 (or less), we drop everything and keep nothing.
74
+ if (max <= 0)
75
+ return null;
76
+ const nextSeq = entries.length > 0 ? seqOf(entries[entries.length - 1]) + 1 : 0;
77
+ const name = seqToName(nextSeq);
78
+ const entry = {
79
+ payload,
80
+ enqueued_at: opts.timestamp ? opts.timestamp() : new Date().toISOString(),
81
+ };
82
+ await fs.writeFile(path.join(dir, name), JSON.stringify(entry, null, 2) + "\n");
83
+ return name;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ /** Read and parse one entry file; returns null if missing or malformed. */
90
+ async function readEntry(dir, name) {
91
+ let raw;
92
+ try {
93
+ raw = await fs.readFile(path.join(dir, name), "utf8");
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ try {
99
+ const parsed = JSON.parse(raw);
100
+ if (parsed && typeof parsed === "object" && parsed.payload && typeof parsed.payload === "object") {
101
+ return parsed;
102
+ }
103
+ }
104
+ catch {
105
+ // Fall through — malformed entry.
106
+ }
107
+ return null;
108
+ }
109
+ /**
110
+ * Flush queued notifications oldest-first via `sender`. Each successfully sent
111
+ * entry is removed. On the FIRST delivery failure we stop and leave the rest
112
+ * queued — the server is presumably still unreachable, so hammering it (and
113
+ * delaying the caller) buys nothing. A malformed/unreadable entry is dropped
114
+ * (it can never succeed). Best-effort and never throws for routine I/O.
115
+ */
116
+ export async function flush(sender, opts) {
117
+ const dir = outboxDir(opts);
118
+ let names;
119
+ try {
120
+ names = await listOutbox(opts);
121
+ }
122
+ catch {
123
+ return { sent: 0, remaining: 0 };
124
+ }
125
+ let sent = 0;
126
+ let index = 0;
127
+ for (; index < names.length; index++) {
128
+ const name = names[index];
129
+ const entry = await readEntry(dir, name);
130
+ if (!entry) {
131
+ // Unrecoverable entry — discard it so it never wedges the queue.
132
+ await fs.rm(path.join(dir, name), { force: true }).catch(() => { });
133
+ continue;
134
+ }
135
+ let result;
136
+ try {
137
+ result = await sender(entry.payload);
138
+ }
139
+ catch {
140
+ // Treat a throwing sender as a delivery failure: stop, keep the rest.
141
+ break;
142
+ }
143
+ if (!result.ok)
144
+ break;
145
+ await fs.rm(path.join(dir, name), { force: true }).catch(() => { });
146
+ sent++;
147
+ }
148
+ const remaining = (await listOutbox(opts).catch(() => [])).length;
149
+ return { sent, remaining };
150
+ }
@@ -0,0 +1,278 @@
1
+ // `gocode-notify push-on-stop` — the dev-machine (P1) auto-push git flow (PRD §2.1).
2
+ //
3
+ // SCOPE OF THIS MODULE (T-C3): the GIT FLOW itself — steps 2–9 of PRD §2.1 —
4
+ // driven entirely through an INJECTED git runner so it is unit-testable with a
5
+ // fake `git` and a stubbed `send` (zero real subprocesses, zero network):
6
+ //
7
+ // gate-off no-op · not-a-repo no-op · branch resolution + protected-branch
8
+ // guard · clean-tree no-op · compose commit message · commit (-F tempfile) ·
9
+ // fast-forward push (NEVER --force) · non-FF rejection → error notification ·
10
+ // success → ONE `finished` notification. ALL paths resolve and map to exit 0.
11
+ //
12
+ // What this module deliberately does NOT do (kept out of scope on purpose):
13
+ // • Settings RESOLUTION / 60s cache / network round-trip (PRD §2.1 step 1) —
14
+ // that is `config.ts` (T-C5). `pushOnStop` takes ALREADY-RESOLVED, merged
15
+ // settings as input; when none are supplied it fails SAFE (auto-push OFF).
16
+ // • The `on-stop` dispatcher + Cursor/Claude hook rewire (PRD §2.2) — that is
17
+ // T-C6. This module is the standalone, independently-callable git flow it
18
+ // will delegate to.
19
+ //
20
+ // Zero runtime deps — Node built-ins only (child_process / fs / os), matching the
21
+ // package's zero-dependency rule. Git is invoked via the injectable runner whose
22
+ // default shells out with `child_process.spawn`.
23
+ import { spawn } from "node:child_process";
24
+ import { promises as fs } from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import { composeCommitMessage, parseNumstat, } from "./commit_message.js";
28
+ import { appendLog, send } from "./send.js";
29
+ /** Branches we refuse to auto-push to unless `allow_protected` is explicitly on (PRD §0.5). */
30
+ export const PROTECTED_BRANCHES = new Set(["main", "master"]);
31
+ /**
32
+ * Default git runner: spawn `git <args>` in `cwd`, capture stdout/stderr, and
33
+ * resolve with the exit code. NEVER rejects — a spawn error (e.g. git missing)
34
+ * resolves to a non-zero code so the flow can treat it as "not a repo / no git"
35
+ * rather than throwing and risking a non-zero process exit.
36
+ */
37
+ export function makeGitRunner(cwd) {
38
+ return (args) => new Promise((resolve) => {
39
+ const child = spawn("git", [...args], { cwd, stdio: ["ignore", "pipe", "pipe"] });
40
+ let stdout = "";
41
+ let stderr = "";
42
+ child.stdout.setEncoding("utf8");
43
+ child.stderr.setEncoding("utf8");
44
+ child.stdout.on("data", (c) => (stdout += c));
45
+ child.stderr.on("data", (c) => (stderr += c));
46
+ child.on("error", (err) => resolve({ code: 127, stdout, stderr: stderr || String(err) }));
47
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
48
+ });
49
+ }
50
+ /** A ready-to-use default runner bound to the current working directory. */
51
+ export const defaultGitRunner = makeGitRunner(process.cwd());
52
+ /** Default temp-file writer for `git commit -F` (handles multi-line bodies safely). */
53
+ async function defaultWriteCommitFile(message) {
54
+ // A per-invocation random suffix avoids collisions between concurrent hooks.
55
+ const name = `gocode-notify-commit-${process.pid}-${randomSuffix()}.txt`;
56
+ const file = path.join(os.tmpdir(), name);
57
+ await fs.writeFile(file, message, "utf8");
58
+ return file;
59
+ }
60
+ async function defaultRemoveCommitFile(file) {
61
+ await fs.rm(file, { force: true }).catch(() => { });
62
+ }
63
+ /** Small dependency-free random hex suffix (avoids importing crypto for this). */
64
+ function randomSuffix() {
65
+ return Math.trunc(Date.now() % 0xffffff).toString(16) + process.hrtime.bigint().toString(16).slice(-6);
66
+ }
67
+ /** True when the push branch is one we must not auto-push to without an explicit opt-in. */
68
+ export function isProtectedBranch(branch) {
69
+ return PROTECTED_BRANCHES.has(branch.trim());
70
+ }
71
+ /** Trim a settings string to a usable value, or undefined if empty/absent. */
72
+ function cleanBranch(value) {
73
+ if (typeof value !== "string")
74
+ return undefined;
75
+ const t = value.trim();
76
+ return t === "" ? undefined : t;
77
+ }
78
+ /**
79
+ * Resolve the effective push branch (PRD §2.4):
80
+ * 1. per-repo override `auto_push.branch`, else
81
+ * 2. global `auto_push.default_branch`, else
82
+ * 3. the current checked-out branch.
83
+ * A CONFIGURED branch (1 or 2) that does not exist locally falls back to the
84
+ * current branch (never auto-created) and the fallback is noted in `detail`.
85
+ */
86
+ async function resolveBranch(git, autoPush) {
87
+ const current = (await git(["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
88
+ const configured = cleanBranch(autoPush.branch) ?? cleanBranch(autoPush.default_branch);
89
+ if (!configured)
90
+ return { branch: current };
91
+ if (configured === current)
92
+ return { branch: current };
93
+ const exists = (await git(["rev-parse", "--verify", "--quiet", `refs/heads/${configured}`])).code === 0;
94
+ if (exists)
95
+ return { branch: configured };
96
+ return {
97
+ branch: current,
98
+ note: `configured branch "${configured}" not found locally — used current branch "${current}"`,
99
+ };
100
+ }
101
+ /** First line of a commit message (the subject), trimmed. */
102
+ function subjectOf(message) {
103
+ const first = message.split("\n", 1)[0] ?? "";
104
+ return first.trim();
105
+ }
106
+ /**
107
+ * Run the dev-machine auto-push flow (PRD §2.1 steps 2–9). Best-effort and total:
108
+ * it NEVER throws and NEVER rejects — every branch returns a {@link PushResult},
109
+ * and the caller maps any result to process exit 0 so a slow/failed push can never
110
+ * block the agent's turn (PRD §0.5 "Never block the agent").
111
+ */
112
+ export async function pushOnStop(opts = {}) {
113
+ const cwd = opts.cwd ?? process.cwd();
114
+ const git = opts.git ?? makeGitRunner(cwd);
115
+ const compose = opts.compose ?? composeCommitMessage;
116
+ const writeCommitFile = opts.writeCommitFile ?? defaultWriteCommitFile;
117
+ const removeCommitFile = opts.removeCommitFile ?? defaultRemoveCommitFile;
118
+ const source = opts.source ?? "unknown";
119
+ const settings = opts.settings ?? {};
120
+ const autoPush = settings.auto_push ?? {};
121
+ const logLine = async (line) => {
122
+ try {
123
+ if (opts.log)
124
+ await opts.log(line);
125
+ else
126
+ await appendLog(`PUSH: ${line}`, { home: opts.home, timestamp: opts.timestamp });
127
+ }
128
+ catch {
129
+ // logging must never be the thing that blocks the flow
130
+ }
131
+ };
132
+ const sendImpl = opts.sendImpl ??
133
+ ((payload) => send(payload, {
134
+ home: opts.home,
135
+ server: opts.server,
136
+ fetchImpl: opts.fetchImpl,
137
+ timeoutMs: opts.timeoutMs,
138
+ timestamp: opts.timestamp,
139
+ }));
140
+ try {
141
+ // ── Step 2: gate. Fail-safe: anything other than an explicit `true` is OFF. ──
142
+ if (autoPush.enabled !== true) {
143
+ await logLine("auto-push disabled for this repo — no-op");
144
+ return { outcome: "disabled" };
145
+ }
146
+ // ── Step 3: verify git repo. `true\n` on stdout + code 0 means inside a work tree. ──
147
+ const inside = await git(["rev-parse", "--is-inside-work-tree"]);
148
+ if (inside.code !== 0 || inside.stdout.trim() !== "true") {
149
+ await logLine("not inside a git work tree (or git unavailable) — no-op");
150
+ return { outcome: "not-a-repo" };
151
+ }
152
+ // ── Step 4: resolve branch + protected-branch guard (BEFORE staging). ──
153
+ const { branch, note: branchNote } = await resolveBranch(git, autoPush);
154
+ if (branchNote)
155
+ await logLine(branchNote);
156
+ if (isProtectedBranch(branch) && autoPush.allow_protected !== true) {
157
+ await logLine(`skipped: "${branch}" is protected and allow_protected is off`);
158
+ return {
159
+ outcome: "protected-branch-skipped",
160
+ branch,
161
+ detail: branchNote,
162
+ };
163
+ }
164
+ const remote = cleanBranch(autoPush.remote) ?? "origin";
165
+ // ── --dry-run: report the resolved target, perform NO git writes, NO send. ──
166
+ if (opts.dryRun) {
167
+ await logLine(`dry-run: would push to ${remote} ${branch}`);
168
+ return { outcome: "dry-run", branch, detail: branchNote };
169
+ }
170
+ // ── Step 5: stage everything, then check for a clean tree. ──
171
+ await git(["add", "-A"]);
172
+ const numstat = await git(["diff", "--staged", "--numstat"]);
173
+ const files = parseNumstat(numstat.stdout);
174
+ if (files.length === 0) {
175
+ await logLine("clean tree (nothing staged) — no commit, no notification");
176
+ return { outcome: "clean-tree", branch, detail: branchNote };
177
+ }
178
+ // ── Step 6: compose the commit message (AI with deterministic fallback). ──
179
+ const mode = settings.commit_message?.mode ?? "auto";
180
+ let stagedDiff = "";
181
+ let statSummary = "";
182
+ if (mode !== "deterministic") {
183
+ stagedDiff = (await git(["diff", "--staged"])).stdout;
184
+ statSummary = (await git(["diff", "--staged", "--stat"])).stdout;
185
+ }
186
+ const composed = await compose({
187
+ files,
188
+ stagedDiff,
189
+ statSummary,
190
+ branch,
191
+ source,
192
+ settings: settings.commit_message,
193
+ });
194
+ // ── Step 7: commit via `-F <tempfile>` (safe multi-line / quotes). ──
195
+ const commitArgs = ["commit", "-F"];
196
+ const file = await writeCommitFile(composed.message);
197
+ try {
198
+ const args = [...commitArgs, file];
199
+ if (autoPush.skip_git_hooks === true)
200
+ args.push("--no-verify");
201
+ const committed = await git(args);
202
+ if (committed.code !== 0) {
203
+ await logLine(`git commit failed (code ${committed.code}): ${committed.stderr.trim()}`);
204
+ return {
205
+ outcome: "commit-failed",
206
+ branch,
207
+ message: composed.message,
208
+ generator: composed.generator,
209
+ detail: committed.stderr.trim() || "commit failed",
210
+ };
211
+ }
212
+ }
213
+ finally {
214
+ await removeCommitFile(file).catch(() => { });
215
+ }
216
+ const sha = (await git(["rev-parse", "--short", "HEAD"])).stdout.trim() || undefined;
217
+ // ── Step 8: fast-forward push. NEVER --force. ──
218
+ const pushed = await git(["push", remote, branch]);
219
+ if (pushed.code !== 0) {
220
+ // Non-FF rejection (or any push failure): notify + exit 0. Never force.
221
+ await logLine(`push rejected (code ${pushed.code}): ${pushed.stderr.trim()}`);
222
+ const errorBody = isNonFastForward(pushed.stderr)
223
+ ? "auto-push rejected (remote moved); pull + retry"
224
+ : `auto-push failed: ${firstLine(pushed.stderr) || "git push error"}`;
225
+ const res = await sendImpl({
226
+ kind: "error",
227
+ title: "GoCode auto-push failed",
228
+ body: errorBody,
229
+ source,
230
+ project: opts.project,
231
+ dedupe_key: opts.dedupeKey,
232
+ });
233
+ return {
234
+ outcome: "push-rejected",
235
+ branch,
236
+ sha,
237
+ message: composed.message,
238
+ generator: composed.generator,
239
+ notified: res.ok,
240
+ detail: errorBody,
241
+ };
242
+ }
243
+ // ── Step 9: success → ONE finished notification carrying the commit summary. ──
244
+ const subject = subjectOf(composed.message);
245
+ const where = sha ? `${branch} @ ${sha}` : branch;
246
+ const res = await sendImpl({
247
+ kind: "finished",
248
+ title: `Auto-pushed to ${branch}`,
249
+ body: `${subject} — ${where}`,
250
+ source,
251
+ project: opts.project,
252
+ dedupe_key: opts.dedupeKey,
253
+ });
254
+ await logLine(`pushed ${files.length} file(s) to ${remote} ${where} (${composed.generator})`);
255
+ return {
256
+ outcome: "pushed",
257
+ branch,
258
+ sha,
259
+ message: composed.message,
260
+ generator: composed.generator,
261
+ notified: res.ok,
262
+ detail: branchNote,
263
+ };
264
+ }
265
+ catch (err) {
266
+ // Defence in depth: the flow must never throw. Any unexpected error degrades
267
+ // to a logged no-op so the hook's turn is never blocked (PRD §0.5).
268
+ await logLine(`unexpected error — treated as no-op: ${err instanceof Error ? err.message : String(err)}`);
269
+ return { outcome: "disabled", detail: "unexpected error" };
270
+ }
271
+ }
272
+ /** True when git's push stderr signals a non-fast-forward / rejected push. */
273
+ export function isNonFastForward(stderr) {
274
+ return /\b(non-fast-forward|rejected|fetch first|tip of your current branch is behind)\b/i.test(stderr);
275
+ }
276
+ function firstLine(text) {
277
+ return (text.split("\n").find((l) => l.trim() !== "") ?? "").trim();
278
+ }