claude-garage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/garage.js +143 -0
- package/daemon/package.json +13 -0
- package/daemon/src/diff.js +465 -0
- package/daemon/src/editor.js +63 -0
- package/daemon/src/events.js +54 -0
- package/daemon/src/hooks.js +241 -0
- package/daemon/src/index.js +76 -0
- package/daemon/src/notify.js +80 -0
- package/daemon/src/picker.js +48 -0
- package/daemon/src/poller.js +134 -0
- package/daemon/src/registry.js +189 -0
- package/daemon/src/security.js +39 -0
- package/daemon/src/sessions.js +255 -0
- package/daemon/src/status.js +59 -0
- package/daemon/src/term.js +94 -0
- package/daemon/src/tmux.js +124 -0
- package/daemon/src/workspaces.js +139 -0
- package/daemon/src/worktrees.js +180 -0
- package/package.json +57 -0
- package/ui/dist/apple-touch-icon.png +0 -0
- package/ui/dist/assets/google-sans-code-latin-400-normal-C-TpydLV.woff +0 -0
- package/ui/dist/assets/google-sans-code-latin-400-normal-CqaqKeEp.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-600-normal-BUCrGHE5.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-600-normal-CKee-OZj.woff +0 -0
- package/ui/dist/assets/google-sans-code-latin-700-normal-0vT4k3S3.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-700-normal-BvnkA064.woff +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-400-normal-CALCk2lB.woff +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-400-normal-xZllot2U.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-600-normal-CNNK3O9-.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-600-normal-DDkmmTp6.woff +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-700-normal-CxgnixdG.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-700-normal-bxgt_uDG.woff +0 -0
- package/ui/dist/assets/google-sans-code-math-400-normal-C2EgcUvD.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-math-400-normal-CgFzjhah.woff +0 -0
- package/ui/dist/assets/google-sans-code-math-600-normal-B5PK-qTM.woff +0 -0
- package/ui/dist/assets/google-sans-code-math-600-normal-DT_RT86i.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-math-700-normal-C65M3EbO.woff +0 -0
- package/ui/dist/assets/google-sans-code-math-700-normal-CgTZgjvl.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-symbols-400-normal-BtBR4e7S.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-symbols-400-normal-NSSmFXdP.woff +0 -0
- package/ui/dist/assets/google-sans-code-symbols-600-normal-DdWLfSlP.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-symbols-600-normal-QtnQvwms.woff +0 -0
- package/ui/dist/assets/google-sans-code-symbols-700-normal-Ceb9rWtn.woff +0 -0
- package/ui/dist/assets/google-sans-code-symbols-700-normal-DDa4ndCu.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-symbols2-400-normal-CicwJrzf.woff +0 -0
- package/ui/dist/assets/google-sans-code-symbols2-600-normal-BtzXZASM.woff +0 -0
- package/ui/dist/assets/google-sans-code-symbols2-700-normal-Cci1DrKv.woff +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-400-normal-CWRUr5CO.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-400-normal-DhSdRbI2.woff +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-600-normal-DBBK0KXU.woff +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-600-normal-zsb-D4cZ.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-700-normal-DDJUOtHZ.woff +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-700-normal-jZ3jhCNl.woff2 +0 -0
- package/ui/dist/assets/index-B9ScE6Rx.css +32 -0
- package/ui/dist/assets/index-BjoMvOWt.js +70 -0
- package/ui/dist/favicon-32.png +0 -0
- package/ui/dist/index.html +23 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const run = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export const GARAGE_PREFIX = "garage/";
|
|
7
|
+
export const NAME_RE = /^[a-z0-9-]+$/;
|
|
8
|
+
|
|
9
|
+
export function sessionId(workspace, label) {
|
|
10
|
+
return `${GARAGE_PREFIX}${workspace}/${label}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// `=` forces exact-name matching; bare -t does prefix matching.
|
|
14
|
+
const exact = (id) => `=${id}`;
|
|
15
|
+
|
|
16
|
+
export async function listSessions() {
|
|
17
|
+
let stdout;
|
|
18
|
+
try {
|
|
19
|
+
({ stdout } = await run("tmux", [
|
|
20
|
+
"ls",
|
|
21
|
+
"-F",
|
|
22
|
+
"#{session_name}\t#{session_path}\t#{session_attached}",
|
|
23
|
+
]));
|
|
24
|
+
} catch {
|
|
25
|
+
// tmux exits non-zero when no server is running — that means "no sessions".
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
return stdout
|
|
29
|
+
.split("\n")
|
|
30
|
+
.filter((line) => line.startsWith(GARAGE_PREFIX))
|
|
31
|
+
.map((line) => {
|
|
32
|
+
const [name, dir, attached] = line.split("\t");
|
|
33
|
+
const [, workspace, label] = name.split("/");
|
|
34
|
+
return { id: name, workspace, label, dir, attached: attached !== "0" };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// D-branch: live pane cwd per garage session, keyed by session name — NOT
|
|
39
|
+
// `session_path` (that's the session's *starting* dir, frozen at creation;
|
|
40
|
+
// a user who `cd`s into a worktree inside the pane changes pane_current_path
|
|
41
|
+
// but not session_path). `-a` lists every pane on the server across every
|
|
42
|
+
// session, so results are filtered to the garage prefix same as
|
|
43
|
+
// listSessions(). A session can have multiple panes/windows if the user
|
|
44
|
+
// split it manually — only the first pane encountered per session is kept,
|
|
45
|
+
// there's no principled way to pick "the" cwd of a multi-pane session.
|
|
46
|
+
export async function listPanePaths() {
|
|
47
|
+
let stdout;
|
|
48
|
+
try {
|
|
49
|
+
({ stdout } = await run("tmux", [
|
|
50
|
+
"list-panes",
|
|
51
|
+
"-a",
|
|
52
|
+
"-F",
|
|
53
|
+
"#{session_name}\t#{pane_current_path}",
|
|
54
|
+
]));
|
|
55
|
+
} catch {
|
|
56
|
+
// No tmux server running — no panes.
|
|
57
|
+
return new Map();
|
|
58
|
+
}
|
|
59
|
+
const paths = new Map();
|
|
60
|
+
for (const line of stdout.split("\n")) {
|
|
61
|
+
if (!line.startsWith(GARAGE_PREFIX)) continue;
|
|
62
|
+
const [name, panePath] = line.split("\t");
|
|
63
|
+
if (!paths.has(name)) paths.set(name, panePath);
|
|
64
|
+
}
|
|
65
|
+
return paths;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// D-branch: shared by sessions.js (per-session, keyed off live pane cwd)
|
|
69
|
+
// and workspaces.js (per-workspace, keyed off the registered dir) — both
|
|
70
|
+
// just need "what branch is HEAD at in this directory", so the git call
|
|
71
|
+
// lives here once. `null` for anything that isn't a usable git working
|
|
72
|
+
// dir (no dir, not a repo, dir vanished, etc.) rather than throwing —
|
|
73
|
+
// callers render it as "no chip" and must never let a git failure break
|
|
74
|
+
// the sessions/workspaces list response.
|
|
75
|
+
export async function resolveBranch(dir) {
|
|
76
|
+
if (!dir) return null;
|
|
77
|
+
try {
|
|
78
|
+
const { stdout } = await run("git", ["-C", dir, "rev-parse", "--abbrev-ref", "HEAD"]);
|
|
79
|
+
const branch = stdout.trim();
|
|
80
|
+
if (branch && branch !== "HEAD") return branch;
|
|
81
|
+
// Detached HEAD — abbrev-ref returns the literal string "HEAD" — fall
|
|
82
|
+
// back to a short sha, prefixed so it's visually distinct from a real
|
|
83
|
+
// branch name in the UI chip.
|
|
84
|
+
const { stdout: sha } = await run("git", ["-C", dir, "rev-parse", "--short", "HEAD"]);
|
|
85
|
+
const short = sha.trim();
|
|
86
|
+
return short ? `@${short}` : null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function hasSession(id) {
|
|
93
|
+
try {
|
|
94
|
+
await run("tmux", ["has-session", "-t", exact(id)]);
|
|
95
|
+
return true;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// extraArgs are appended as separate argv entries after `command` — tmux
|
|
102
|
+
// joins the trailing shell-command words itself (e.g. `command, "--resume",
|
|
103
|
+
// sessionId` becomes the pane's `claude --resume <sessionId>`), so callers
|
|
104
|
+
// must not pre-join them into a single string.
|
|
105
|
+
export async function createSession(id, dir, command, extraArgs = []) {
|
|
106
|
+
await run("tmux", ["new-session", "-d", "-s", id, "-c", dir, command, ...extraArgs]);
|
|
107
|
+
// The pit wall renders its own session title bars, so tmux's status line
|
|
108
|
+
// is visual noise in every grid cell. Per-session option — a plain
|
|
109
|
+
// `tmux attach` escape-hatch user can restore it with `set status on`.
|
|
110
|
+
// set-option does not accept the `=` exact-match target prefix; the full
|
|
111
|
+
// freshly-created name is unambiguous here.
|
|
112
|
+
await run("tmux", ["set-option", "-t", id, "status", "off"]).catch(() => {});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function killSession(id) {
|
|
116
|
+
await run("tmux", ["kill-session", "-t", exact(id)]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// D-rename: renames the tmux session entity itself. Attached clients (grid
|
|
120
|
+
// ptys, iTerm) stay attached across a rename — they're bound to the session
|
|
121
|
+
// entity, not its name — so this is safe to call on a live, attached session.
|
|
122
|
+
export async function renameSession(oldId, newId) {
|
|
123
|
+
await run("tmux", ["rename-session", "-t", exact(oldId), newId]);
|
|
124
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import {
|
|
3
|
+
GARAGE_PREFIX,
|
|
4
|
+
NAME_RE,
|
|
5
|
+
listSessions,
|
|
6
|
+
renameSession,
|
|
7
|
+
resolveBranch,
|
|
8
|
+
killSession,
|
|
9
|
+
} from "./tmux.js";
|
|
10
|
+
import {
|
|
11
|
+
listWorkspaces,
|
|
12
|
+
upsertWorkspace,
|
|
13
|
+
getWorkspace,
|
|
14
|
+
renameWorkspace,
|
|
15
|
+
removeWorkspace,
|
|
16
|
+
} from "./registry.js";
|
|
17
|
+
|
|
18
|
+
export default async function workspaceRoutes(app) {
|
|
19
|
+
// D-branch: same helper as sessions.js's per-session branch (tmux.js's
|
|
20
|
+
// resolveBranch), keyed off the registered dir instead of a live pane
|
|
21
|
+
// cwd — a registered workspace has no pane of its own. This route isn't
|
|
22
|
+
// hit on every SSE tick the way GET /api/sessions is, so no per-request
|
|
23
|
+
// dedup here; a plain per-workspace resolve is cheap enough at this
|
|
24
|
+
// scale (one execFile pair per registered workspace).
|
|
25
|
+
app.get("/api/workspaces", async () => {
|
|
26
|
+
const workspaces = await listWorkspaces();
|
|
27
|
+
return Promise.all(
|
|
28
|
+
workspaces.map(async (w) => ({ ...w, branch: await resolveBranch(w.dir) }))
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
app.put("/api/workspaces", async (req, reply) => {
|
|
33
|
+
const { name, dir } = req.body ?? {};
|
|
34
|
+
|
|
35
|
+
if (!NAME_RE.test(name ?? "")) {
|
|
36
|
+
return reply.code(400).send({ error: "name must match [a-z0-9-]+" });
|
|
37
|
+
}
|
|
38
|
+
const dirStat = await stat(dir ?? "").catch(() => null);
|
|
39
|
+
if (!dirStat?.isDirectory()) {
|
|
40
|
+
return reply.code(400).send({ error: `dir is not a directory: ${dir}` });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const workspace = await upsertWorkspace(name, dir);
|
|
44
|
+
return reply.code(200).send(workspace);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// D-rename: renames everything that embeds the workspace name — the
|
|
48
|
+
// registry key, every live tmux session under it, and their
|
|
49
|
+
// resume-metadata keys. Live-session renames happen first (best-effort,
|
|
50
|
+
// no rollback on partial failure per design) so a mid-flight failure
|
|
51
|
+
// still leaves the registry consistent with whichever sessions actually
|
|
52
|
+
// got renamed.
|
|
53
|
+
app.patch("/api/workspaces/:name", async (req, reply) => {
|
|
54
|
+
const oldName = req.params.name;
|
|
55
|
+
const { name: newName } = req.body ?? {};
|
|
56
|
+
|
|
57
|
+
const existing = await getWorkspace(oldName);
|
|
58
|
+
if (!existing) {
|
|
59
|
+
return reply.code(404).send({ error: `unknown workspace: ${oldName}` });
|
|
60
|
+
}
|
|
61
|
+
if (!NAME_RE.test(newName ?? "")) {
|
|
62
|
+
return reply.code(400).send({ error: "name must match [a-z0-9-]+" });
|
|
63
|
+
}
|
|
64
|
+
if (newName !== oldName && (await getWorkspace(newName))) {
|
|
65
|
+
return reply
|
|
66
|
+
.code(409)
|
|
67
|
+
.send({ error: `workspace already registered: ${newName}` });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const oldPrefix = `${GARAGE_PREFIX}${oldName}/`;
|
|
71
|
+
const newPrefix = `${GARAGE_PREFIX}${newName}/`;
|
|
72
|
+
const live = (await listSessions()).filter((s) => s.id.startsWith(oldPrefix));
|
|
73
|
+
|
|
74
|
+
const renamedSessions = [];
|
|
75
|
+
const failedSessions = [];
|
|
76
|
+
for (const s of live) {
|
|
77
|
+
const newId = newPrefix + s.id.slice(oldPrefix.length);
|
|
78
|
+
try {
|
|
79
|
+
await renameSession(s.id, newId);
|
|
80
|
+
renamedSessions.push({ from: s.id, to: newId });
|
|
81
|
+
} catch (err) {
|
|
82
|
+
failedSessions.push({ id: s.id, error: err.message ?? String(err) });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const workspace = await renameWorkspace(oldName, newName);
|
|
87
|
+
|
|
88
|
+
const response = { ...workspace, renamedSessions };
|
|
89
|
+
if (failedSessions.length > 0) {
|
|
90
|
+
response.failedSessions = failedSessions;
|
|
91
|
+
}
|
|
92
|
+
return reply.code(200).send(response);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Removing a workspace by default NEVER touches tmux: the registry is a
|
|
96
|
+
// directory mapping, not a session store. Live sessions keep running and
|
|
97
|
+
// reappear in the rail as an unregistered group; the workspace's resume
|
|
98
|
+
// metadata is dropped (removeWorkspace handles that) so no
|
|
99
|
+
// permanently-unrestorable ghosts linger.
|
|
100
|
+
//
|
|
101
|
+
// p7 follow-up: `?sessions=kill` opts into shutting the workspace down
|
|
102
|
+
// for real — every live garage/<name>/* tmux session is killed first,
|
|
103
|
+
// then the registry entry (and, via removeWorkspace, the resume
|
|
104
|
+
// metadata) is removed. Per-session kill failures are reported without
|
|
105
|
+
// aborting the rest. Worktree directories/branches are deliberately left
|
|
106
|
+
// untouched — bulk removal must never silently discard branch work; they
|
|
107
|
+
// remain recoverable via plain git.
|
|
108
|
+
app.delete("/api/workspaces/:name", async (req, reply) => {
|
|
109
|
+
const name = req.params.name;
|
|
110
|
+
if (!(await getWorkspace(name))) {
|
|
111
|
+
return reply.code(404).send({ error: `unknown workspace: ${name}` });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const killMode = req.query?.sessions === "kill";
|
|
115
|
+
if (!killMode) {
|
|
116
|
+
await removeWorkspace(name);
|
|
117
|
+
return reply.code(204).send();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const prefix = `${GARAGE_PREFIX}${name}/`;
|
|
121
|
+
const live = (await listSessions()).filter((s) => s.id.startsWith(prefix));
|
|
122
|
+
const killedSessions = [];
|
|
123
|
+
const failedSessions = [];
|
|
124
|
+
for (const s of live) {
|
|
125
|
+
try {
|
|
126
|
+
await killSession(s.id);
|
|
127
|
+
killedSessions.push(s.id);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
failedSessions.push({ id: s.id, error: err.message ?? String(err) });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
await removeWorkspace(name);
|
|
134
|
+
|
|
135
|
+
const response = { removed: name, killedSessions };
|
|
136
|
+
if (failedSessions.length > 0) response.failedSessions = failedSessions;
|
|
137
|
+
return reply.code(200).send(response);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { mkdir } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
const run = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
// D-wt-location: outside any repo, so a worktree never pollutes `git status`
|
|
11
|
+
// for the repo itself. Keyed by workspace so `~/.garage/worktrees/<ws>/` is
|
|
12
|
+
// one place to `ls` per workspace.
|
|
13
|
+
const WORKTREES_ROOT = path.join(homedir(), ".garage", "worktrees");
|
|
14
|
+
|
|
15
|
+
// D-wt-branch: namespaced so a repo's real branches never collide with ours,
|
|
16
|
+
// and so the discard/merge cleanup guard below can refuse to touch anything
|
|
17
|
+
// that isn't ours.
|
|
18
|
+
const GARAGE_BRANCH_PREFIX = "garage/";
|
|
19
|
+
|
|
20
|
+
async function git(dir, args) {
|
|
21
|
+
try {
|
|
22
|
+
return await run("git", ["-C", dir, ...args]);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
// execFile's rejection already carries .stderr/.stdout when the process
|
|
25
|
+
// ran and exited non-zero; wrap so callers get a clean .message (git's
|
|
26
|
+
// stderr, trimmed) while still exposing the raw stderr for surfacing to
|
|
27
|
+
// the API caller (D-wt-finish: merge conflicts return git's stderr).
|
|
28
|
+
const wrapped = new Error(err.stderr?.trim() || err.message);
|
|
29
|
+
wrapped.stderr = err.stderr;
|
|
30
|
+
wrapped.cause = err;
|
|
31
|
+
throw wrapped;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertGarageBranch(branch) {
|
|
36
|
+
if (typeof branch !== "string" || !branch.startsWith(GARAGE_BRANCH_PREFIX)) {
|
|
37
|
+
const err = new Error(`refusing to touch non-garage branch: ${branch}`);
|
|
38
|
+
err.statusCode = 400;
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function branchExists(repoDir, branch) {
|
|
44
|
+
try {
|
|
45
|
+
await run("git", ["-C", repoDir, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// D-wt-location / D-wt-branch: both the directory and the branch name are
|
|
53
|
+
// derived from the same candidate label, suffixed together (-2, -3, ...)
|
|
54
|
+
// until a candidate is free on both axes — keeps `garage/<label>` and its
|
|
55
|
+
// worktree dir visually paired even after a collision.
|
|
56
|
+
async function pickCandidate(repoDir, workspace, label) {
|
|
57
|
+
const baseDir = path.join(WORKTREES_ROOT, workspace);
|
|
58
|
+
let suffix = 1;
|
|
59
|
+
let candidate = label;
|
|
60
|
+
for (;;) {
|
|
61
|
+
const dirPath = path.join(baseDir, candidate);
|
|
62
|
+
const branch = `${GARAGE_BRANCH_PREFIX}${candidate}`;
|
|
63
|
+
const dirTaken = existsSync(dirPath);
|
|
64
|
+
const branchTaken = dirTaken || (await branchExists(repoDir, branch));
|
|
65
|
+
if (!dirTaken && !branchTaken) return { dirPath, branch };
|
|
66
|
+
suffix += 1;
|
|
67
|
+
candidate = `${label}-${suffix}`;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Creates `~/.garage/worktrees/<workspace>/<label>` (suffixed on collision)
|
|
72
|
+
// as a new git worktree on a fresh `garage/<label>` branch cut from repoDir's
|
|
73
|
+
// current HEAD. Throws a 400 error (with git's stderr as .message) if repoDir
|
|
74
|
+
// isn't a git repo or `git worktree add` fails for any reason (dirty/locked
|
|
75
|
+
// repo state, etc.) — callers should surface err.statusCode / err.message
|
|
76
|
+
// directly, no session or tmux state is created before this succeeds.
|
|
77
|
+
export async function createWorktree({ repoDir, workspace, label }) {
|
|
78
|
+
try {
|
|
79
|
+
await run("git", ["-C", repoDir, "rev-parse", "--is-inside-work-tree"]);
|
|
80
|
+
} catch {
|
|
81
|
+
const err = new Error(`not a git repository: ${repoDir}`);
|
|
82
|
+
err.statusCode = 400;
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { dirPath, branch } = await pickCandidate(repoDir, workspace, label);
|
|
87
|
+
await mkdir(path.dirname(dirPath), { recursive: true });
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await run("git", ["-C", repoDir, "worktree", "add", "-b", branch, dirPath]);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
const wrapped = new Error(err.stderr?.trim() || err.message);
|
|
93
|
+
wrapped.statusCode = 400;
|
|
94
|
+
throw wrapped;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { path: dirPath, branch };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// D-wt-finish: runs after the session is dead — repoDir/path/branch come
|
|
101
|
+
// from the worktree record the caller already holds (DELETE's response
|
|
102
|
+
// body), never re-derived. `merge` and `discard` are guarded to garage/*
|
|
103
|
+
// branches so this can never touch a repo's real branches even if a caller
|
|
104
|
+
// passes a bogus record. `keep` is a deliberate no-op so the UI's three-way
|
|
105
|
+
// finish prompt is uniform (dismissing it is equivalent).
|
|
106
|
+
export async function finishWorktree({ repoDir, path: wtPath, branch, action }) {
|
|
107
|
+
if (action === "keep") {
|
|
108
|
+
return { action: "keep", path: wtPath, branch };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (action !== "merge" && action !== "discard") {
|
|
112
|
+
const err = new Error(`unknown action: ${action}`);
|
|
113
|
+
err.statusCode = 400;
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
assertGarageBranch(branch);
|
|
118
|
+
|
|
119
|
+
if (action === "merge") {
|
|
120
|
+
// The most common failure isn't a conflict — it's an agent that edited
|
|
121
|
+
// but never committed: the branch has nothing to merge and `worktree
|
|
122
|
+
// remove` would refuse anyway. Catch it up front with a human message.
|
|
123
|
+
const dirty = await git(wtPath, ["status", "--porcelain"]).catch(() => null);
|
|
124
|
+
if (dirty?.stdout && dirty.stdout.trim() !== "") {
|
|
125
|
+
const err = new Error(
|
|
126
|
+
"worktree has uncommitted changes — commit them in the session first, or discard"
|
|
127
|
+
);
|
|
128
|
+
err.statusCode = 409;
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
await git(repoDir, ["merge", "--no-ff", branch]);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// D-wt-finish: non-zero exit -> 409, worktree and branch kept, the
|
|
135
|
+
// user resolves manually (no conflict UI in v1).
|
|
136
|
+
const conflictErr = new Error(err.message);
|
|
137
|
+
conflictErr.statusCode = 409;
|
|
138
|
+
conflictErr.stderr = err.stderr;
|
|
139
|
+
throw conflictErr;
|
|
140
|
+
}
|
|
141
|
+
await git(repoDir, ["worktree", "remove", wtPath]);
|
|
142
|
+
await git(repoDir, ["branch", "-d", branch]);
|
|
143
|
+
return { action: "merge", path: wtPath, branch, merged: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// discard
|
|
147
|
+
await git(repoDir, ["worktree", "remove", "--force", wtPath]);
|
|
148
|
+
await git(repoDir, ["branch", "-D", branch]);
|
|
149
|
+
return { action: "discard", path: wtPath, branch, discarded: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export default async function worktreeRoutes(app) {
|
|
153
|
+
app.post("/api/worktrees/finish", async (req, reply) => {
|
|
154
|
+
const { worktree, action } = req.body ?? {};
|
|
155
|
+
|
|
156
|
+
if (!worktree || typeof worktree !== "object") {
|
|
157
|
+
return reply.code(400).send({ error: "worktree record required" });
|
|
158
|
+
}
|
|
159
|
+
const { path: wtPath, branch, repoDir } = worktree;
|
|
160
|
+
if (!wtPath || !branch || !repoDir) {
|
|
161
|
+
return reply
|
|
162
|
+
.code(400)
|
|
163
|
+
.send({ error: "worktree record requires path, branch, and repoDir" });
|
|
164
|
+
}
|
|
165
|
+
if (!["merge", "discard", "keep"].includes(action)) {
|
|
166
|
+
return reply
|
|
167
|
+
.code(400)
|
|
168
|
+
.send({ error: "action must be one of merge, discard, keep" });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const result = await finishWorktree({ repoDir, path: wtPath, branch, action });
|
|
173
|
+
return reply.code(200).send(result);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
const body = { error: err.message };
|
|
176
|
+
if (err.stderr) body.stderr = err.stderr;
|
|
177
|
+
return reply.code(err.statusCode ?? 500).send(body);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-garage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Multiple Claude agents driving you crazy? Park them all in one garage: every session on one live terminal wall, no window juggling. Needs-input triage, worktree isolation, diff review. tmux owns the sessions; close the app and nothing dies.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Saiful Islam <saifulislamadar@gmail.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/adar2378/claude-garage.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/adar2378/claude-garage#readme",
|
|
12
|
+
"bugs": "https://github.com/adar2378/claude-garage/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"claude",
|
|
15
|
+
"claude-code",
|
|
16
|
+
"tmux",
|
|
17
|
+
"terminal",
|
|
18
|
+
"agents",
|
|
19
|
+
"session-manager",
|
|
20
|
+
"dashboard",
|
|
21
|
+
"xterm",
|
|
22
|
+
"git-worktree",
|
|
23
|
+
"developer-tools"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"claude-garage": "bin/garage.js"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"bin",
|
|
34
|
+
"daemon/src",
|
|
35
|
+
"daemon/package.json",
|
|
36
|
+
"ui/dist"
|
|
37
|
+
],
|
|
38
|
+
"workspaces": [
|
|
39
|
+
"daemon",
|
|
40
|
+
"ui"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"dev": "concurrently -n daemon,ui -c blue,magenta \"npm:dev --workspace daemon\" \"npm:dev --workspace ui\"",
|
|
44
|
+
"test": "node --test \"daemon/test/*.test.js\" \"ui/test/*.test.js\"",
|
|
45
|
+
"postinstall": "chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper 2>/dev/null || true",
|
|
46
|
+
"prepack": "npm run build --workspace ui"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@fastify/static": "^10.1.0",
|
|
50
|
+
"fastify": "^5.3.0",
|
|
51
|
+
"node-pty": "^1.1.0",
|
|
52
|
+
"ws": "^8.18.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"concurrently": "^10.0.3"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|