clauderipple 0.9.4 → 0.10.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/CHANGELOG.md +80 -0
- package/README.md +31 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +43 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +48 -40
- package/dist/cli/src/settings.js +2 -1
- package/dist/mod/hooks/i18n.ts +252 -6
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +238 -32
- package/dist/mod/hooks/session.ts +98 -1
- package/dist/mod/hooks/settings.js +7 -3
- package/dist/mod/hooks/settings.ts +9 -4
- package/dist/mod/hooks/stats.ts +25 -1
- package/dist/mod/hooks/ui/agents.tsx +858 -79
- package/dist/mod/hooks/ui/alerts.tsx +20 -17
- package/dist/mod/hooks/ui/art.ts +33 -7
- package/dist/mod/hooks/ui/band.tsx +262 -81
- package/dist/mod/hooks/ui/flat.ts +265 -0
- package/dist/mod/hooks/ui/kit.tsx +70 -55
- package/dist/mod/hooks/ui/look.ts +129 -0
- package/dist/mod/hooks/ui/settings.tsx +71 -46
- package/dist/mod/hooks/ui/setup.tsx +15 -13
- package/dist/mod/hooks/ui/theme.ts +3 -0
- package/dist/mod/hooks/ui/tools.ts +124 -0
- package/dist/mod/hooks/ui/view.ts +116 -3
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +375 -27
- package/dist/mod/types/index.d.ts +68 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +295 -0
- package/dist/remote/src/composer-menu.js +11 -0
- package/dist/remote/src/config.js +102 -0
- package/dist/remote/src/desktop.js +1018 -0
- package/dist/remote/src/e2ee.js +126 -0
- package/dist/remote/src/e2ee.ts +126 -0
- package/dist/remote/src/eventlog.js +298 -0
- package/dist/remote/src/expo-push.js +127 -0
- package/dist/remote/src/folders.js +79 -0
- package/dist/remote/src/fsutil.js +39 -0
- package/dist/remote/src/handoff.js +38 -0
- package/dist/remote/src/host-service.js +168 -0
- package/dist/remote/src/images.js +215 -0
- package/dist/remote/src/local-control.js +138 -0
- package/dist/remote/src/main.js +264 -0
- package/dist/remote/src/permissions.js +124 -0
- package/dist/remote/src/power.js +76 -0
- package/dist/remote/src/processes.js +24 -0
- package/dist/remote/src/protocol.js +32 -0
- package/dist/remote/src/protocol.ts +284 -0
- package/dist/remote/src/push-content.js +32 -0
- package/dist/remote/src/push.js +134 -0
- package/dist/remote/src/receipts.js +111 -0
- package/dist/remote/src/relay.js +438 -0
- package/dist/remote/src/router.js +208 -0
- package/dist/remote/src/sdk-launcher.js +18 -0
- package/dist/remote/src/server.js +706 -0
- package/dist/remote/src/service.js +157 -0
- package/dist/remote/src/sessions.js +1800 -0
- package/dist/remote/src/sidebar-storage.js +211 -0
- package/dist/remote/src/sidebar.js +173 -0
- package/dist/remote/src/tool-details.js +205 -0
- package/dist/remote/src/tool-details.ts +153 -0
- package/dist/remote/src/worktree.js +99 -0
- package/dist/remote/web/app.ts +746 -0
- package/dist/remote/web/conn.ts +193 -0
- package/dist/remote/web/demo.ts +134 -0
- package/dist/remote/web/dom.ts +124 -0
- package/dist/remote/web/esc.ts +4 -0
- package/dist/remote/web/i18n.ts +374 -0
- package/dist/remote/web/icon.svg +1 -0
- package/dist/remote/web/index.html +24 -0
- package/dist/remote/web/manifest.webmanifest +10 -0
- package/dist/remote/web/mascot.ts +60 -0
- package/dist/remote/web/md.ts +78 -0
- package/dist/remote/web/notify.ts +55 -0
- package/dist/remote/web/pushstate.ts +27 -0
- package/dist/remote/web/secure.ts +109 -0
- package/dist/remote/web/sidebar.ts +106 -0
- package/dist/remote/web/style.css +311 -0
- package/dist/remote/web/sw.ts +48 -0
- package/dist/remote/web/timeline.ts +224 -0
- package/dist/remote/web/views.ts +323 -0
- package/dist/router/src/admin.js +227 -4
- package/dist/router/src/config.js +30 -0
- package/dist/router/src/index.js +39 -0
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/presets.js +3 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/chatgpt/index.js +41 -11
- package/dist/router/src/providers/chatgpt/translate.js +147 -7
- package/dist/router/src/providers/google/index.js +26 -0
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/usage.js +257 -0
- package/dist/router/src/providers/grok/index.js +18 -0
- package/dist/router/src/providers/grok/usage.js +77 -0
- package/dist/router/src/providers/openai/index.js +29 -5
- package/dist/router/src/providers/openai/translate.js +260 -27
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/proxy.js +339 -258
- package/dist/router/src/reasoning.js +157 -0
- package/dist/router/src/remote-host.js +320 -0
- package/dist/router/src/remote-tunnel.js +320 -0
- package/dist/router/src/requestlog.js +48 -39
- package/dist/router/src/thinking.js +20 -9
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +436 -59
- package/dist/ui/i18n.js +184 -0
- package/dist/ui/index.html +24 -5
- package/dist/ui/style.css +39 -0
- package/docs/ARCHITECTURE.md +164 -12
- package/package.json +7 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Pure tool presentation data. Shared by host serialization, web and native; no platform imports.
|
|
2
|
+
import type { Text, ToolCall } from "./protocol.ts";
|
|
3
|
+
|
|
4
|
+
export function record(value: unknown): Record<string, unknown> | null {
|
|
5
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
6
|
+
}
|
|
7
|
+
export function toolInput(value: Text | undefined): Record<string, unknown> | null {
|
|
8
|
+
if (typeof value !== "string") return null;
|
|
9
|
+
try { return record(JSON.parse(value)); } catch { return null; } // Legacy tools may store a plain prompt, not JSON.
|
|
10
|
+
}
|
|
11
|
+
export const stringField = (input: Record<string, unknown> | null, key: string): string => typeof input?.[key] === "string" ? input[key] : "";
|
|
12
|
+
|
|
13
|
+
export interface DiffLine { kind: "context" | "add" | "remove"; text: string; old: number | null; next: number | null }
|
|
14
|
+
export interface ChangeCount { added: number; removed: number }
|
|
15
|
+
export interface ToolDiff { path: string; header: string; lines: DiffLine[] }
|
|
16
|
+
function lines(text: string): string[] {
|
|
17
|
+
if (!text) return [];
|
|
18
|
+
const list = text.replace(/\r\n/g, "\n").split("\n");
|
|
19
|
+
if (list.at(-1) === "") list.pop();
|
|
20
|
+
return list;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A linear-space line diff. Common edges are peeled before finding the next shared line,
|
|
24
|
+
* avoiding an N×M matrix for large writes. Equal-distance anchors prefer removals. */
|
|
25
|
+
export function diffLines(before: string, after: string): DiffLine[] {
|
|
26
|
+
const a = lines(before), b = lines(after);
|
|
27
|
+
const positions = new Map<string, number[]>();
|
|
28
|
+
b.forEach((line, i) => { const at = positions.get(line) ?? []; at.push(i); positions.set(line, at); });
|
|
29
|
+
const out: DiffLine[] = [];
|
|
30
|
+
let i = 0, j = 0;
|
|
31
|
+
const remove = () => out.push({ kind: "remove", text: a[i]!, old: ++i, next: null });
|
|
32
|
+
const add = () => out.push({ kind: "add", text: b[j]!, old: null, next: ++j });
|
|
33
|
+
const context = () => out.push({ kind: "context", text: a[i]!, old: ++i, next: ++j });
|
|
34
|
+
while (i < a.length && j < b.length) {
|
|
35
|
+
if (a[i] === b[j]) { context(); continue; }
|
|
36
|
+
let anchor: { i: number; j: number; distance: number } | null = null;
|
|
37
|
+
for (let k = i; k < a.length; k++) {
|
|
38
|
+
if (anchor && k - i > anchor.distance) break;
|
|
39
|
+
const candidates = positions.get(a[k]!);
|
|
40
|
+
if (!candidates) continue;
|
|
41
|
+
// Binary search: the same repeated line can occur thousands of times.
|
|
42
|
+
let lo = 0, hi = candidates.length;
|
|
43
|
+
while (lo < hi) { const mid = (lo + hi) >>> 1; if (candidates[mid]! < j) lo = mid + 1; else hi = mid; }
|
|
44
|
+
const next = candidates[lo];
|
|
45
|
+
if (next === undefined) continue;
|
|
46
|
+
const distance = k - i + next - j;
|
|
47
|
+
if (!anchor || distance < anchor.distance) anchor = { i: k, j: next, distance };
|
|
48
|
+
}
|
|
49
|
+
if (!anchor) { while (i < a.length) remove(); while (j < b.length) add(); break; }
|
|
50
|
+
while (i < anchor.i) remove();
|
|
51
|
+
while (j < anchor.j) add();
|
|
52
|
+
}
|
|
53
|
+
while (i < a.length) remove();
|
|
54
|
+
while (j < b.length) add();
|
|
55
|
+
// A changed final newline is a real edit, even if every visible line is equal.
|
|
56
|
+
if (before !== after && before.endsWith("\n") !== after.endsWith("\n") && a.length && b.length && a.at(-1) === b.at(-1)) {
|
|
57
|
+
const last = out.at(-1);
|
|
58
|
+
if (last?.kind === "context") out.splice(out.length - 1, 1,
|
|
59
|
+
{ kind: "remove", text: last.text + (before.endsWith("\n") ? "" : "\n\"), old: last.old, next: null },
|
|
60
|
+
{ kind: "add", text: last.text + (after.endsWith("\n") ? "" : "\n\"), old: null, next: last.next });
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function toolDiffs(name: string, input: Record<string, unknown> | null): ToolDiff[] {
|
|
66
|
+
if (!input || !["Edit", "MultiEdit", "Write"].includes(name)) return [];
|
|
67
|
+
const path = stringField(input, "file_path") || stringField(input, "path") || "file";
|
|
68
|
+
const edits = name === "MultiEdit" && Array.isArray(input.edits) ? input.edits.map(record).filter(e => e !== null) : [input];
|
|
69
|
+
return edits.map(edit => {
|
|
70
|
+
const before = name === "Write" ? stringField(edit, "old_content") : stringField(edit, "old_string");
|
|
71
|
+
const after = name === "Write" ? stringField(edit, "content") : stringField(edit, "new_string");
|
|
72
|
+
const diff = diffLines(before, after);
|
|
73
|
+
return { path, header: `@@ -${before ? 1 : 0},${lines(before).length} +${after ? 1 : 0},${lines(after).length} @@`, lines: diff };
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export function changeCount(name: string, input: Record<string, unknown> | null): ChangeCount {
|
|
77
|
+
return toolDiffs(name, input).flatMap(d => d.lines).reduce((sum, line) => ({
|
|
78
|
+
added: sum.added + Number(line.kind === "add"), removed: sum.removed + Number(line.kind === "remove"),
|
|
79
|
+
}), { added: 0, removed: 0 });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ActivityTool extends ToolCall { result?: { ok: boolean } }
|
|
83
|
+
export type SummaryRow = { type: "tool"; tool: ActivityTool } | { type: "subagent"; status: "running" | "done" | "failed" };
|
|
84
|
+
type Category = "command" | "read" | "edit" | "search" | "web" | "agent" | "other";
|
|
85
|
+
function category(name: string): Category {
|
|
86
|
+
if (["Bash", "BashOutput", "KillShell"].includes(name)) return "command";
|
|
87
|
+
if (name === "Read") return "read";
|
|
88
|
+
if (["Edit", "MultiEdit", "Write", "NotebookEdit"].includes(name)) return "edit";
|
|
89
|
+
if (["Grep", "Glob", "LS"].includes(name)) return "search";
|
|
90
|
+
if (["WebSearch", "WebFetch"].includes(name)) return "web";
|
|
91
|
+
if (["Task", "Agent"].includes(name)) return "agent";
|
|
92
|
+
return "other";
|
|
93
|
+
}
|
|
94
|
+
const completed = {
|
|
95
|
+
ko: { command: (n: number) => `명령어 ${n}개 실행함`, read: (n: number) => `파일 ${n}개 읽음`, edit: (n: number) => `파일 ${n}개를 편집함`, search: (n: number) => `검색 ${n}회`, web: (n: number) => `웹 ${n}회`, agent: (n: number) => `에이전트 ${n}개`, other: (n: number) => `도구 ${n}개 사용함` },
|
|
96
|
+
en: { command: (n: number) => `Ran ${n} command${n === 1 ? "" : "s"}`, read: (n: number) => `Read ${n} file${n === 1 ? "" : "s"}`, edit: (n: number) => `Edited ${n} file${n === 1 ? "" : "s"}`, search: (n: number) => `${n} search${n === 1 ? "" : "es"}`, web: (n: number) => `${n} web request${n === 1 ? "" : "s"}`, agent: (n: number) => `${n} agent${n === 1 ? "" : "s"}`, other: (n: number) => `Used ${n} tool${n === 1 ? "" : "s"}` },
|
|
97
|
+
};
|
|
98
|
+
const active = {
|
|
99
|
+
ko: { command: "명령어 실행 중…", read: "파일 읽는 중…", edit: "파일 편집 중…", search: "검색 중…", web: "웹 검색 중…", agent: "에이전트 실행 중…", other: "도구 실행 중…" },
|
|
100
|
+
en: { command: "Running command…", read: "Reading file…", edit: "Editing file…", search: "Searching…", web: "Searching the web…", agent: "Running agent…", other: "Running tool…" },
|
|
101
|
+
};
|
|
102
|
+
const more = { ko: (n: number) => `그 외 작업 ${n}개`, en: (n: number) => `and ${n} more action${n === 1 ? "" : "s"}` };
|
|
103
|
+
/** Like the Claude app: kinds in the order they first appear, files counted once per path, five or more kinds cut to three. */
|
|
104
|
+
export function activitySummary(rows: readonly SummaryRow[], running: boolean, language: "ko" | "en"): ChangeCount & { text: string; failed: boolean } {
|
|
105
|
+
const counts = new Map<Category, number>();
|
|
106
|
+
const paths = new Set<string>();
|
|
107
|
+
let added = 0, removed = 0, failed = false, pending: Category | null = null;
|
|
108
|
+
for (const row of rows) {
|
|
109
|
+
const kind = row.type === "subagent" ? "agent" : category(row.tool.name);
|
|
110
|
+
const input = row.type === "tool" ? toolInput(row.tool.input) : null;
|
|
111
|
+
const path = stringField(input, "file_path") || stringField(input, "path") || (row.type === "tool" ? row.tool.summary : "");
|
|
112
|
+
const file = (kind === "edit" || kind === "read") && path ? `${kind}:${path}` : "";
|
|
113
|
+
if (!file || !paths.has(file)) counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
|
114
|
+
if (file) paths.add(file);
|
|
115
|
+
if (row.type === "subagent") { failed ||= row.status === "failed"; if (row.status === "running") pending = kind; continue; }
|
|
116
|
+
failed ||= row.tool.result?.ok === false;
|
|
117
|
+
if (!row.tool.result) pending = kind;
|
|
118
|
+
const changes = row.tool.changes ?? changeCount(row.tool.name, input);
|
|
119
|
+
added += changes.added; removed += changes.removed;
|
|
120
|
+
}
|
|
121
|
+
const parts = [...counts];
|
|
122
|
+
const shown = parts.length >= 5 ? parts.slice(0, 3) : parts;
|
|
123
|
+
const rest = parts.slice(shown.length).reduce((sum, [, n]) => sum + n, 0);
|
|
124
|
+
const text = running && pending ? active[language][pending] : [...shown.map(([kind, n]) => completed[language][kind](n)), ...(rest ? [more[language](rest)] : [])].join(", ");
|
|
125
|
+
return { text, added, removed, failed };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function numberedRead(text: string, input: Record<string, unknown> | null): { number: number; text: string }[] {
|
|
129
|
+
const offset = typeof input?.offset === "number" && input.offset > 0 ? Math.floor(input.offset) : 1;
|
|
130
|
+
return lines(text).map((line, i) => {
|
|
131
|
+
const match = /^\s*(\d+)[→\t](.*)$/.exec(line);
|
|
132
|
+
return match ? { number: Number(match[1]), text: match[2]! } : { number: offset + i, text: line };
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
export function pretty(text: string): string {
|
|
136
|
+
try { return JSON.stringify(JSON.parse(text), null, 2); } catch { return text; }
|
|
137
|
+
}
|
|
138
|
+
export function webLinks(text: string): { title: string; url: string }[] {
|
|
139
|
+
const out = new Map<string, string>();
|
|
140
|
+
const add = (url: string, title: string) => { if (/^https?:\/\//.test(url)) out.set(url, title || url); };
|
|
141
|
+
function walk(value: unknown): void {
|
|
142
|
+
if (Array.isArray(value)) { value.forEach(walk); return; }
|
|
143
|
+
const obj = record(value);
|
|
144
|
+
if (!obj) return;
|
|
145
|
+
const url = stringField(obj, "url") || stringField(obj, "link");
|
|
146
|
+
if (url) add(url, stringField(obj, "title") || stringField(obj, "name"));
|
|
147
|
+
Object.values(obj).forEach(v => { if (typeof v === "object") walk(v); });
|
|
148
|
+
}
|
|
149
|
+
try { walk(JSON.parse(text)); } catch { /* Text/Markdown output is normal. */ }
|
|
150
|
+
for (const match of text.matchAll(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g)) add(match[2]!, match[1]!);
|
|
151
|
+
for (const match of text.matchAll(/https?:\/\/[^\s<>"\])]+/g)) { const url = match[0].replace(/[.,;]+$/, ""); if (!out.has(url)) add(url, url); }
|
|
152
|
+
return [...out].map(([url, title]) => ({ title, url }));
|
|
153
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// New-session isolation. A "local" session runs in the folder you picked; a "worktree" session
|
|
2
|
+
// instead branches off in `git worktree`, so the person's checkout is never touched. Everything
|
|
3
|
+
// here is host-side and validated: a folder that is not a git repository is refused rather than
|
|
4
|
+
// guessed at. No code is copied from any reference implementation (CLAUDE.md).
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
const run = promisify(execFile);
|
|
10
|
+
/** git with a bounded buffer and a fixed cwd; the caller decides what a non-zero exit means. */
|
|
11
|
+
async function git(cwd, args) {
|
|
12
|
+
const { stdout } = await run("git", ["-C", cwd, ...args], { timeout: 20_000, maxBuffer: 4 * 1024 * 1024 });
|
|
13
|
+
return stdout.toString();
|
|
14
|
+
}
|
|
15
|
+
async function isDirectory(p) {
|
|
16
|
+
try {
|
|
17
|
+
return (await fs.stat(p)).isDirectory();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** The repository root containing `dir`, or null if it is not inside one. */
|
|
24
|
+
async function repoRoot(dir) {
|
|
25
|
+
try {
|
|
26
|
+
return (await git(dir, ["rev-parse", "--show-toplevel"])).trim() || null;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** A folder's git situation, for the new-session isolation choice. Never throws. */
|
|
33
|
+
export async function gitInfo(dir) {
|
|
34
|
+
const target = path.resolve(dir);
|
|
35
|
+
if (!(await isDirectory(target)))
|
|
36
|
+
return { isRepo: false, root: null, branch: null, branches: [] };
|
|
37
|
+
const root = await repoRoot(target);
|
|
38
|
+
if (!root)
|
|
39
|
+
return { isRepo: false, root: null, branch: null, branches: [] };
|
|
40
|
+
let branch = null, branches = [];
|
|
41
|
+
try {
|
|
42
|
+
const head = (await git(root, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
43
|
+
branch = head && head !== "HEAD" ? head : null;
|
|
44
|
+
}
|
|
45
|
+
catch { /* detached or empty repo: no current branch */ }
|
|
46
|
+
try {
|
|
47
|
+
branches = (await git(root, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]))
|
|
48
|
+
.split("\n").map((line) => line.trim()).filter(Boolean).sort((a, b) => a.localeCompare(b));
|
|
49
|
+
}
|
|
50
|
+
catch { /* no branches yet */ }
|
|
51
|
+
return { isRepo: true, root, branch, branches };
|
|
52
|
+
}
|
|
53
|
+
/** A branch/directory name from the task text: lower-case, alphanumeric runs joined by dashes. */
|
|
54
|
+
export function worktreeSlug(text) {
|
|
55
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Create a worktree under `<repo parent>/<repo>-worktrees/<slug>`, on a new branch named from the
|
|
59
|
+
* slug, based on `baseBranch` (default: the current branch). Refuses a folder that is not inside a
|
|
60
|
+
* git repository, an unknown base branch, or a target that already exists — the person's folder is
|
|
61
|
+
* never modified in place.
|
|
62
|
+
*/
|
|
63
|
+
export async function createWorktree(opts) {
|
|
64
|
+
const cwd = path.resolve(opts.cwd);
|
|
65
|
+
if (!(await isDirectory(cwd)))
|
|
66
|
+
throw new Error(`not a folder: ${cwd}`);
|
|
67
|
+
const root = await repoRoot(cwd);
|
|
68
|
+
if (!root)
|
|
69
|
+
throw new Error("Not a git repository; a new worktree needs one.");
|
|
70
|
+
let base = opts.baseBranch?.trim() ?? "";
|
|
71
|
+
if (!base) {
|
|
72
|
+
const head = (await git(root, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
73
|
+
if (!head || head === "HEAD")
|
|
74
|
+
throw new Error("The repository has no current branch to branch from.");
|
|
75
|
+
base = head;
|
|
76
|
+
}
|
|
77
|
+
// The base comes from the phone and goes on git's command line: a leading dash would be an option.
|
|
78
|
+
if (base.startsWith("-"))
|
|
79
|
+
throw new Error(`Unknown base branch: ${base}`);
|
|
80
|
+
try {
|
|
81
|
+
await git(root, ["rev-parse", "--verify", "--quiet", `${base}^{commit}`]);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new Error(`Unknown base branch: ${base}`);
|
|
85
|
+
}
|
|
86
|
+
const slug = worktreeSlug(opts.text) || `session-${(opts.now ?? Date.now)()}`;
|
|
87
|
+
const dir = path.join(path.dirname(root), `${path.basename(root)}-worktrees`, slug);
|
|
88
|
+
if (await isDirectory(dir))
|
|
89
|
+
throw new Error(`A worktree already exists at ${dir}`);
|
|
90
|
+
await fs.mkdir(path.dirname(dir), { recursive: true });
|
|
91
|
+
try {
|
|
92
|
+
await git(root, ["worktree", "add", "-b", slug, dir, base]);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
96
|
+
throw new Error(`Could not create the worktree: ${message.split("\n").find((line) => line.trim()) ?? message}`);
|
|
97
|
+
}
|
|
98
|
+
return { dir, branch: slug, base };
|
|
99
|
+
}
|