pi-kage 0.3.5 → 0.3.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/README.md +18 -3
- package/bin/kage.mjs +690 -620
- package/package.json +13 -3
package/bin/kage.mjs
CHANGED
|
@@ -22,296 +22,336 @@
|
|
|
22
22
|
* kage rm [name] [--force] discard a clone (no merge)
|
|
23
23
|
* kage pull <path...> (inside a clone) copy files back to the origin
|
|
24
24
|
*/
|
|
25
|
-
|
|
26
25
|
import { spawn, spawnSync } from "node:child_process";
|
|
27
26
|
import { randomUUID } from "node:crypto";
|
|
28
27
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
29
28
|
import { homedir } from "node:os";
|
|
30
29
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
31
30
|
import readline from "node:readline";
|
|
32
|
-
|
|
33
|
-
const VERSION = "0.3.5"; // keep in sync with package.json (enforced by test)
|
|
31
|
+
const VERSION = "0.3.6"; // keep in sync with package.json (enforced by test)
|
|
34
32
|
const MARKER = ".kage.json";
|
|
35
33
|
const SESSIONS = process.env.KAGE_SESSIONS_DIR || join(homedir(), ".pi", "agent", "sessions");
|
|
36
34
|
const RECENT_SESSIONS = 5; // how many of the origin's most-recent sessions to copy into a clone
|
|
37
|
-
|
|
35
|
+
/** Typed accessors for the loose flags bag, so consumers don't re-derive the shape inline. */
|
|
36
|
+
const boolFlag = (flags, name) => Boolean(flags[name]);
|
|
37
|
+
const strFlag = (flags, name) => {
|
|
38
|
+
const v = flags[name];
|
|
39
|
+
return typeof v === "string" ? v : undefined;
|
|
40
|
+
};
|
|
38
41
|
// ── output helpers ───────────────────────────────────────────────────────────
|
|
39
42
|
const TTY = process.stderr.isTTY;
|
|
40
43
|
const col = (code, s) => (TTY ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
41
44
|
const paint = {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
cyan: (s) => col("36", s),
|
|
45
|
+
bold: (s) => col("1", s),
|
|
46
|
+
dim: (s) => col("90", s),
|
|
47
|
+
red: (s) => col("31", s),
|
|
48
|
+
green: (s) => col("32", s),
|
|
49
|
+
yellow: (s) => col("33", s),
|
|
50
|
+
magenta: (s) => col("35", s),
|
|
51
|
+
cyan: (s) => col("36", s),
|
|
50
52
|
};
|
|
51
53
|
const info = (msg) => console.error(msg);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
};
|
|
56
|
-
|
|
54
|
+
// A function declaration (not an arrow const) so its `never` return type drives
|
|
55
|
+
// TypeScript's control-flow narrowing at call sites (`if (!x) die(...)` -> x is defined).
|
|
56
|
+
function die(msg) {
|
|
57
|
+
console.error(`✗ ${msg}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
57
60
|
// ── shell / git helpers ──────────────────────────────────────────────────────
|
|
58
61
|
function sh(cmd, args, opts = {}) {
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
// `encoding: "utf8"` selects spawnSync's string overload, so stdout/stderr are typed strings.
|
|
63
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", ...opts });
|
|
64
|
+
return { ok: r.status === 0, out: (r.stdout || "").trim(), err: (r.stderr || "").trim() };
|
|
61
65
|
}
|
|
62
66
|
const git = (cwd, args) => sh("git", args, { cwd });
|
|
63
|
-
|
|
64
67
|
/** Absolute path -> pi's session dir name: /a/b -> --a-b-- */
|
|
65
68
|
const encodeCwd = (abs) => `--${abs.replace(/^\//, "").replace(/\//g, "-")}--`;
|
|
66
69
|
const sessionDirFor = (repoAbs) => join(SESSIONS, encodeCwd(repoAbs));
|
|
67
|
-
|
|
68
70
|
function repoTopLevel(cwd) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
const r = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
72
|
+
return r.ok ? r.out : undefined;
|
|
73
|
+
}
|
|
74
|
+
/** Validate parsed JSON is a kage marker (only originRepo is required; name/createdAt are best-effort). */
|
|
75
|
+
function isMarker(v) {
|
|
76
|
+
return typeof v === "object" && v !== null && typeof v.originRepo === "string";
|
|
71
77
|
}
|
|
72
|
-
|
|
73
78
|
function readMarker(dir) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
const p = join(dir, MARKER);
|
|
80
|
+
if (!existsSync(p))
|
|
81
|
+
return undefined;
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(readFileSync(p, "utf8"));
|
|
84
|
+
return isMarker(parsed) ? parsed : undefined;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
81
89
|
}
|
|
82
|
-
|
|
83
90
|
/** Copy a whole directory: clonefile on macOS, reflink on Linux, plain copy as fallback. */
|
|
84
91
|
function copyTree(src, dst) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
92
|
+
const isMac = process.platform === "darwin";
|
|
93
|
+
let r = sh("cp", isMac ? ["-c", "-R", src, dst] : ["--reflink=auto", "-R", src, dst]);
|
|
94
|
+
if (!r.ok)
|
|
95
|
+
r = sh("cp", ["-R", src, dst]);
|
|
96
|
+
return r;
|
|
89
97
|
}
|
|
90
|
-
|
|
91
98
|
/** An indeterminate spinner on stderr (no-op when not a TTY). Returns { stop() }. */
|
|
92
99
|
function spinner(label) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
100
|
+
if (!process.stderr.isTTY)
|
|
101
|
+
return { stop() { } };
|
|
102
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
103
|
+
const t0 = Date.now();
|
|
104
|
+
let i = 0;
|
|
105
|
+
const tick = () => {
|
|
106
|
+
const s = ((Date.now() - t0) / 1000).toFixed(1);
|
|
107
|
+
i = (i + 1) % frames.length;
|
|
108
|
+
process.stderr.write(`\r\x1b[2K${paint.cyan(frames[i] ?? "")} ${label} ${paint.dim(`${s}s`)}`);
|
|
109
|
+
};
|
|
110
|
+
tick();
|
|
111
|
+
const id = setInterval(tick, 80);
|
|
112
|
+
return {
|
|
113
|
+
stop() {
|
|
114
|
+
clearInterval(id);
|
|
115
|
+
process.stderr.write("\r\x1b[2K");
|
|
116
|
+
},
|
|
117
|
+
};
|
|
109
118
|
}
|
|
110
|
-
|
|
111
119
|
/** Copy the repo with a spinner (the copy can be slow on non-reflink filesystems). */
|
|
112
120
|
async function copyRepo(src, dst) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
121
|
+
const isMac = process.platform === "darwin";
|
|
122
|
+
const primary = isMac ? ["-c", "-R", src, dst] : ["--reflink=auto", "-R", src, dst];
|
|
123
|
+
const tryCp = (args) => new Promise((res) => {
|
|
124
|
+
const p = spawn("cp", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
125
|
+
let err = "";
|
|
126
|
+
p.stderr?.on("data", (d) => (err += d));
|
|
127
|
+
p.on("error", (e) => res({ ok: false, err: e.message }));
|
|
128
|
+
p.on("close", (code) => res({ ok: code === 0, err: err.trim() }));
|
|
129
|
+
});
|
|
130
|
+
const sp = spinner(`copying ${basename(dst)}`);
|
|
131
|
+
let r = await tryCp(primary);
|
|
132
|
+
if (!r.ok)
|
|
133
|
+
r = await tryCp(["-R", src, dst]);
|
|
134
|
+
sp.stop();
|
|
135
|
+
return r;
|
|
128
136
|
}
|
|
129
|
-
|
|
130
137
|
function tsName() {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
138
|
+
const d = new Date();
|
|
139
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
140
|
+
return `kage-${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
134
141
|
}
|
|
135
|
-
|
|
136
142
|
/**
|
|
137
143
|
* Sanitize a clone name into a slug that's safe as both a folder suffix and a git branch/ref:
|
|
138
144
|
* ref-illegal chars (spaces, /, ~^:?*[\\, etc.) -> '-', no '..', no leading/trailing '-'/'.',
|
|
139
145
|
* no trailing '.lock'. Falls back to a timestamp name if it sanitizes to empty.
|
|
140
146
|
*/
|
|
141
147
|
function slug(name) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
148
|
+
const s = name
|
|
149
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
150
|
+
.replace(/\.{2,}/g, ".")
|
|
151
|
+
.replace(/-{2,}/g, "-")
|
|
152
|
+
.replace(/^[-.]+|[-.]+$/g, "")
|
|
153
|
+
.replace(/\.lock$/i, "-lock");
|
|
154
|
+
return s || tsName();
|
|
149
155
|
}
|
|
150
|
-
|
|
151
156
|
function parseArgs(argv) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
157
|
+
const positional = [];
|
|
158
|
+
const flags = {};
|
|
159
|
+
for (let i = 0; i < argv.length; i++) {
|
|
160
|
+
const a = argv[i];
|
|
161
|
+
if (a === undefined)
|
|
162
|
+
continue; // unreachable (bounded loop), but proves index safety to the checker
|
|
163
|
+
if (a.startsWith("--")) {
|
|
164
|
+
const eq = a.indexOf("=");
|
|
165
|
+
const next = argv[i + 1];
|
|
166
|
+
if (eq >= 0)
|
|
167
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
168
|
+
else if (next !== undefined && !next.startsWith("--")) {
|
|
169
|
+
flags[a.slice(2)] = next;
|
|
170
|
+
i++;
|
|
171
|
+
}
|
|
172
|
+
else
|
|
173
|
+
flags[a.slice(2)] = true;
|
|
174
|
+
}
|
|
175
|
+
else
|
|
176
|
+
positional.push(a);
|
|
177
|
+
}
|
|
178
|
+
return { positional, flags };
|
|
164
179
|
}
|
|
165
|
-
|
|
166
180
|
// ── clone discovery & status ─────────────────────────────────────────────────
|
|
167
181
|
function listClones(originRepo) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
182
|
+
const parent = dirname(originRepo);
|
|
183
|
+
const out = [];
|
|
184
|
+
for (const name of readdirSync(parent)) {
|
|
185
|
+
const dir = join(parent, name);
|
|
186
|
+
const m = readMarker(dir);
|
|
187
|
+
if (m && m.originRepo === originRepo)
|
|
188
|
+
out.push({ dir, name: m.name || basename(dir), marker: m });
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
176
191
|
}
|
|
177
|
-
|
|
178
192
|
function cloneStatus(dir) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
193
|
+
const branch = git(dir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "?";
|
|
194
|
+
const st = git(dir, ["status", "--porcelain"]).out;
|
|
195
|
+
const changed = st.split("\n").filter((l) => l.trim() && l.slice(3).trim() !== MARKER);
|
|
196
|
+
const dirty = changed.length > 0;
|
|
197
|
+
const up = git(dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
198
|
+
let ahead = 0;
|
|
199
|
+
let behind = 0;
|
|
200
|
+
if (up.ok) {
|
|
201
|
+
const rl = git(dir, ["rev-list", "--left-right", "--count", "@{u}...HEAD"]);
|
|
202
|
+
if (rl.ok) {
|
|
203
|
+
const [b, a] = rl.out.split(/\s+/).map(Number);
|
|
204
|
+
behind = b || 0;
|
|
205
|
+
ahead = a || 0;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// uncommitted line changes (tracked, vs HEAD) and the last commit on this branch
|
|
209
|
+
const ss = git(dir, ["diff", "HEAD", "--shortstat"]).out;
|
|
210
|
+
const added = Number(ss.match(/(\d+) insertion/)?.[1] || 0);
|
|
211
|
+
const removed = Number(ss.match(/(\d+) deletion/)?.[1] || 0);
|
|
212
|
+
const lc = git(dir, ["log", "-1", "--format=%h\x1f%s\x1f%cr"]).out;
|
|
213
|
+
const [sha = "", subject = "", when = ""] = lc ? lc.split("\x1f") : [];
|
|
214
|
+
const lastCommit = sha ? { sha, subject, when } : undefined;
|
|
215
|
+
return { branch, dirty, dirtyCount: changed.length, added, removed, ahead, behind, hasUpstream: up.ok, lastCommit };
|
|
202
216
|
}
|
|
203
|
-
|
|
204
217
|
/** Compact relative age, e.g. "2h ago". */
|
|
205
218
|
function ago(date) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
219
|
+
const s = Math.max(0, (Date.now() - new Date(date).getTime()) / 1000);
|
|
220
|
+
if (s < 60)
|
|
221
|
+
return `${Math.floor(s)}s ago`;
|
|
222
|
+
if (s < 3600)
|
|
223
|
+
return `${Math.floor(s / 60)}m ago`;
|
|
224
|
+
if (s < 86400)
|
|
225
|
+
return `${Math.floor(s / 3600)}h ago`;
|
|
226
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
211
227
|
}
|
|
212
|
-
|
|
213
228
|
/** Best-effort PR lookup via gh; returns { state, number, url } or undefined. */
|
|
229
|
+
/** Validate `gh pr view --json` output has the fields we read. */
|
|
230
|
+
function isPr(v) {
|
|
231
|
+
if (typeof v !== "object" || v === null)
|
|
232
|
+
return false;
|
|
233
|
+
const p = v;
|
|
234
|
+
return typeof p.state === "string" && typeof p.number === "number" && typeof p.url === "string";
|
|
235
|
+
}
|
|
214
236
|
function prInfo(dir, branch) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
237
|
+
const r = sh("gh", ["pr", "view", branch, "--json", "state,number,url"], { cwd: dir });
|
|
238
|
+
if (!r.ok)
|
|
239
|
+
return undefined;
|
|
240
|
+
try {
|
|
241
|
+
const parsed = JSON.parse(r.out);
|
|
242
|
+
return isPr(parsed) ? parsed : undefined;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
222
247
|
}
|
|
223
|
-
|
|
224
248
|
/** True when the clone has no local-only work (clean + pushed) -> safe to remove. */
|
|
225
249
|
const isSafeToClean = (s) => !s.dirty && s.hasUpstream && s.ahead === 0;
|
|
226
|
-
|
|
227
250
|
/** True if the clone has committed work that lives only in the clone (not on a remote, not yet in the origin). */
|
|
228
251
|
function hasUnpreservedCommits(originRepo, cloneDir, s) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
252
|
+
if (s.hasUpstream && s.ahead === 0)
|
|
253
|
+
return false; // already on a remote
|
|
254
|
+
const head = git(cloneDir, ["rev-parse", "HEAD"]).out;
|
|
255
|
+
if (!head)
|
|
256
|
+
return false;
|
|
257
|
+
return !git(originRepo, ["cat-file", "-e", `${head}^{commit}`]).ok;
|
|
233
258
|
}
|
|
234
|
-
|
|
235
259
|
// ── interactive picker (TUI-lite, arrow keys, no deps) ───────────────────────
|
|
236
260
|
/** Returns the chosen index, or -1 when cancelled / non-interactive. */
|
|
237
261
|
function select(title, labels) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
262
|
+
// `settle` (not `resolve`) avoids shadowing the imported path.resolve.
|
|
263
|
+
return new Promise((settle) => {
|
|
264
|
+
if (!process.stdin.isTTY || labels.length === 0)
|
|
265
|
+
return settle(-1);
|
|
266
|
+
let idx = 0;
|
|
267
|
+
const n = labels.length;
|
|
268
|
+
const out = process.stderr;
|
|
269
|
+
readline.emitKeypressEvents(process.stdin);
|
|
270
|
+
process.stdin.setRawMode(true);
|
|
271
|
+
process.stdin.resume();
|
|
272
|
+
out.write(`${title}\n`);
|
|
273
|
+
const draw = () => labels.forEach((l, i) => {
|
|
274
|
+
out.write(`\x1b[2K${i === idx ? paint.cyan("❯ ") : " "}${l}\n`);
|
|
275
|
+
});
|
|
276
|
+
draw();
|
|
277
|
+
const done = (r) => {
|
|
278
|
+
process.stdin.removeListener("keypress", onKey);
|
|
279
|
+
process.stdin.setRawMode(false);
|
|
280
|
+
process.stdin.pause();
|
|
281
|
+
out.write("\n");
|
|
282
|
+
settle(r);
|
|
283
|
+
};
|
|
284
|
+
const onKey = (str, key) => {
|
|
285
|
+
if (key.name === "up" || str === "k")
|
|
286
|
+
idx = (idx - 1 + n) % n;
|
|
287
|
+
else if (key.name === "down" || str === "j")
|
|
288
|
+
idx = (idx + 1) % n;
|
|
289
|
+
else if (key.name === "return")
|
|
290
|
+
return done(idx);
|
|
291
|
+
else if (key.name === "escape" || str === "q" || (key.ctrl && key.name === "c"))
|
|
292
|
+
return done(-1);
|
|
293
|
+
else
|
|
294
|
+
return;
|
|
295
|
+
out.write(`\x1b[${n}A`);
|
|
296
|
+
draw();
|
|
297
|
+
};
|
|
298
|
+
process.stdin.on("keypress", onKey);
|
|
299
|
+
});
|
|
268
300
|
}
|
|
269
|
-
|
|
270
301
|
async function ask(prompt, prefill) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
302
|
+
if (!process.stdin.isTTY)
|
|
303
|
+
return "";
|
|
304
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
305
|
+
const a = await new Promise((r) => {
|
|
306
|
+
rl.question(prompt, (x) => {
|
|
307
|
+
rl.close();
|
|
308
|
+
r(x);
|
|
309
|
+
});
|
|
310
|
+
if (prefill)
|
|
311
|
+
rl.write(prefill); // pre-fill an editable default: Enter accepts, or edit it
|
|
312
|
+
});
|
|
313
|
+
return a.trim();
|
|
278
314
|
}
|
|
279
|
-
|
|
280
315
|
async function confirm(msg) {
|
|
281
|
-
|
|
282
|
-
|
|
316
|
+
if (!process.stdin.isTTY)
|
|
317
|
+
return false;
|
|
318
|
+
return /^y(es)?$/i.test(await ask(`${msg} [y/N] `));
|
|
283
319
|
}
|
|
284
|
-
|
|
285
320
|
/** Resolve which clone to act on: inside a clone, by name, only-one, or interactive pick. */
|
|
286
321
|
async function pickClone(action, name) {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
322
|
+
const here = repoTopLevel(process.cwd());
|
|
323
|
+
const hm = here ? readMarker(here) : undefined;
|
|
324
|
+
if (here && hm && !name)
|
|
325
|
+
return { originRepo: hm.originRepo, clone: { dir: here, name: hm.name || basename(here), marker: hm } };
|
|
326
|
+
// If `name` resolves to a clone directory, use its marker directly — works from anywhere,
|
|
327
|
+
// even outside a repo (e.g. `kage rm ../app--fix` from the parent dir).
|
|
328
|
+
if (name) {
|
|
329
|
+
const asPath = resolve(name);
|
|
330
|
+
const pm = readMarker(asPath);
|
|
331
|
+
if (pm)
|
|
332
|
+
return { originRepo: pm.originRepo, clone: { dir: asPath, name: pm.name || basename(asPath), marker: pm } };
|
|
333
|
+
}
|
|
334
|
+
const originRepo = hm ? hm.originRepo : here;
|
|
335
|
+
if (!originRepo)
|
|
336
|
+
die("not a git repository (run inside the repo or clone, or pass a path to a clone)");
|
|
337
|
+
const clones = listClones(originRepo);
|
|
338
|
+
if (clones.length === 0)
|
|
339
|
+
die("no shadow clones found for this repo");
|
|
340
|
+
if (name) {
|
|
341
|
+
const c = clones.find((x) => x.name === name || basename(x.dir) === name);
|
|
342
|
+
if (!c)
|
|
343
|
+
die(`no clone named ${name}`);
|
|
344
|
+
return { originRepo, clone: c };
|
|
345
|
+
}
|
|
346
|
+
const first = clones[0];
|
|
347
|
+
if (first && clones.length === 1)
|
|
348
|
+
return { originRepo, clone: first };
|
|
349
|
+
const idx = await select(`Multiple clones — pick one to ${action}:`, clones.map((c) => `${c.name} ${paint.dim(cloneStatus(c.dir).branch)}`));
|
|
350
|
+
const chosen = idx < 0 ? undefined : clones[idx];
|
|
351
|
+
if (!chosen)
|
|
352
|
+
return null;
|
|
353
|
+
return { originRepo, clone: chosen };
|
|
313
354
|
}
|
|
314
|
-
|
|
315
355
|
// ── copy the origin's session history into the clone ─────────────────────────
|
|
316
356
|
/**
|
|
317
357
|
* Copies the origin's most recent session files (up to RECENT_SESSIONS, by mtime) into the
|
|
@@ -321,31 +361,32 @@ async function pickClone(action, name) {
|
|
|
321
361
|
* resumed one and added turns, it comes back as a separate session (see mergeBack).
|
|
322
362
|
*/
|
|
323
363
|
function copyOriginHistory(originRepo, cloneDir) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
364
|
+
const srcDir = sessionDirFor(originRepo);
|
|
365
|
+
if (!existsSync(srcDir))
|
|
366
|
+
return 0;
|
|
367
|
+
const destDir = sessionDirFor(cloneDir);
|
|
368
|
+
mkdirSync(destDir, { recursive: true });
|
|
369
|
+
const recent = readdirSync(srcDir)
|
|
370
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
371
|
+
.map((f) => ({ f, m: statSync(join(srcDir, f)).mtimeMs }))
|
|
372
|
+
.sort((a, b) => b.m - a.m)
|
|
373
|
+
.slice(0, RECENT_SESSIONS);
|
|
374
|
+
let n = 0;
|
|
375
|
+
for (const { f } of recent) {
|
|
376
|
+
const lines = readFileSync(join(srcDir, f), "utf8").split("\n");
|
|
377
|
+
try {
|
|
378
|
+
const header = JSON.parse(lines[0] ?? "");
|
|
379
|
+
header.cwd = cloneDir;
|
|
380
|
+
lines[0] = JSON.stringify(header);
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
/* leave malformed header as-is */
|
|
384
|
+
}
|
|
385
|
+
writeFileSync(join(destDir, f), lines.join("\n"));
|
|
386
|
+
n++;
|
|
387
|
+
}
|
|
388
|
+
return n;
|
|
347
389
|
}
|
|
348
|
-
|
|
349
390
|
// ── merge the clone's new sessions back into the origin ──────────────────────
|
|
350
391
|
/**
|
|
351
392
|
* Copies the clone's sessions into the origin's session dir:
|
|
@@ -356,69 +397,78 @@ function copyOriginHistory(originRepo, cloneDir) {
|
|
|
356
397
|
* resumes) is never mutated. Costs a duplicated prefix; avoids hijacking the origin's leaf.
|
|
357
398
|
*/
|
|
358
399
|
function mergeBack(cloneDir, originRepo) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
400
|
+
const srcDir = sessionDirFor(cloneDir);
|
|
401
|
+
if (!existsSync(srcDir))
|
|
402
|
+
return 0;
|
|
403
|
+
const destDir = sessionDirFor(originRepo);
|
|
404
|
+
mkdirSync(destDir, { recursive: true });
|
|
405
|
+
let n = 0;
|
|
406
|
+
for (const f of readdirSync(srcDir)) {
|
|
407
|
+
if (!f.endsWith(".jsonl"))
|
|
408
|
+
continue;
|
|
409
|
+
const src = readFileSync(join(srcDir, f), "utf8")
|
|
410
|
+
.split("\n")
|
|
411
|
+
.filter((l) => l.trim());
|
|
412
|
+
if (src.length === 0)
|
|
413
|
+
continue;
|
|
414
|
+
const dest = join(destDir, f);
|
|
415
|
+
if (!existsSync(dest)) {
|
|
416
|
+
let header;
|
|
417
|
+
try {
|
|
418
|
+
header = JSON.parse(src[0] ?? "");
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
header.cwd = originRepo;
|
|
424
|
+
writeFileSync(dest, `${[JSON.stringify(header), ...src.slice(1)].join("\n")}\n`);
|
|
425
|
+
n++;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
// A copied-in origin session. If the clone added records (e.g. you resumed it there),
|
|
429
|
+
// write the clone's full session back as a NEW, self-contained file — leaving the origin's
|
|
430
|
+
// original file (and the leaf pi resumes) untouched. Unchanged copies add nothing.
|
|
431
|
+
const have = new Set();
|
|
432
|
+
for (const l of readFileSync(dest, "utf8").split("\n")) {
|
|
433
|
+
if (!l.trim())
|
|
434
|
+
continue;
|
|
435
|
+
try {
|
|
436
|
+
have.add(JSON.parse(l).id);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
/* ignore */
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const hasNew = src.slice(1).some((l) => {
|
|
443
|
+
try {
|
|
444
|
+
return !have.has(JSON.parse(l).id);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
if (!hasNew)
|
|
451
|
+
continue;
|
|
452
|
+
let header;
|
|
453
|
+
try {
|
|
454
|
+
header = JSON.parse(src[0] ?? "");
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
const id = randomUUID();
|
|
460
|
+
const fname = `${new Date().toISOString().replace(/[:.]/g, "-")}_${id}.jsonl`;
|
|
461
|
+
writeFileSync(join(destDir, fname), `${[JSON.stringify({ ...header, id, cwd: originRepo }), ...src.slice(1)].join("\n")}\n`);
|
|
462
|
+
n++;
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
rmSync(srcDir, { recursive: true, force: true });
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
/* ignore */
|
|
469
|
+
}
|
|
470
|
+
return n;
|
|
420
471
|
}
|
|
421
|
-
|
|
422
472
|
/**
|
|
423
473
|
* We just deleted the clone we were running inside, so the parent shell is now in a
|
|
424
474
|
* deleted directory. A CLI can't cd its parent shell, so: if the shell wrapper is active
|
|
@@ -426,299 +476,311 @@ function mergeBack(cloneDir, originRepo) {
|
|
|
426
476
|
* otherwise print a copy-pasteable `cd` and how to enable the auto version.
|
|
427
477
|
*/
|
|
428
478
|
function leaveClone(originRepo) {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
479
|
+
const f = process.env.KAGE_CD_FILE;
|
|
480
|
+
if (f) {
|
|
481
|
+
try {
|
|
482
|
+
writeFileSync(f, originRepo);
|
|
483
|
+
info(paint.dim(` ↩ back to ${originRepo}`));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
/* fall through to the manual hint */
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
info(paint.yellow(` ↩ your shell is still in the deleted clone — run: ${paint.bold(`cd ${originRepo}`)}`));
|
|
491
|
+
info(paint.dim(` enable auto cd-back: add eval "$(kage shell-init)" to your ~/.zshrc`));
|
|
441
492
|
}
|
|
442
|
-
|
|
443
493
|
function launchPi(cwd, args) {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
494
|
+
const r = spawnSync("pi", args, { cwd, stdio: "inherit" });
|
|
495
|
+
if (r.error) {
|
|
496
|
+
const err = r.error;
|
|
497
|
+
if (err.code === "ENOENT")
|
|
498
|
+
die("pi not found (make sure it is installed and on your PATH)");
|
|
499
|
+
die(`failed to launch pi: ${err.message}`);
|
|
500
|
+
}
|
|
449
501
|
}
|
|
450
|
-
|
|
451
502
|
// ── subcommands ───────────────────────────────────────────────────────────────
|
|
452
503
|
async function cmdNew(argv) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
info("");
|
|
529
|
-
info(`↩︎ left the clone's pi. To finish: ${paint.bold(`kage finish ${safe}`)}`);
|
|
504
|
+
const { positional, flags } = parseArgs(argv);
|
|
505
|
+
const path = positional[0];
|
|
506
|
+
// Interactive launcher: `kage` with no args, inside a repo that already has clones.
|
|
507
|
+
if (!path && !boolFlag(flags, "name") && process.stdin.isTTY) {
|
|
508
|
+
const repoRoot = repoTopLevel(process.cwd());
|
|
509
|
+
const clones = repoRoot ? listClones(repoRoot) : [];
|
|
510
|
+
if (repoRoot && clones.length > 0) {
|
|
511
|
+
const labels = [
|
|
512
|
+
"+ Create a new shadow clone",
|
|
513
|
+
...clones.map((c) => {
|
|
514
|
+
const s = cloneStatus(c.dir);
|
|
515
|
+
const tag = s.dirty ? paint.yellow(" ●") : isSafeToClean(s) ? paint.green(" ✓") : "";
|
|
516
|
+
return `→ Enter ${c.name} ${paint.cyan(s.branch)}${tag}`;
|
|
517
|
+
}),
|
|
518
|
+
];
|
|
519
|
+
const idx = await select(`Shadow clones of ${basename(repoRoot)} — pick one, or create:`, labels);
|
|
520
|
+
if (idx < 0)
|
|
521
|
+
return info("cancelled");
|
|
522
|
+
if (idx > 0) {
|
|
523
|
+
const clone = clones[idx - 1];
|
|
524
|
+
if (!clone)
|
|
525
|
+
return info("cancelled");
|
|
526
|
+
const act = await select(`${clone.name}:`, ["Enter (resume pi)", "Finish (merge memory & remove)", "Remove (discard)", "Cancel"]);
|
|
527
|
+
if (act === 0)
|
|
528
|
+
return launchPi(clone.dir, ["-c"]);
|
|
529
|
+
if (act === 1)
|
|
530
|
+
return cmdFinish([clone.name]);
|
|
531
|
+
if (act === 2)
|
|
532
|
+
return cmdRm([clone.name]);
|
|
533
|
+
return info("cancelled");
|
|
534
|
+
}
|
|
535
|
+
// idx === 0: "create" — fall through to the name prompt below.
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const targetPath = path ? resolve(path) : process.cwd();
|
|
539
|
+
const repoRoot = repoTopLevel(targetPath);
|
|
540
|
+
if (!repoRoot)
|
|
541
|
+
die(`not a git repository: ${targetPath}`);
|
|
542
|
+
if (existsSync(join(repoRoot, MARKER)))
|
|
543
|
+
die("already inside a clone; run kage from the origin repo");
|
|
544
|
+
// Resolve the clone name: explicit --name wins; otherwise show the full folder name with
|
|
545
|
+
// the fixed "<repo>--" prefix in the prompt and an editable default suffix — press Enter to
|
|
546
|
+
// accept, or edit the suffix (non-interactive falls back to the default).
|
|
547
|
+
let name = strFlag(flags, "name") ?? "";
|
|
548
|
+
if (!name) {
|
|
549
|
+
const def = tsName();
|
|
550
|
+
const prompt = `Kage name: ${basename(repoRoot)}--`;
|
|
551
|
+
name = (process.stdin.isTTY ? await ask(prompt, def) : "") || def;
|
|
552
|
+
}
|
|
553
|
+
const safe = slug(name);
|
|
554
|
+
const cloneDir = join(dirname(repoRoot), `${basename(repoRoot)}--${safe}`);
|
|
555
|
+
if (existsSync(cloneDir))
|
|
556
|
+
die(`directory already exists: ${cloneDir}`);
|
|
557
|
+
const cp = await copyRepo(repoRoot, cloneDir);
|
|
558
|
+
if (!cp.ok)
|
|
559
|
+
die(`copy failed: ${cp.err}`);
|
|
560
|
+
// kage does NOT create a branch — the clone stays on the origin's current branch.
|
|
561
|
+
const histN = copyOriginHistory(repoRoot, cloneDir);
|
|
562
|
+
const marker = {
|
|
563
|
+
originRepo: repoRoot,
|
|
564
|
+
name: safe,
|
|
565
|
+
createdAt: new Date().toISOString(),
|
|
566
|
+
};
|
|
567
|
+
writeFileSync(join(cloneDir, MARKER), JSON.stringify(marker, null, 2));
|
|
568
|
+
const curBranch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out || "?";
|
|
569
|
+
info("");
|
|
570
|
+
info(`🥷 ${paint.bold("Shadow clone ready")}: ${cloneDir}`);
|
|
571
|
+
info(` origin: ${repoRoot} branch: ${paint.cyan(curBranch)}`);
|
|
572
|
+
if (histN > 0)
|
|
573
|
+
info(paint.dim(` origin's ${histN} session(s) are available via resume (pi: pick from the list)`));
|
|
574
|
+
info(paint.dim(` when done: kage finish ${safe}`));
|
|
575
|
+
info("");
|
|
576
|
+
launchPi(cloneDir, []);
|
|
577
|
+
info("");
|
|
578
|
+
info(`↩︎ left the clone's pi. To finish: ${paint.bold(`kage finish ${safe}`)}`);
|
|
530
579
|
}
|
|
531
|
-
|
|
532
580
|
async function cmdFinish(argv) {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
581
|
+
const { positional, flags } = parseArgs(argv);
|
|
582
|
+
const force = boolFlag(flags, "force");
|
|
583
|
+
const pr = boolFlag(flags, "pr");
|
|
584
|
+
const push = pr || boolFlag(flags, "push"); // --pr implies --push
|
|
585
|
+
const picked = await pickClone("finish", positional[0]);
|
|
586
|
+
if (!picked)
|
|
587
|
+
return info("cancelled");
|
|
588
|
+
const { originRepo, clone } = picked;
|
|
589
|
+
const insideClone = repoTopLevel(process.cwd()) === clone.dir;
|
|
590
|
+
// Optional convenience: push the branch (and open a PR) before finishing.
|
|
591
|
+
if (push) {
|
|
592
|
+
const s = cloneStatus(clone.dir);
|
|
593
|
+
if (s.dirty)
|
|
594
|
+
die(`${clone.name} has uncommitted changes — commit them first (kage won't auto-commit)`);
|
|
595
|
+
if (!s.hasUpstream) {
|
|
596
|
+
const r = git(clone.dir, ["push", "-u", "origin", s.branch]);
|
|
597
|
+
if (!r.ok)
|
|
598
|
+
die(`push failed: ${r.err}`);
|
|
599
|
+
info(`⬆ pushed ${s.branch} to origin`);
|
|
600
|
+
}
|
|
601
|
+
else if (s.ahead > 0) {
|
|
602
|
+
const r = git(clone.dir, ["push"]);
|
|
603
|
+
if (!r.ok)
|
|
604
|
+
die(`push failed: ${r.err}`);
|
|
605
|
+
info(`⬆ pushed ${s.ahead} commit(s)`);
|
|
606
|
+
}
|
|
607
|
+
if (pr) {
|
|
608
|
+
const existing = prInfo(clone.dir, s.branch);
|
|
609
|
+
if (existing) {
|
|
610
|
+
info(`🔗 PR already open: ${existing.url}`);
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
const r = sh("gh", ["pr", "create", "--fill"], { cwd: clone.dir });
|
|
614
|
+
if (!r.ok)
|
|
615
|
+
die(`gh pr create failed: ${r.err || r.out || "is gh installed & authed?"}`);
|
|
616
|
+
info(`🔗 opened PR: ${r.out.split("\n").pop()}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
// Decide how to preserve the clone's committed work before deleting it.
|
|
621
|
+
const s = cloneStatus(clone.dir);
|
|
622
|
+
const hasRemote = git(clone.dir, ["remote"]).out.trim().length > 0;
|
|
623
|
+
if (!force) {
|
|
624
|
+
if (s.dirty)
|
|
625
|
+
die(`${clone.name}: uncommitted changes — commit them, or pass --force to discard them`);
|
|
626
|
+
// With a remote, keep the "push your work" guard so PR-flow mistakes surface.
|
|
627
|
+
if (hasRemote && (!s.hasUpstream || s.ahead > 0)) {
|
|
628
|
+
die(`${clone.name}: branch not pushed — push it (or use --push / --pr), or pass --force`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// Preserve committed work that isn't on a remote: fetch the clone's branch into the origin
|
|
632
|
+
// as a local 'kage/<name>' branch (origin's working tree is left untouched). This is what
|
|
633
|
+
// makes finish lossless without GitHub — the commits land in the origin's git, ready to merge.
|
|
634
|
+
if (hasUnpreservedCommits(originRepo, clone.dir, s)) {
|
|
635
|
+
const head = git(clone.dir, ["rev-parse", "HEAD"]).out;
|
|
636
|
+
// Always a unique ref (name + short sha) so reusing a clone name never collides with an
|
|
637
|
+
// earlier preserved branch — which would either abort the fetch (non-ff) or clobber it.
|
|
638
|
+
const target = `kage/${slug(clone.name)}-${head.slice(0, 7)}`;
|
|
639
|
+
const r = git(originRepo, ["fetch", clone.dir, `${s.branch}:refs/heads/${target}`]);
|
|
640
|
+
if (!r.ok)
|
|
641
|
+
die(`failed to preserve the clone's branch into the origin: ${r.err}`);
|
|
642
|
+
info(`🌿 preserved the clone's commits in the origin as ${paint.cyan(target)} (merge with: git merge ${target})`);
|
|
643
|
+
}
|
|
644
|
+
const n = mergeBack(clone.dir, originRepo);
|
|
645
|
+
try {
|
|
646
|
+
process.chdir(originRepo);
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
/* ignore */
|
|
650
|
+
}
|
|
651
|
+
rmSync(clone.dir, { recursive: true, force: true });
|
|
652
|
+
info(`💨 Clone dispelled: merged ${n} session(s) back, removed ${clone.dir}`);
|
|
653
|
+
if (insideClone)
|
|
654
|
+
leaveClone(originRepo);
|
|
602
655
|
}
|
|
603
|
-
|
|
604
656
|
async function cmdRm(argv) {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
657
|
+
const { positional, flags } = parseArgs(argv);
|
|
658
|
+
const force = boolFlag(flags, "force");
|
|
659
|
+
const picked = await pickClone("remove", positional[0]);
|
|
660
|
+
if (!picked)
|
|
661
|
+
return info("cancelled");
|
|
662
|
+
const { originRepo, clone } = picked;
|
|
663
|
+
const insideClone = repoTopLevel(process.cwd()) === clone.dir;
|
|
664
|
+
if (!force) {
|
|
665
|
+
const s = cloneStatus(clone.dir);
|
|
666
|
+
if (s.dirty || hasUnpreservedCommits(originRepo, clone.dir, s)) {
|
|
667
|
+
die(`${clone.name} has local-only work — use 'kage finish' to keep it, or 'kage rm --force' to discard`);
|
|
668
|
+
}
|
|
669
|
+
if (!(await confirm(`Discard clone ${clone.name} without merging its memory?`)))
|
|
670
|
+
return info("aborted");
|
|
671
|
+
}
|
|
672
|
+
try {
|
|
673
|
+
process.chdir(originRepo);
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
/* ignore */
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
rmSync(sessionDirFor(clone.dir), { recursive: true, force: true });
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
/* ignore */
|
|
683
|
+
}
|
|
684
|
+
rmSync(clone.dir, { recursive: true, force: true });
|
|
685
|
+
info(`🗑 Removed clone ${clone.name} (${clone.dir})`);
|
|
686
|
+
if (insideClone)
|
|
687
|
+
leaveClone(originRepo);
|
|
633
688
|
}
|
|
634
|
-
|
|
635
689
|
function cmdList(argv) {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
690
|
+
const { flags } = parseArgs(argv);
|
|
691
|
+
const here = repoTopLevel(process.cwd());
|
|
692
|
+
if (!here)
|
|
693
|
+
die("not a git repository");
|
|
694
|
+
// Works from inside a clone too: resolve to the origin via the marker, then list its clones.
|
|
695
|
+
const repoRoot = readMarker(here)?.originRepo || here;
|
|
696
|
+
const clones = listClones(repoRoot);
|
|
697
|
+
if (clones.length === 0) {
|
|
698
|
+
info("No shadow clones.");
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
info(paint.bold(`Shadow clones of ${basename(repoRoot)}:`));
|
|
702
|
+
info("");
|
|
703
|
+
for (const c of clones) {
|
|
704
|
+
const s = cloneStatus(c.dir);
|
|
705
|
+
const pr = boolFlag(flags, "pr") ? prInfo(c.dir, s.branch) : undefined;
|
|
706
|
+
// header: status glyph · name · branch · age
|
|
707
|
+
const glyph = s.dirty ? paint.yellow("●") : isSafeToClean(s) ? paint.green("✓") : paint.cyan("·");
|
|
708
|
+
const age = c.marker?.createdAt ? paint.dim(`created ${ago(c.marker.createdAt)}`) : "";
|
|
709
|
+
info(` ${glyph} ${paint.bold(c.name)} ${paint.cyan(s.branch)} ${age}`);
|
|
710
|
+
// detail: working-tree state · sync · PR · safe-to-clean
|
|
711
|
+
const parts = [];
|
|
712
|
+
if (s.dirty) {
|
|
713
|
+
let d = `${s.dirtyCount} changed`;
|
|
714
|
+
if (s.added || s.removed)
|
|
715
|
+
d += ` (${paint.green(`+${s.added}`)} ${paint.red(`-${s.removed}`)})`;
|
|
716
|
+
parts.push(paint.yellow(d));
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
parts.push(paint.green("clean"));
|
|
720
|
+
}
|
|
721
|
+
if (!s.hasUpstream)
|
|
722
|
+
parts.push(paint.dim("not pushed"));
|
|
723
|
+
else {
|
|
724
|
+
const sync = [];
|
|
725
|
+
if (s.ahead)
|
|
726
|
+
sync.push(`↑${s.ahead}`);
|
|
727
|
+
if (s.behind)
|
|
728
|
+
sync.push(`↓${s.behind}`);
|
|
729
|
+
parts.push(sync.length ? sync.join(" ") : paint.dim("in sync"));
|
|
730
|
+
}
|
|
731
|
+
if (pr)
|
|
732
|
+
parts.push(prState(pr));
|
|
733
|
+
if (isSafeToClean(s))
|
|
734
|
+
parts.push(paint.green("safe to clean"));
|
|
735
|
+
info(` ${parts.join(paint.dim(" · "))}`);
|
|
736
|
+
// last commit on the branch
|
|
737
|
+
if (s.lastCommit) {
|
|
738
|
+
info(paint.dim(` last: ${s.lastCommit.sha} "${s.lastCommit.subject}" (${s.lastCommit.when})`));
|
|
739
|
+
}
|
|
740
|
+
info("");
|
|
741
|
+
}
|
|
742
|
+
info(paint.dim(" finish <name> to merge & remove · rm <name> to discard · status --pr for PR status"));
|
|
682
743
|
}
|
|
683
|
-
|
|
744
|
+
const PR_COLORS = { OPEN: paint.green, MERGED: paint.magenta, CLOSED: paint.red };
|
|
684
745
|
function prState(pr) {
|
|
685
|
-
|
|
686
|
-
|
|
746
|
+
const f = PR_COLORS[pr.state] ?? paint.dim;
|
|
747
|
+
return f(`PR #${pr.number} ${pr.state.toLowerCase()}`);
|
|
687
748
|
}
|
|
688
|
-
|
|
689
749
|
function cmdPull(argv) {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
750
|
+
const { positional } = parseArgs(argv);
|
|
751
|
+
const cloneDir = repoTopLevel(process.cwd());
|
|
752
|
+
const marker = cloneDir ? readMarker(cloneDir) : undefined;
|
|
753
|
+
if (!cloneDir || !marker)
|
|
754
|
+
die("kage pull only runs inside a clone (edit the origin directly otherwise)");
|
|
755
|
+
if (positional.length === 0)
|
|
756
|
+
die("usage: kage pull <relative-path> [more paths...]");
|
|
757
|
+
const originRepo = marker.originRepo;
|
|
758
|
+
const cloneRoot = cloneDir.endsWith(sep) ? cloneDir : cloneDir + sep;
|
|
759
|
+
const originRoot = originRepo.endsWith(sep) ? originRepo : originRepo + sep;
|
|
760
|
+
let done = 0;
|
|
761
|
+
for (const rel of positional) {
|
|
762
|
+
const src = resolve(cloneDir, rel);
|
|
763
|
+
const dst = resolve(originRepo, rel);
|
|
764
|
+
if (!src.startsWith(cloneRoot) || !dst.startsWith(originRoot)) {
|
|
765
|
+
info(`✗ path escapes the repo, skipped: ${rel}`);
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
if (!existsSync(src)) {
|
|
769
|
+
info(`✗ not found in clone, skipped: ${rel}`);
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (existsSync(dst))
|
|
773
|
+
rmSync(dst, { recursive: true, force: true });
|
|
774
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
775
|
+
const cp = copyTree(src, dst);
|
|
776
|
+
if (!cp.ok) {
|
|
777
|
+
info(`✗ copy failed ${rel}: ${cp.err}`);
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
done++;
|
|
781
|
+
}
|
|
782
|
+
info(`📤 Pulled ${done}/${positional.length} path(s) from the clone back to the origin (${originRepo})`);
|
|
720
783
|
}
|
|
721
|
-
|
|
722
784
|
const SHELL_INIT = `# kage shell integration — add to ~/.zshrc or ~/.bashrc: eval "$(kage shell-init)"
|
|
723
785
|
kage() {
|
|
724
786
|
local f; f="$(mktemp "\${TMPDIR:-/tmp}/kage-cd.XXXXXX")"
|
|
@@ -745,13 +807,13 @@ elif [ -n "$BASH_VERSION" ]; then
|
|
|
745
807
|
}
|
|
746
808
|
complete -F _kage kage
|
|
747
809
|
fi`;
|
|
748
|
-
|
|
749
810
|
function cmdClones() {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
811
|
+
const repoRoot = repoTopLevel(process.cwd());
|
|
812
|
+
if (!repoRoot)
|
|
813
|
+
return;
|
|
814
|
+
for (const c of listClones(repoRoot))
|
|
815
|
+
process.stdout.write(`${c.name}\n`);
|
|
753
816
|
}
|
|
754
|
-
|
|
755
817
|
const HELP = `kage 🥷 — Shadow Clone Jutsu for your git repo
|
|
756
818
|
|
|
757
819
|
Usage:
|
|
@@ -794,41 +856,49 @@ Examples:
|
|
|
794
856
|
|
|
795
857
|
Env:
|
|
796
858
|
KAGE_SESSIONS_DIR pi session storage (default: ~/.pi/agent/sessions)`;
|
|
797
|
-
|
|
798
859
|
async function main() {
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
860
|
+
const [sub, ...rest] = process.argv.slice(2);
|
|
861
|
+
switch (sub) {
|
|
862
|
+
case undefined:
|
|
863
|
+
case "new":
|
|
864
|
+
return cmdNew(sub === "new" ? rest : process.argv.slice(2));
|
|
865
|
+
case "status":
|
|
866
|
+
case "list": // alias
|
|
867
|
+
return cmdList(rest);
|
|
868
|
+
case "finish":
|
|
869
|
+
return cmdFinish(rest);
|
|
870
|
+
case "rm":
|
|
871
|
+
return cmdRm(rest);
|
|
872
|
+
case "pull":
|
|
873
|
+
return cmdPull(rest);
|
|
874
|
+
case "shell-init":
|
|
875
|
+
case "completion": {
|
|
876
|
+
process.stdout.write(`${SHELL_INIT}\n`);
|
|
877
|
+
// When a human runs this directly (stdout is a TTY, not captured by `$(...)`),
|
|
878
|
+
// the script just scrolled past unused — show how to actually activate it.
|
|
879
|
+
// During `eval "$(kage shell-init)"` stdout is a pipe, so this stays silent.
|
|
880
|
+
if (process.stdout.isTTY) {
|
|
881
|
+
info("");
|
|
882
|
+
info(paint.dim("# ↑ the script above isn't run by printing it — activate it with:"));
|
|
883
|
+
info(` eval "$(kage shell-init)" ${paint.dim("# add this line to your ~/.zshrc or ~/.bashrc")}`);
|
|
884
|
+
}
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
case "__clones":
|
|
888
|
+
return cmdClones();
|
|
889
|
+
case "-h":
|
|
890
|
+
case "--help":
|
|
891
|
+
return info(HELP);
|
|
892
|
+
case "-v":
|
|
893
|
+
case "--version":
|
|
894
|
+
return info(VERSION);
|
|
895
|
+
default:
|
|
896
|
+
// `kage <path>` clones another repo, but a bare word that isn't an existing directory
|
|
897
|
+
// is a mistyped command (e.g. `kage statsu`) — fail clearly instead of "not a git repository".
|
|
898
|
+
if (!sub.startsWith("-") && !(existsSync(resolve(sub)) && statSync(resolve(sub)).isDirectory())) {
|
|
899
|
+
die(`unknown command or path: ${sub} (run 'kage --help')`);
|
|
900
|
+
}
|
|
901
|
+
return cmdNew(process.argv.slice(2)); // `kage <path>` or `kage <flags>`
|
|
902
|
+
}
|
|
832
903
|
}
|
|
833
|
-
|
|
834
904
|
main();
|