@xbghc/warden 0.15.0 → 0.16.1
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 +43 -462
- package/dist/cli.js +842 -485
- package/dist/web/assets/index-DWKs7nG-.js +198 -0
- package/dist/web/assets/index-lss80_YX.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-W_TDLgHi.css +0 -1
- package/dist/web/assets/index-yrM09beV.js +0 -200
package/dist/cli.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// bin/cli.ts
|
|
4
|
-
import
|
|
4
|
+
import path12 from "path";
|
|
5
5
|
import { spawn } from "child_process";
|
|
6
6
|
import { existsSync as existsSync2 } from "fs";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
8
|
|
|
9
9
|
// packages/server/src/index.ts
|
|
10
10
|
import net from "net";
|
|
11
|
-
import { randomUUID as
|
|
11
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
12
12
|
|
|
13
13
|
// node_modules/.pnpm/@hono+node-server@1.19.17_hono@4.13.7/node_modules/@hono/node-server/dist/index.mjs
|
|
14
14
|
import { createServer as createServerHTTP } from "http";
|
|
@@ -652,8 +652,9 @@ var serve = (options, listeningListener) => {
|
|
|
652
652
|
};
|
|
653
653
|
|
|
654
654
|
// packages/server/src/app.ts
|
|
655
|
-
import
|
|
656
|
-
import {
|
|
655
|
+
import path10 from "path";
|
|
656
|
+
import { stat as stat7 } from "fs/promises";
|
|
657
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
657
658
|
|
|
658
659
|
// node_modules/.pnpm/hono@4.13.7/node_modules/hono/dist/compose.js
|
|
659
660
|
var compose = (middleware, onError, onNotFound) => {
|
|
@@ -812,26 +813,26 @@ var throwNestingLimitExceeded = () => {
|
|
|
812
813
|
};
|
|
813
814
|
|
|
814
815
|
// node_modules/.pnpm/hono@4.13.7/node_modules/hono/dist/utils/url.js
|
|
815
|
-
var splitPath = (
|
|
816
|
-
const paths =
|
|
816
|
+
var splitPath = (path13) => {
|
|
817
|
+
const paths = path13.split("/");
|
|
817
818
|
if (paths[0] === "") {
|
|
818
819
|
paths.shift();
|
|
819
820
|
}
|
|
820
821
|
return paths;
|
|
821
822
|
};
|
|
822
823
|
var splitRoutingPath = (routePath) => {
|
|
823
|
-
const { groups, path:
|
|
824
|
-
const paths = splitPath(
|
|
824
|
+
const { groups, path: path13 } = extractGroupsFromPath(routePath);
|
|
825
|
+
const paths = splitPath(path13);
|
|
825
826
|
return replaceGroupMarks(paths, groups);
|
|
826
827
|
};
|
|
827
|
-
var extractGroupsFromPath = (
|
|
828
|
+
var extractGroupsFromPath = (path13) => {
|
|
828
829
|
const groups = [];
|
|
829
|
-
|
|
830
|
+
path13 = path13.replace(/\{[^}]+\}/g, (match2, index) => {
|
|
830
831
|
const mark = `@${index}`;
|
|
831
832
|
groups.push([mark, match2]);
|
|
832
833
|
return mark;
|
|
833
834
|
});
|
|
834
|
-
return { groups, path:
|
|
835
|
+
return { groups, path: path13 };
|
|
835
836
|
};
|
|
836
837
|
var replaceGroupMarks = (paths, groups) => {
|
|
837
838
|
for (let i = groups.length - 1; i >= 0; i--) {
|
|
@@ -888,8 +889,8 @@ var getPath = (request) => {
|
|
|
888
889
|
const queryIndex = url.indexOf("?", i);
|
|
889
890
|
const hashIndex = url.indexOf("#", i);
|
|
890
891
|
const end = queryIndex === -1 ? hashIndex === -1 ? void 0 : hashIndex : hashIndex === -1 ? queryIndex : Math.min(queryIndex, hashIndex);
|
|
891
|
-
const
|
|
892
|
-
return tryDecodeURI(
|
|
892
|
+
const path13 = url.slice(start, end);
|
|
893
|
+
return tryDecodeURI(path13.includes("%25") ? path13.replace(/%25/g, "%2525") : path13);
|
|
893
894
|
} else if (charCode === 63 || charCode === 35) {
|
|
894
895
|
break;
|
|
895
896
|
}
|
|
@@ -906,11 +907,11 @@ var mergePath = (base, sub, ...rest) => {
|
|
|
906
907
|
}
|
|
907
908
|
return `${base?.[0] === "/" ? "" : "/"}${base}${sub === "/" ? "" : `${base?.at(-1) === "/" ? "" : "/"}${sub?.[0] === "/" ? sub.slice(1) : sub}`}`;
|
|
908
909
|
};
|
|
909
|
-
var checkOptionalParameter = (
|
|
910
|
-
if (
|
|
910
|
+
var checkOptionalParameter = (path13) => {
|
|
911
|
+
if (path13.charCodeAt(path13.length - 1) !== 63 || !path13.includes(":")) {
|
|
911
912
|
return null;
|
|
912
913
|
}
|
|
913
|
-
const segments =
|
|
914
|
+
const segments = path13.split("/");
|
|
914
915
|
const results = [];
|
|
915
916
|
let basePath = "";
|
|
916
917
|
segments.forEach((segment) => {
|
|
@@ -1052,9 +1053,9 @@ var HonoRequest = class {
|
|
|
1052
1053
|
*/
|
|
1053
1054
|
path;
|
|
1054
1055
|
bodyCache = {};
|
|
1055
|
-
constructor(request,
|
|
1056
|
+
constructor(request, path13 = "/", matchResult = [[]]) {
|
|
1056
1057
|
this.raw = request;
|
|
1057
|
-
this.path =
|
|
1058
|
+
this.path = path13;
|
|
1058
1059
|
this.#matchResult = matchResult;
|
|
1059
1060
|
}
|
|
1060
1061
|
param(key) {
|
|
@@ -1826,8 +1827,8 @@ var Hono = class _Hono {
|
|
|
1826
1827
|
return this;
|
|
1827
1828
|
};
|
|
1828
1829
|
});
|
|
1829
|
-
this.on = (method,
|
|
1830
|
-
for (const p of [
|
|
1830
|
+
this.on = (method, path13, ...handlers) => {
|
|
1831
|
+
for (const p of [path13].flat()) {
|
|
1831
1832
|
this.#path = p;
|
|
1832
1833
|
for (const m of [method].flat()) {
|
|
1833
1834
|
const methodName = m.toUpperCase();
|
|
@@ -1885,8 +1886,8 @@ var Hono = class _Hono {
|
|
|
1885
1886
|
* app.route("/api", app2) // GET /api/user
|
|
1886
1887
|
* ```
|
|
1887
1888
|
*/
|
|
1888
|
-
route(
|
|
1889
|
-
const subApp = this.basePath(
|
|
1889
|
+
route(path13, app) {
|
|
1890
|
+
const subApp = this.basePath(path13);
|
|
1890
1891
|
app.routes.map((r) => {
|
|
1891
1892
|
let handler;
|
|
1892
1893
|
if (app.errorHandler === errorHandler) {
|
|
@@ -1912,9 +1913,9 @@ var Hono = class _Hono {
|
|
|
1912
1913
|
* const api = new Hono().basePath('/api')
|
|
1913
1914
|
* ```
|
|
1914
1915
|
*/
|
|
1915
|
-
basePath(
|
|
1916
|
+
basePath(path13) {
|
|
1916
1917
|
const subApp = this.#clone();
|
|
1917
|
-
subApp._basePath = mergePath(this._basePath,
|
|
1918
|
+
subApp._basePath = mergePath(this._basePath, path13);
|
|
1918
1919
|
return subApp;
|
|
1919
1920
|
}
|
|
1920
1921
|
/**
|
|
@@ -1988,7 +1989,7 @@ var Hono = class _Hono {
|
|
|
1988
1989
|
* })
|
|
1989
1990
|
* ```
|
|
1990
1991
|
*/
|
|
1991
|
-
mount(
|
|
1992
|
+
mount(path13, applicationHandler, options) {
|
|
1992
1993
|
let replaceRequest;
|
|
1993
1994
|
let optionHandler;
|
|
1994
1995
|
if (options) {
|
|
@@ -2015,7 +2016,7 @@ var Hono = class _Hono {
|
|
|
2015
2016
|
return [c.env, executionContext];
|
|
2016
2017
|
};
|
|
2017
2018
|
replaceRequest ||= (() => {
|
|
2018
|
-
const mergedPath = mergePath(this._basePath,
|
|
2019
|
+
const mergedPath = mergePath(this._basePath, path13);
|
|
2019
2020
|
const pathPrefixLength = mergedPath === "/" ? 0 : mergedPath.length;
|
|
2020
2021
|
return (request) => {
|
|
2021
2022
|
const url = new URL(request.url);
|
|
@@ -2030,18 +2031,18 @@ var Hono = class _Hono {
|
|
|
2030
2031
|
}
|
|
2031
2032
|
await next();
|
|
2032
2033
|
};
|
|
2033
|
-
this.#addRoute(METHOD_NAME_ALL, mergePath(
|
|
2034
|
+
this.#addRoute(METHOD_NAME_ALL, mergePath(path13, "*"), handler);
|
|
2034
2035
|
return this;
|
|
2035
2036
|
}
|
|
2036
|
-
#addRoute(method,
|
|
2037
|
-
|
|
2037
|
+
#addRoute(method, path13, handler, baseRoutePath) {
|
|
2038
|
+
path13 = mergePath(this._basePath, path13);
|
|
2038
2039
|
const r = {
|
|
2039
2040
|
basePath: baseRoutePath !== void 0 ? mergePath(this._basePath, baseRoutePath) : this._basePath,
|
|
2040
|
-
path:
|
|
2041
|
+
path: path13,
|
|
2041
2042
|
method,
|
|
2042
2043
|
handler
|
|
2043
2044
|
};
|
|
2044
|
-
this.router.add(method,
|
|
2045
|
+
this.router.add(method, path13, [handler, r]);
|
|
2045
2046
|
this.routes.push(r);
|
|
2046
2047
|
}
|
|
2047
2048
|
#handleError(err, c) {
|
|
@@ -2054,10 +2055,10 @@ var Hono = class _Hono {
|
|
|
2054
2055
|
if (method === "HEAD") {
|
|
2055
2056
|
return (async () => new Response(null, await this.#dispatch(request, executionCtx, env, "GET")))();
|
|
2056
2057
|
}
|
|
2057
|
-
const
|
|
2058
|
-
const matchResult = this.router.match(method,
|
|
2058
|
+
const path13 = this.getPath(request, { env });
|
|
2059
|
+
const matchResult = this.router.match(method, path13);
|
|
2059
2060
|
const c = new Context(request, {
|
|
2060
|
-
path:
|
|
2061
|
+
path: path13,
|
|
2061
2062
|
matchResult,
|
|
2062
2063
|
env,
|
|
2063
2064
|
executionCtx,
|
|
@@ -2160,7 +2161,7 @@ var createNullObject = () => /* @__PURE__ */ Object.create(null);
|
|
|
2160
2161
|
|
|
2161
2162
|
// node_modules/.pnpm/hono@4.13.7/node_modules/hono/dist/router/reg-exp-router/matcher.js
|
|
2162
2163
|
var emptyParam = [];
|
|
2163
|
-
function match(method,
|
|
2164
|
+
function match(method, path13) {
|
|
2164
2165
|
const matchers = this.buildAllMatchers();
|
|
2165
2166
|
const match2 = ((method2, path22) => {
|
|
2166
2167
|
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
|
@@ -2176,7 +2177,7 @@ function match(method, path12) {
|
|
|
2176
2177
|
return [matcher[1][index], match3];
|
|
2177
2178
|
});
|
|
2178
2179
|
this.match = match2;
|
|
2179
|
-
return match2(method,
|
|
2180
|
+
return match2(method, path13);
|
|
2180
2181
|
}
|
|
2181
2182
|
|
|
2182
2183
|
// node_modules/.pnpm/hono@4.13.7/node_modules/hono/dist/router/reg-exp-router/node.js
|
|
@@ -2293,14 +2294,14 @@ var Trie = class {
|
|
|
2293
2294
|
#index = 0;
|
|
2294
2295
|
// dynamic path -> [handler index, param assoc]; static paths are not registered
|
|
2295
2296
|
paths = createNullObject();
|
|
2296
|
-
insert(
|
|
2297
|
+
insert(path13, isStatic) {
|
|
2297
2298
|
if (isStatic) {
|
|
2298
|
-
this.#root.insert(
|
|
2299
|
+
this.#root.insert(path13.split(""), 0, [], this.#context, true);
|
|
2299
2300
|
return;
|
|
2300
2301
|
}
|
|
2301
2302
|
const paramAssoc = [];
|
|
2302
2303
|
const groups = [];
|
|
2303
|
-
let markedPath =
|
|
2304
|
+
let markedPath = path13;
|
|
2304
2305
|
for (let i = 0; ; ) {
|
|
2305
2306
|
let replaced = false;
|
|
2306
2307
|
markedPath = markedPath.replace(/\{[^}]+\}/g, (m) => {
|
|
@@ -2325,7 +2326,7 @@ var Trie = class {
|
|
|
2325
2326
|
}
|
|
2326
2327
|
}
|
|
2327
2328
|
this.#root.insert(tokens, this.#index, paramAssoc, this.#context, false);
|
|
2328
|
-
this.paths[
|
|
2329
|
+
this.paths[path13] = [this.#index++, paramAssoc];
|
|
2329
2330
|
}
|
|
2330
2331
|
buildRegExp() {
|
|
2331
2332
|
let regexp = this.#root.buildRegExpStr();
|
|
@@ -2352,17 +2353,17 @@ var Trie = class {
|
|
|
2352
2353
|
|
|
2353
2354
|
// node_modules/.pnpm/hono@4.13.7/node_modules/hono/dist/router/reg-exp-router/router.js
|
|
2354
2355
|
var wildcardRegExpCache = createNullObject();
|
|
2355
|
-
function buildWildcardRegExp(
|
|
2356
|
-
return wildcardRegExpCache[
|
|
2357
|
-
`^${
|
|
2356
|
+
function buildWildcardRegExp(path13) {
|
|
2357
|
+
return wildcardRegExpCache[path13] ??= new RegExp(
|
|
2358
|
+
`^${path13.replace(
|
|
2358
2359
|
/\/:[^/{}]+(?:\{\[\^\/]\+})?(?=[/{]|$)|\/?\*$|([.\\+*[^\]$()?{}|])/g,
|
|
2359
2360
|
(match2, metaChar) => metaChar ? `\\${metaChar}` : match2 === "/*" ? TAIL_WILDCARD_REG_EXP_STR : match2 === "*" ? ONLY_WILDCARD_REG_EXP_STR : `/:${LABEL_REG_EXP_STR}`
|
|
2360
2361
|
)}$`
|
|
2361
2362
|
);
|
|
2362
2363
|
}
|
|
2363
|
-
function findMiddleware(middleware,
|
|
2364
|
+
function findMiddleware(middleware, path13) {
|
|
2364
2365
|
for (const k of Object.keys(middleware).sort((a, b) => b.length - a.length)) {
|
|
2365
|
-
if (buildWildcardRegExp(k).test(
|
|
2366
|
+
if (buildWildcardRegExp(k).test(path13)) {
|
|
2366
2367
|
return [...middleware[k]];
|
|
2367
2368
|
}
|
|
2368
2369
|
}
|
|
@@ -2378,14 +2379,14 @@ var RegExpRouter = class {
|
|
|
2378
2379
|
this.#routes = { [METHOD_NAME_ALL]: createNullObject() };
|
|
2379
2380
|
this.#tries = { [METHOD_NAME_ALL]: new Trie() };
|
|
2380
2381
|
}
|
|
2381
|
-
#insertPath(method,
|
|
2382
|
+
#insertPath(method, path13) {
|
|
2382
2383
|
try {
|
|
2383
|
-
this.#tries[method].insert(
|
|
2384
|
+
this.#tries[method].insert(path13, !/\*|\/:/.test(path13));
|
|
2384
2385
|
} catch (e) {
|
|
2385
|
-
throw e === PATH_ERROR ? new UnsupportedPathError(
|
|
2386
|
+
throw e === PATH_ERROR ? new UnsupportedPathError(path13) : e;
|
|
2386
2387
|
}
|
|
2387
2388
|
}
|
|
2388
|
-
add(method,
|
|
2389
|
+
add(method, path13, handler) {
|
|
2389
2390
|
const middleware = this.#middleware;
|
|
2390
2391
|
const routes = this.#routes;
|
|
2391
2392
|
if (!middleware) {
|
|
@@ -2401,28 +2402,28 @@ var RegExpRouter = class {
|
|
|
2401
2402
|
}
|
|
2402
2403
|
}
|
|
2403
2404
|
}
|
|
2404
|
-
if (
|
|
2405
|
-
|
|
2405
|
+
if (path13 === "/*") {
|
|
2406
|
+
path13 = "*";
|
|
2406
2407
|
}
|
|
2407
2408
|
const methods = method === METHOD_NAME_ALL ? Object.keys(middleware) : [method];
|
|
2408
|
-
if (/\*$/.test(
|
|
2409
|
-
const re = buildWildcardRegExp(
|
|
2409
|
+
if (/\*$/.test(path13)) {
|
|
2410
|
+
const re = buildWildcardRegExp(path13);
|
|
2410
2411
|
for (const m of methods) {
|
|
2411
|
-
if (!middleware[m][
|
|
2412
|
-
this.#insertPath(m,
|
|
2413
|
-
middleware[m][
|
|
2412
|
+
if (!middleware[m][path13]) {
|
|
2413
|
+
this.#insertPath(m, path13);
|
|
2414
|
+
middleware[m][path13] = findMiddleware(middleware[m], path13) || findMiddleware(middleware[METHOD_NAME_ALL], path13) || [];
|
|
2414
2415
|
}
|
|
2415
2416
|
}
|
|
2416
2417
|
for (const handlerMap of [middleware, routes]) {
|
|
2417
2418
|
for (const m of methods) {
|
|
2418
2419
|
for (const p in handlerMap[m]) {
|
|
2419
|
-
re.test(p) && handlerMap[m][p].push([handler,
|
|
2420
|
+
re.test(p) && handlerMap[m][p].push([handler, path13]);
|
|
2420
2421
|
}
|
|
2421
2422
|
}
|
|
2422
2423
|
}
|
|
2423
2424
|
return;
|
|
2424
2425
|
}
|
|
2425
|
-
const paths = checkOptionalParameter(
|
|
2426
|
+
const paths = checkOptionalParameter(path13) || [path13];
|
|
2426
2427
|
for (const path22 of paths) {
|
|
2427
2428
|
for (const m of methods) {
|
|
2428
2429
|
if (!routes[m][path22]) {
|
|
@@ -2451,11 +2452,11 @@ var RegExpRouter = class {
|
|
|
2451
2452
|
const handlerData = [];
|
|
2452
2453
|
const [regexp, indexReplacementMap, paramReplacementMap] = trie.buildRegExp();
|
|
2453
2454
|
for (const r of [middleware, routes]) {
|
|
2454
|
-
for (const
|
|
2455
|
-
const handlers = r[
|
|
2456
|
-
const pathData = trie.paths[
|
|
2455
|
+
for (const path13 in r) {
|
|
2456
|
+
const handlers = r[path13];
|
|
2457
|
+
const pathData = trie.paths[path13];
|
|
2457
2458
|
if (!pathData) {
|
|
2458
|
-
staticMap[
|
|
2459
|
+
staticMap[path13] = [handlers.map(([h]) => [h, createNullObject()]), emptyParam];
|
|
2459
2460
|
continue;
|
|
2460
2461
|
}
|
|
2461
2462
|
handlerData[pathData[0]] = handlers.map(([h, handlerPath]) => [
|
|
@@ -2479,13 +2480,13 @@ var SmartRouter = class {
|
|
|
2479
2480
|
constructor(init) {
|
|
2480
2481
|
this.#routers = init.routers;
|
|
2481
2482
|
}
|
|
2482
|
-
add(method,
|
|
2483
|
+
add(method, path13, handler) {
|
|
2483
2484
|
if (!this.#routes) {
|
|
2484
2485
|
throw new Error(MESSAGE_MATCHER_IS_ALREADY_BUILT);
|
|
2485
2486
|
}
|
|
2486
|
-
this.#routes.push([method,
|
|
2487
|
+
this.#routes.push([method, path13, handler]);
|
|
2487
2488
|
}
|
|
2488
|
-
match(method,
|
|
2489
|
+
match(method, path13) {
|
|
2489
2490
|
if (!this.#routes) {
|
|
2490
2491
|
throw new Error("Fatal error");
|
|
2491
2492
|
}
|
|
@@ -2500,7 +2501,7 @@ var SmartRouter = class {
|
|
|
2500
2501
|
for (let i2 = 0, len2 = routes.length; i2 < len2; i2++) {
|
|
2501
2502
|
router.add(...routes[i2]);
|
|
2502
2503
|
}
|
|
2503
|
-
res = router.match(method,
|
|
2504
|
+
res = router.match(method, path13);
|
|
2504
2505
|
} catch (e) {
|
|
2505
2506
|
if (e instanceof UnsupportedPathError) {
|
|
2506
2507
|
continue;
|
|
@@ -2535,9 +2536,9 @@ var Node2 = class _Node2 {
|
|
|
2535
2536
|
#patterns = [];
|
|
2536
2537
|
#pattern;
|
|
2537
2538
|
#params = emptyParams;
|
|
2538
|
-
insert(method,
|
|
2539
|
+
insert(method, path13, handler) {
|
|
2539
2540
|
let curNode = this;
|
|
2540
|
-
const parts = splitRoutingPath(
|
|
2541
|
+
const parts = splitRoutingPath(path13);
|
|
2541
2542
|
const possibleKeys = /* @__PURE__ */ new Set();
|
|
2542
2543
|
let i = 0;
|
|
2543
2544
|
for (const p of parts) {
|
|
@@ -2577,12 +2578,12 @@ var Node2 = class _Node2 {
|
|
|
2577
2578
|
}
|
|
2578
2579
|
}
|
|
2579
2580
|
}
|
|
2580
|
-
search(method,
|
|
2581
|
+
search(method, path13) {
|
|
2581
2582
|
const handlerSets = [];
|
|
2582
2583
|
this.#params = emptyParams;
|
|
2583
2584
|
const curNode = this;
|
|
2584
2585
|
let curNodes = [curNode];
|
|
2585
|
-
const parts = splitPath(
|
|
2586
|
+
const parts = splitPath(path13);
|
|
2586
2587
|
const curNodesQueue = [];
|
|
2587
2588
|
const len = parts.length;
|
|
2588
2589
|
let partOffsets = null;
|
|
@@ -2624,13 +2625,13 @@ var Node2 = class _Node2 {
|
|
|
2624
2625
|
if (matcher !== true) {
|
|
2625
2626
|
if (!partOffsets) {
|
|
2626
2627
|
partOffsets = [];
|
|
2627
|
-
let offset =
|
|
2628
|
+
let offset = path13[0] === "/" ? 1 : 0;
|
|
2628
2629
|
for (let p = 0; p < len; p++) {
|
|
2629
2630
|
partOffsets[p] = offset;
|
|
2630
2631
|
offset += parts[p].length + 1;
|
|
2631
2632
|
}
|
|
2632
2633
|
}
|
|
2633
|
-
const restPathString =
|
|
2634
|
+
const restPathString = path13.slice(partOffsets[i]);
|
|
2634
2635
|
const m = matcher.exec(restPathString);
|
|
2635
2636
|
if (m) {
|
|
2636
2637
|
params[name] = m[0];
|
|
@@ -2690,13 +2691,13 @@ var Node2 = class _Node2 {
|
|
|
2690
2691
|
var TrieRouter = class {
|
|
2691
2692
|
name = "TrieRouter";
|
|
2692
2693
|
#node = new Node2();
|
|
2693
|
-
add(method,
|
|
2694
|
-
for (const result of checkOptionalParameter(
|
|
2694
|
+
add(method, path13, handler) {
|
|
2695
|
+
for (const result of checkOptionalParameter(path13) || [path13]) {
|
|
2695
2696
|
this.#node.insert(method, result, handler);
|
|
2696
2697
|
}
|
|
2697
2698
|
}
|
|
2698
|
-
match(method,
|
|
2699
|
-
return this.#node.search(method,
|
|
2699
|
+
match(method, path13) {
|
|
2700
|
+
return this.#node.search(method, path13);
|
|
2700
2701
|
}
|
|
2701
2702
|
};
|
|
2702
2703
|
|
|
@@ -3000,6 +3001,17 @@ function moveBefore(list, id, before) {
|
|
|
3000
3001
|
return rest;
|
|
3001
3002
|
}
|
|
3002
3003
|
|
|
3004
|
+
// packages/shared/src/comments.ts
|
|
3005
|
+
function commentWorktree(c) {
|
|
3006
|
+
return tryParseTargetKey(c.targetKey)?.worktree;
|
|
3007
|
+
}
|
|
3008
|
+
function awaitsAgent(c) {
|
|
3009
|
+
return !c.exportedAt;
|
|
3010
|
+
}
|
|
3011
|
+
function awaitsReviewer(c) {
|
|
3012
|
+
return c.replies?.at(-1)?.author === "agent";
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3003
3015
|
// packages/server/src/errors.ts
|
|
3004
3016
|
var HttpError = class extends Error {
|
|
3005
3017
|
constructor(status, message, code) {
|
|
@@ -3399,39 +3411,286 @@ async function getRepoInfo(ctx, defaultTarget) {
|
|
|
3399
3411
|
}
|
|
3400
3412
|
|
|
3401
3413
|
// packages/server/src/targets.ts
|
|
3402
|
-
import
|
|
3403
|
-
import { readFile } from "fs/promises";
|
|
3414
|
+
import path4 from "path";
|
|
3415
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
3404
3416
|
|
|
3405
3417
|
// packages/server/src/checkpoints.ts
|
|
3418
|
+
import path3 from "path";
|
|
3419
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
3420
|
+
import { copyFile, mkdir as mkdir2, rm, stat as stat2, utimes as utimes2 } from "fs/promises";
|
|
3421
|
+
|
|
3422
|
+
// packages/server/src/state.ts
|
|
3423
|
+
import os from "os";
|
|
3406
3424
|
import path2 from "path";
|
|
3407
3425
|
import { randomUUID } from "crypto";
|
|
3408
|
-
import {
|
|
3426
|
+
import { mkdir, open, readFile, rename, stat, unlink, utimes } from "fs/promises";
|
|
3427
|
+
|
|
3428
|
+
// packages/server/src/hash.ts
|
|
3429
|
+
import { createHash } from "crypto";
|
|
3430
|
+
function sha1(input) {
|
|
3431
|
+
return createHash("sha1").update(input).digest("hex");
|
|
3432
|
+
}
|
|
3433
|
+
function lineHash(content) {
|
|
3434
|
+
return sha1(content.trimEnd());
|
|
3435
|
+
}
|
|
3436
|
+
function hunkHash(lineContents) {
|
|
3437
|
+
return sha1(
|
|
3438
|
+
lineContents.map((l) => l.trimEnd()).join("\n").trim()
|
|
3439
|
+
);
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3442
|
+
// packages/server/src/state.ts
|
|
3443
|
+
function dataDir() {
|
|
3444
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
3445
|
+
const base = xdg?.trim() ? xdg : path2.join(os.homedir(), ".local", "share");
|
|
3446
|
+
return path2.join(base, "warden");
|
|
3447
|
+
}
|
|
3448
|
+
function repoHash(repoRoot) {
|
|
3449
|
+
return sha1(repoRoot).slice(0, 12);
|
|
3450
|
+
}
|
|
3451
|
+
function stateFilePath(repoRoot, baseDir = dataDir()) {
|
|
3452
|
+
return path2.join(baseDir, repoHash(repoRoot), "state.json");
|
|
3453
|
+
}
|
|
3454
|
+
function defaultPrefs() {
|
|
3455
|
+
return { viewMode: "unified", nvimSocketByRoot: {}, autoRefresh: true, railOpen: false, ignoreDebug: true };
|
|
3456
|
+
}
|
|
3457
|
+
function defaultState(repoRoot) {
|
|
3458
|
+
return { schemaVersion: 1, repoRoot, targets: {}, todos: [], checkpoints: [], prefs: defaultPrefs() };
|
|
3459
|
+
}
|
|
3460
|
+
function ensureTarget(state, key) {
|
|
3461
|
+
let t = state.targets[key];
|
|
3462
|
+
if (!t) {
|
|
3463
|
+
t = { viewed: {}, comments: [] };
|
|
3464
|
+
state.targets[key] = t;
|
|
3465
|
+
}
|
|
3466
|
+
return t;
|
|
3467
|
+
}
|
|
3468
|
+
var usable = (v) => !!v && typeof v === "object" && typeof v.id === "string";
|
|
3469
|
+
var usableCheckpoint = (v) => {
|
|
3470
|
+
const c = v;
|
|
3471
|
+
return !!c && typeof c === "object" && Number.isInteger(c.id) && typeof c.tree === "string" && (c.worktree === void 0 || typeof c.worktree === "string");
|
|
3472
|
+
};
|
|
3473
|
+
var usableComment = (v) => usable(v) && typeof v.anchor === "object" && !!v.anchor;
|
|
3474
|
+
var SCHEMA_VERSION = 1;
|
|
3475
|
+
var KNOWN_KEYS = /* @__PURE__ */ new Set(["schemaVersion", "repoRoot", "targets", "todos", "checkpoints", "prefs", "issues"]);
|
|
3476
|
+
var StateTooNew = class extends HttpError {
|
|
3477
|
+
constructor(file, version) {
|
|
3478
|
+
super(409, `${file} was written by a newer warden (schema ${version}); upgrade warden to use it`, "state_too_new");
|
|
3479
|
+
}
|
|
3480
|
+
};
|
|
3481
|
+
var NotAState = class extends Error {
|
|
3482
|
+
};
|
|
3483
|
+
function normalise(raw2, repoRoot, file) {
|
|
3484
|
+
if (!raw2 || typeof raw2 !== "object" || Array.isArray(raw2)) throw new NotAState();
|
|
3485
|
+
const r = raw2;
|
|
3486
|
+
if (typeof r.schemaVersion !== "number") throw new NotAState();
|
|
3487
|
+
if (r.schemaVersion > SCHEMA_VERSION) throw new StateTooNew(file, r.schemaVersion);
|
|
3488
|
+
const extra = Object.fromEntries(Object.entries(r).filter(([k]) => !KNOWN_KEYS.has(k)));
|
|
3489
|
+
const targets = {};
|
|
3490
|
+
for (const [k, v] of Object.entries(r.targets ?? {})) {
|
|
3491
|
+
if (!v || typeof v !== "object") continue;
|
|
3492
|
+
targets[k] = {
|
|
3493
|
+
viewed: typeof v.viewed === "object" && v.viewed ? v.viewed : {},
|
|
3494
|
+
comments: Array.isArray(v.comments) ? v.comments.filter(usableComment) : [],
|
|
3495
|
+
...typeof v.head === "string" ? { head: v.head } : {}
|
|
3496
|
+
};
|
|
3497
|
+
}
|
|
3498
|
+
migrateLocalViews(targets);
|
|
3499
|
+
return {
|
|
3500
|
+
...extra,
|
|
3501
|
+
schemaVersion: SCHEMA_VERSION,
|
|
3502
|
+
repoRoot: r.repoRoot ?? repoRoot,
|
|
3503
|
+
targets,
|
|
3504
|
+
// Issues were folded into todos (a todo links comments now); the few there were are let go,
|
|
3505
|
+
// and the next write leaves them out of the file.
|
|
3506
|
+
todos: Array.isArray(r.todos) ? r.todos.filter(usable) : [],
|
|
3507
|
+
checkpoints: Array.isArray(r.checkpoints) ? r.checkpoints.filter(usableCheckpoint) : [],
|
|
3508
|
+
prefs: {
|
|
3509
|
+
...defaultPrefs(),
|
|
3510
|
+
...r.prefs ?? {},
|
|
3511
|
+
nvimSocketByRoot: r.prefs?.nvimSocketByRoot ?? {},
|
|
3512
|
+
autoRefresh: typeof r.prefs?.autoRefresh === "boolean" ? r.prefs.autoRefresh : true,
|
|
3513
|
+
railOpen: typeof r.prefs?.railOpen === "boolean" ? r.prefs.railOpen : false,
|
|
3514
|
+
ignoreDebug: typeof r.prefs?.ignoreDebug === "boolean" ? r.prefs.ignoreDebug : true
|
|
3515
|
+
}
|
|
3516
|
+
};
|
|
3517
|
+
}
|
|
3518
|
+
function migrateLocalViews(targets) {
|
|
3519
|
+
for (const key of Object.keys(targets).sort()) {
|
|
3520
|
+
const scope = commentScopeKey(key);
|
|
3521
|
+
if (scope === key) continue;
|
|
3522
|
+
const src = targets[key];
|
|
3523
|
+
delete targets[key];
|
|
3524
|
+
if (src.comments.length === 0) continue;
|
|
3525
|
+
let dst = targets[scope];
|
|
3526
|
+
if (!dst) {
|
|
3527
|
+
dst = { viewed: {}, comments: [] };
|
|
3528
|
+
targets[scope] = dst;
|
|
3529
|
+
}
|
|
3530
|
+
dst.comments.push(...src.comments);
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
var LOCK_STALE_MS = 5e3;
|
|
3534
|
+
var LOCK_WAIT_MS = 5e3;
|
|
3535
|
+
async function sleep(ms) {
|
|
3536
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
3537
|
+
}
|
|
3538
|
+
var StateStore = class {
|
|
3539
|
+
constructor(file, repoRoot) {
|
|
3540
|
+
this.file = file;
|
|
3541
|
+
this.repoRoot = repoRoot;
|
|
3542
|
+
}
|
|
3543
|
+
file;
|
|
3544
|
+
repoRoot;
|
|
3545
|
+
queue = Promise.resolve();
|
|
3546
|
+
async load() {
|
|
3547
|
+
try {
|
|
3548
|
+
const text = await readFile(this.file, "utf8");
|
|
3549
|
+
return normalise(JSON.parse(text), this.repoRoot, this.file);
|
|
3550
|
+
} catch (e) {
|
|
3551
|
+
const err = e;
|
|
3552
|
+
if (err.code === "ENOENT") return defaultState(this.repoRoot);
|
|
3553
|
+
if (e instanceof SyntaxError || e instanceof NotAState) {
|
|
3554
|
+
try {
|
|
3555
|
+
await rename(this.file, `${this.file}.corrupt-${Date.now()}`);
|
|
3556
|
+
} catch {
|
|
3557
|
+
}
|
|
3558
|
+
return defaultState(this.repoRoot);
|
|
3559
|
+
}
|
|
3560
|
+
throw e;
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
/** Apply a mutation under lock. The callback receives the freshest on-disk state. */
|
|
3564
|
+
update(fn) {
|
|
3565
|
+
const run2 = async () => {
|
|
3566
|
+
await mkdir(path2.dirname(this.file), { recursive: true });
|
|
3567
|
+
const lock = await this.acquireLock();
|
|
3568
|
+
try {
|
|
3569
|
+
const state = await this.load();
|
|
3570
|
+
const result = await fn(state);
|
|
3571
|
+
if (!await lock.held()) throw new HttpError(409, "the state lock was taken over while this change was prepared; try again", "state_lock_lost");
|
|
3572
|
+
await this.writeAtomic(state);
|
|
3573
|
+
return result;
|
|
3574
|
+
} finally {
|
|
3575
|
+
await lock.release();
|
|
3576
|
+
}
|
|
3577
|
+
};
|
|
3578
|
+
const p = this.queue.then(run2, run2);
|
|
3579
|
+
this.queue = p.catch(() => void 0);
|
|
3580
|
+
return p;
|
|
3581
|
+
}
|
|
3582
|
+
async writeAtomic(state) {
|
|
3583
|
+
const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`;
|
|
3584
|
+
const fh = await open(tmp, "w");
|
|
3585
|
+
try {
|
|
3586
|
+
await fh.writeFile(JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
3587
|
+
await fh.sync();
|
|
3588
|
+
} finally {
|
|
3589
|
+
await fh.close();
|
|
3590
|
+
}
|
|
3591
|
+
await rename(tmp, this.file);
|
|
3592
|
+
}
|
|
3593
|
+
/**
|
|
3594
|
+
* An exclusive lock file beside the state file, shared by every warden process and the agent
|
|
3595
|
+
* CLI. It carries a token of its own, so a holder releases only the lock it took, and its mtime
|
|
3596
|
+
* is refreshed while held: a lock left by a process that died goes stale and is taken over, one
|
|
3597
|
+
* whose holder is merely slow does not.
|
|
3598
|
+
*/
|
|
3599
|
+
async acquireLock() {
|
|
3600
|
+
const lock = `${this.file}.lock`;
|
|
3601
|
+
const token = `${process.pid}:${randomUUID()}`;
|
|
3602
|
+
const started = Date.now();
|
|
3603
|
+
for (; ; ) {
|
|
3604
|
+
try {
|
|
3605
|
+
const fh = await open(lock, "wx");
|
|
3606
|
+
await fh.writeFile(token);
|
|
3607
|
+
await fh.close();
|
|
3608
|
+
break;
|
|
3609
|
+
} catch (e) {
|
|
3610
|
+
const err = e;
|
|
3611
|
+
if (err.code !== "EEXIST") throw e;
|
|
3612
|
+
try {
|
|
3613
|
+
const st = await stat(lock);
|
|
3614
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
3615
|
+
const aside = `${lock}.stale-${token.replace(":", "-")}`;
|
|
3616
|
+
await rename(lock, aside).then(
|
|
3617
|
+
() => unlink(aside).catch(() => void 0),
|
|
3618
|
+
() => void 0
|
|
3619
|
+
);
|
|
3620
|
+
continue;
|
|
3621
|
+
}
|
|
3622
|
+
} catch {
|
|
3623
|
+
continue;
|
|
3624
|
+
}
|
|
3625
|
+
if (Date.now() - started > LOCK_WAIT_MS) {
|
|
3626
|
+
throw new Error(`timed out waiting for state lock ${lock}`);
|
|
3627
|
+
}
|
|
3628
|
+
await sleep(20);
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
const ours = async () => await readFile(lock, "utf8").catch(() => "") === token;
|
|
3632
|
+
const beat = setInterval(() => {
|
|
3633
|
+
void ours().then((mine) => mine ? utimes(lock, /* @__PURE__ */ new Date(), /* @__PURE__ */ new Date()).catch(() => void 0) : void 0);
|
|
3634
|
+
}, LOCK_STALE_MS / 4);
|
|
3635
|
+
beat.unref();
|
|
3636
|
+
return {
|
|
3637
|
+
held: ours,
|
|
3638
|
+
release: async () => {
|
|
3639
|
+
clearInterval(beat);
|
|
3640
|
+
if (await ours()) await unlink(lock).catch(() => void 0);
|
|
3641
|
+
}
|
|
3642
|
+
};
|
|
3643
|
+
}
|
|
3644
|
+
};
|
|
3645
|
+
function forgetWorktreeTargets(state, worktreePath) {
|
|
3646
|
+
const prefix = `worktree:${worktreePath}:`;
|
|
3647
|
+
const gone = /* @__PURE__ */ new Set();
|
|
3648
|
+
for (const key of Object.keys(state.targets)) {
|
|
3649
|
+
if (!key.startsWith(prefix)) continue;
|
|
3650
|
+
for (const c of state.targets[key].comments) gone.add(c.id);
|
|
3651
|
+
delete state.targets[key];
|
|
3652
|
+
}
|
|
3653
|
+
unlinkComments(state, gone);
|
|
3654
|
+
state.checkpoints = state.checkpoints.filter((c) => c.worktree !== worktreePath);
|
|
3655
|
+
}
|
|
3656
|
+
function unlinkComments(state, ids) {
|
|
3657
|
+
const gone = new Set(ids);
|
|
3658
|
+
if (gone.size === 0) return;
|
|
3659
|
+
for (const todo of state.todos) {
|
|
3660
|
+
if (!todo.commentIds?.some((id) => gone.has(id))) continue;
|
|
3661
|
+
const kept = todo.commentIds.filter((id) => !gone.has(id));
|
|
3662
|
+
if (kept.length) todo.commentIds = kept;
|
|
3663
|
+
else delete todo.commentIds;
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
// packages/server/src/checkpoints.ts
|
|
3409
3668
|
function checkpointStore(stateFile) {
|
|
3410
|
-
return
|
|
3669
|
+
return path3.join(path3.dirname(stateFile), "checkpoints");
|
|
3411
3670
|
}
|
|
3412
3671
|
async function repoObjects(cwd) {
|
|
3413
3672
|
const r = await runGit(["rev-parse", "--git-path", "objects"], { cwd });
|
|
3414
|
-
return
|
|
3673
|
+
return path3.resolve(cwd, r.stdout.trim());
|
|
3415
3674
|
}
|
|
3416
3675
|
async function repoIndex(cwd) {
|
|
3417
3676
|
const r = await runGit(["rev-parse", "--git-path", "index"], { cwd });
|
|
3418
|
-
return
|
|
3677
|
+
return path3.resolve(cwd, r.stdout.trim());
|
|
3419
3678
|
}
|
|
3420
3679
|
async function checkpointObjects(cwd, store) {
|
|
3421
|
-
return { objects:
|
|
3680
|
+
return { objects: path3.join(store, "objects"), alternates: await repoObjects(cwd) };
|
|
3422
3681
|
}
|
|
3423
3682
|
async function withIndexCopy(cwd, store, fn) {
|
|
3424
|
-
const tmp =
|
|
3425
|
-
await
|
|
3426
|
-
await
|
|
3683
|
+
const tmp = path3.join(store, "tmp");
|
|
3684
|
+
await mkdir2(path3.join(store, "objects"), { recursive: true });
|
|
3685
|
+
await mkdir2(tmp, { recursive: true });
|
|
3427
3686
|
const [objects, index] = await Promise.all([checkpointObjects(cwd, store), repoIndex(cwd)]);
|
|
3428
|
-
const copy =
|
|
3687
|
+
const copy = path3.join(tmp, `index-${randomUUID2()}`);
|
|
3429
3688
|
try {
|
|
3430
|
-
const st = await
|
|
3689
|
+
const st = await stat2(index).catch(() => void 0);
|
|
3431
3690
|
if (st) {
|
|
3432
3691
|
await copyFile(index, copy);
|
|
3433
3692
|
const earlier = Math.floor(st.mtimeMs / 1e3) - 1;
|
|
3434
|
-
await
|
|
3693
|
+
await utimes2(copy, earlier, earlier);
|
|
3435
3694
|
}
|
|
3436
3695
|
return await fn({ ...objects, index: copy });
|
|
3437
3696
|
} finally {
|
|
@@ -3468,29 +3727,26 @@ function forgetCheckpoint(state, c) {
|
|
|
3468
3727
|
const key = checkpointKey(c);
|
|
3469
3728
|
const gone = new Set((state.targets[key]?.comments ?? []).map((x) => x.id));
|
|
3470
3729
|
delete state.targets[key];
|
|
3471
|
-
|
|
3730
|
+
unlinkComments(state, gone);
|
|
3472
3731
|
}
|
|
3473
|
-
function addCheckpoint(state, worktree, tree, head) {
|
|
3732
|
+
function addCheckpoint(state, worktree, tree, head, handoff = false) {
|
|
3474
3733
|
const mine = checkpointsOf(state, worktree);
|
|
3475
3734
|
const id = mine.reduce((n, c) => Math.max(n, c.id), 0) + 1;
|
|
3476
|
-
const checkpoint = { id, ...worktree ? { worktree } : {}, tree, head, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
3735
|
+
const checkpoint = { id, ...worktree ? { worktree } : {}, tree, head, createdAt: (/* @__PURE__ */ new Date()).toISOString(), ...handoff ? { handoff } : {} };
|
|
3477
3736
|
state.checkpoints.push(checkpoint);
|
|
3478
|
-
|
|
3737
|
+
const evictable = mine.filter((c) => !state.targets[checkpointKey(c)]?.comments.length);
|
|
3738
|
+
const excess = mine.length + 1 - MAX_CHECKPOINTS;
|
|
3739
|
+
for (const old of evictable.slice(0, Math.max(0, excess))) forgetCheckpoint(state, old);
|
|
3479
3740
|
return checkpoint;
|
|
3480
3741
|
}
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
}
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
}
|
|
3490
|
-
function hunkHash(lineContents) {
|
|
3491
|
-
return sha1(
|
|
3492
|
-
lineContents.map((l) => l.trimEnd()).join("\n").trim()
|
|
3493
|
-
);
|
|
3742
|
+
async function takeCheckpoint(store, cwd, worktree, opts = {}) {
|
|
3743
|
+
const tree = await snapshotWorktree(cwd, checkpointStore(store.file));
|
|
3744
|
+
const head = await revParse(cwd, "HEAD") ?? "";
|
|
3745
|
+
return store.update((s) => {
|
|
3746
|
+
const newest = checkpointsOf(s, worktree).at(-1);
|
|
3747
|
+
if (newest?.tree === tree) return { checkpoint: newest, unchanged: true };
|
|
3748
|
+
return { checkpoint: addCheckpoint(s, worktree, tree, head, opts.handoff), unchanged: false };
|
|
3749
|
+
});
|
|
3494
3750
|
}
|
|
3495
3751
|
|
|
3496
3752
|
// packages/server/src/diffparse.ts
|
|
@@ -3745,7 +4001,8 @@ async function runDiff(ctx, args) {
|
|
|
3745
4001
|
if (ctx.target.kind !== "checkpoint") return (await runGit(args, { cwd: ctx.cwd })).stdout;
|
|
3746
4002
|
return withCheckpointIndex(ctx.cwd, checkpointOf(ctx).store, async (snapshot) => (await runGit(args, { cwd: ctx.cwd, snapshot })).stdout);
|
|
3747
4003
|
}
|
|
3748
|
-
var
|
|
4004
|
+
var DIFF_OUTPUT_ARGS = ["--no-color", "--no-ext-diff", "--no-textconv", "--src-prefix=a/", "--dst-prefix=b/", "--submodule=short", "-U3"];
|
|
4005
|
+
var DIFF_BASE_ARGS = ["diff", ...DIFF_OUTPUT_ARGS, "-M", "--find-renames"];
|
|
3749
4006
|
function resolveTargetContext(repo, worktrees, key) {
|
|
3750
4007
|
let target;
|
|
3751
4008
|
try {
|
|
@@ -3754,7 +4011,7 @@ function resolveTargetContext(repo, worktrees, key) {
|
|
|
3754
4011
|
if (e instanceof TargetKeyError) throw badRequest(e.message, "invalid_target");
|
|
3755
4012
|
throw e;
|
|
3756
4013
|
}
|
|
3757
|
-
let cwd = repo.
|
|
4014
|
+
let cwd = repo.commonRoot;
|
|
3758
4015
|
if (target.worktree) {
|
|
3759
4016
|
const wt = worktrees.find((w) => w.path === target.worktree);
|
|
3760
4017
|
if (!wt) throw badRequest(`unknown worktree: ${target.worktree}`, "unknown_worktree");
|
|
@@ -3814,7 +4071,7 @@ async function isUntracked(cwd, file) {
|
|
|
3814
4071
|
return r.stdout.split("\0").filter(Boolean).includes(file);
|
|
3815
4072
|
}
|
|
3816
4073
|
async function untrackedDiff(cwd, file) {
|
|
3817
|
-
const r = await runGit(["diff",
|
|
4074
|
+
const r = await runGit(["diff", ...DIFF_OUTPUT_ARGS, "--no-index", "--", "/dev/null", file], {
|
|
3818
4075
|
cwd,
|
|
3819
4076
|
okCodes: [0, 1]
|
|
3820
4077
|
});
|
|
@@ -3841,7 +4098,7 @@ async function mapLimit(items, limit, fn) {
|
|
|
3841
4098
|
}
|
|
3842
4099
|
async function listTargetDiffs(ctx) {
|
|
3843
4100
|
const args = await diffArgs(ctx);
|
|
3844
|
-
const files = parseUnifiedDiff(await runDiff(ctx, args));
|
|
4101
|
+
const files = parseUnifiedDiff(await runDiff(ctx, [...args, "--"]));
|
|
3845
4102
|
if (includesUntracked(ctx.target)) {
|
|
3846
4103
|
const untracked = await listUntracked(ctx.cwd);
|
|
3847
4104
|
const extra = await mapLimit(untracked, 8, (f) => untrackedDiff(ctx.cwd, f).catch(() => void 0));
|
|
@@ -3892,7 +4149,7 @@ async function getFullFile(ctx, filePath, side) {
|
|
|
3892
4149
|
const ref = await refForSide(ctx, side);
|
|
3893
4150
|
if (ref === void 0) {
|
|
3894
4151
|
try {
|
|
3895
|
-
const buf = await
|
|
4152
|
+
const buf = await readFile2(path4.join(ctx.cwd, filePath));
|
|
3896
4153
|
return buf.toString("utf8");
|
|
3897
4154
|
} catch {
|
|
3898
4155
|
return null;
|
|
@@ -3911,7 +4168,7 @@ async function pathsWithMarkers(ctx, side, paths) {
|
|
|
3911
4168
|
const found = /* @__PURE__ */ new Set();
|
|
3912
4169
|
if (paths.length === 0) return found;
|
|
3913
4170
|
const ref = await refForSide(ctx, side);
|
|
3914
|
-
const args = ["grep", "-l", "-z", "-I", "-i", "-F", ...DEBUG_MARKER_STRINGS.flatMap((m) => ["-e", m])];
|
|
4171
|
+
const args = ["grep", "--no-color", "--no-recurse-submodules", "-l", "-z", "-I", "-i", "-F", ...DEBUG_MARKER_STRINGS.flatMap((m) => ["-e", m])];
|
|
3915
4172
|
if (ref === void 0) args.push("--untracked");
|
|
3916
4173
|
else if (ref === ":0") args.push("--cached");
|
|
3917
4174
|
else args.push(ref);
|
|
@@ -4192,15 +4449,15 @@ function buildStagePatch(diff, mode, selection, opts = {}) {
|
|
|
4192
4449
|
}
|
|
4193
4450
|
|
|
4194
4451
|
// packages/server/src/worktrees.ts
|
|
4195
|
-
import
|
|
4196
|
-
import { readdir, realpath as realpath2, stat as
|
|
4452
|
+
import path5 from "path";
|
|
4453
|
+
import { readdir, realpath as realpath2, stat as stat3 } from "fs/promises";
|
|
4197
4454
|
function slotPath(ctx, slot) {
|
|
4198
|
-
return
|
|
4455
|
+
return path5.join(path5.dirname(ctx.commonRoot), `${path5.basename(ctx.commonRoot)}-${slot}`);
|
|
4199
4456
|
}
|
|
4200
4457
|
function slotOf(ctx, p) {
|
|
4201
|
-
if (
|
|
4202
|
-
const prefix = `${
|
|
4203
|
-
const name =
|
|
4458
|
+
if (path5.dirname(p) !== path5.dirname(ctx.commonRoot)) return void 0;
|
|
4459
|
+
const prefix = `${path5.basename(ctx.commonRoot)}-`;
|
|
4460
|
+
const name = path5.basename(p);
|
|
4204
4461
|
const n = name.startsWith(prefix) ? name.slice(prefix.length) : "";
|
|
4205
4462
|
return /^[1-9]\d*$/.test(n) ? Number(n) : void 0;
|
|
4206
4463
|
}
|
|
@@ -4227,6 +4484,39 @@ async function listUpstreams(ctx) {
|
|
|
4227
4484
|
}
|
|
4228
4485
|
return out;
|
|
4229
4486
|
}
|
|
4487
|
+
async function commonDirOf(dir) {
|
|
4488
|
+
try {
|
|
4489
|
+
const r = await runGit(["rev-parse", "--show-toplevel", "--git-common-dir"], { cwd: dir });
|
|
4490
|
+
const [top, common] = r.stdout.trim().split("\n");
|
|
4491
|
+
if (!top || !common || await realpath2(top) !== await realpath2(dir)) return void 0;
|
|
4492
|
+
return await realpath2(path5.resolve(dir, common));
|
|
4493
|
+
} catch {
|
|
4494
|
+
return void 0;
|
|
4495
|
+
}
|
|
4496
|
+
}
|
|
4497
|
+
var IN_PROGRESS = [
|
|
4498
|
+
["rebase-merge", "rebase"],
|
|
4499
|
+
["rebase-apply", "rebase"],
|
|
4500
|
+
["MERGE_HEAD", "merge"],
|
|
4501
|
+
["CHERRY_PICK_HEAD", "cherry-pick"],
|
|
4502
|
+
["REVERT_HEAD", "revert"],
|
|
4503
|
+
["BISECT_LOG", "bisect"]
|
|
4504
|
+
];
|
|
4505
|
+
async function checkoutState(ctx, dir) {
|
|
4506
|
+
const [ours, theirs] = await Promise.all([commonDirOf(ctx.commonRoot), commonDirOf(dir)]);
|
|
4507
|
+
if (!ours || ours !== theirs) return { foreign: true };
|
|
4508
|
+
const r = await runGit(["rev-parse", ...IN_PROGRESS.flatMap(([f]) => ["--git-path", f])], { cwd: dir });
|
|
4509
|
+
const paths = r.stdout.trim().split("\n");
|
|
4510
|
+
for (const [i, [, what]] of IN_PROGRESS.entries()) {
|
|
4511
|
+
const p = paths[i];
|
|
4512
|
+
if (p && await stat3(path5.resolve(dir, p)).then(
|
|
4513
|
+
() => true,
|
|
4514
|
+
() => false
|
|
4515
|
+
))
|
|
4516
|
+
return { foreign: false, busy: what };
|
|
4517
|
+
}
|
|
4518
|
+
return { foreign: false };
|
|
4519
|
+
}
|
|
4230
4520
|
async function listWorktreesDetailed(ctx) {
|
|
4231
4521
|
const [all, upstreams] = await Promise.all([listWorktreesAll(ctx), listUpstreams(ctx)]);
|
|
4232
4522
|
const main2 = all.find((w) => w.isMain);
|
|
@@ -4234,12 +4524,16 @@ async function listWorktreesDetailed(ctx) {
|
|
|
4234
4524
|
all.map(async (w) => {
|
|
4235
4525
|
const slot = w.isMain || w.bare ? void 0 : slotOf(ctx, w.path);
|
|
4236
4526
|
const dirty = w.prunable || w.bare ? 0 : await dirtyCount(w.path);
|
|
4527
|
+
const state = slot === void 0 || w.prunable ? { foreign: false } : await checkoutState(ctx, w.path);
|
|
4237
4528
|
const detail = {
|
|
4238
4529
|
...w,
|
|
4239
4530
|
dirty,
|
|
4240
4531
|
...slot === void 0 ? {} : { slot },
|
|
4241
|
-
|
|
4242
|
-
|
|
4532
|
+
...state.foreign ? { foreign: true } : {},
|
|
4533
|
+
...state.busy ? { busy: state.busy } : {},
|
|
4534
|
+
// Free is what the next checkout may take over: a detached HEAD, nothing that would be lost,
|
|
4535
|
+
// nothing stopped halfway, and a checkout that is this repository's own.
|
|
4536
|
+
free: slot !== void 0 && !w.prunable && w.detached && dirty === 0 && !state.foreign && !state.busy
|
|
4243
4537
|
};
|
|
4244
4538
|
if (detail.free) return detail;
|
|
4245
4539
|
const upstream = w.branch ? upstreams.get(w.branch) : void 0;
|
|
@@ -4285,7 +4579,7 @@ async function assertBranchName(ctx, name) {
|
|
|
4285
4579
|
}
|
|
4286
4580
|
}
|
|
4287
4581
|
async function dirUsable(p) {
|
|
4288
|
-
const st = await
|
|
4582
|
+
const st = await stat3(p).catch((e) => {
|
|
4289
4583
|
if (e.code === "ENOENT") return void 0;
|
|
4290
4584
|
throw e;
|
|
4291
4585
|
});
|
|
@@ -4307,6 +4601,8 @@ async function pickSlot(ctx, worktrees, wanted) {
|
|
|
4307
4601
|
}
|
|
4308
4602
|
const have = slots.find((w) => w.slot === wanted);
|
|
4309
4603
|
if (have?.prunable) throw new HttpError(409, `the directory of slot ${wanted} is gone; remove its entry first`, "slot_gone");
|
|
4604
|
+
if (have?.foreign) throw new HttpError(409, `${have.path} is not a checkout of this repository`, "not_our_checkout");
|
|
4605
|
+
if (have?.busy) throw new HttpError(409, `a ${have.busy} is in progress in slot ${wanted}`, "operation_in_progress");
|
|
4310
4606
|
if (have && !have.free) throw new HttpError(409, `slot ${wanted} is in use${have.branch ? ` by ${have.branch}` : ""}`, "slot_in_use");
|
|
4311
4607
|
if (have) return { slot: wanted, path: have.path, reuse: true };
|
|
4312
4608
|
const p = slotPath(ctx, wanted);
|
|
@@ -4363,8 +4659,8 @@ async function checkoutWorktree(ctx, req) {
|
|
|
4363
4659
|
if (e instanceof GitError && /already (checked out|used by worktree)/.test(e.stderr)) throw new HttpError(409, e.message, "branch_in_use");
|
|
4364
4660
|
throw e;
|
|
4365
4661
|
}
|
|
4366
|
-
const
|
|
4367
|
-
const worktree = (await listWorktrees(ctx)).find((w) => w.path ===
|
|
4662
|
+
const real2 = await realpath2(target.path).catch(() => target.path);
|
|
4663
|
+
const worktree = (await listWorktrees(ctx)).find((w) => w.path === real2);
|
|
4368
4664
|
if (!worktree) throw new HttpError(500, `git checked ${branch} out at ${target.path} but does not list it`, "git_failed");
|
|
4369
4665
|
return { worktree, slot: target.slot, reused: target.reuse };
|
|
4370
4666
|
}
|
|
@@ -4382,11 +4678,14 @@ async function dropBranch(ctx, wt, asked) {
|
|
|
4382
4678
|
}
|
|
4383
4679
|
async function releaseWorktree(ctx, req) {
|
|
4384
4680
|
const p = req.path.trim();
|
|
4385
|
-
const wt = (await listWorktreesAll(ctx)).find((w) => w.path === p || p && w.path ===
|
|
4681
|
+
const wt = (await listWorktreesAll(ctx)).find((w) => w.path === p || p && w.path === path5.resolve(p));
|
|
4386
4682
|
if (!wt) throw badRequest(`unknown worktree: ${p}`, "unknown_worktree");
|
|
4387
4683
|
if (wt.isMain) throw badRequest("the main worktree cannot be released", "main_worktree");
|
|
4388
4684
|
if (wt.bare || slotOf(ctx, wt.path) === void 0) throw badRequest(`${wt.path} is not a slot; remove it instead`, "not_a_slot");
|
|
4389
4685
|
if (wt.prunable) throw badRequest(`the directory of ${wt.path} is gone; remove its entry instead`, "worktree_gone");
|
|
4686
|
+
const state = await checkoutState(ctx, wt.path);
|
|
4687
|
+
if (state.foreign) throw new HttpError(409, `${wt.path} is not a checkout of this repository; warden will not write in it`, "not_our_checkout");
|
|
4688
|
+
if (state.busy) throw new HttpError(409, `a ${state.busy} is in progress in ${wt.path}; finish or abort it first`, "operation_in_progress");
|
|
4390
4689
|
const dirty = await dirtyCount(wt.path);
|
|
4391
4690
|
if (dirty > 0 && !req.force) throw new HttpError(409, `${wt.path} has uncommitted changes`, "worktree_dirty");
|
|
4392
4691
|
if (dirty > 0) {
|
|
@@ -4398,9 +4697,13 @@ async function releaseWorktree(ctx, req) {
|
|
|
4398
4697
|
}
|
|
4399
4698
|
async function removeWorktree(ctx, req) {
|
|
4400
4699
|
const p = req.path.trim();
|
|
4401
|
-
const wt = (await listWorktreesAll(ctx)).find((w) => w.path === p || p && w.path ===
|
|
4700
|
+
const wt = (await listWorktreesAll(ctx)).find((w) => w.path === p || p && w.path === path5.resolve(p));
|
|
4402
4701
|
if (!wt) throw badRequest(`unknown worktree: ${p}`, "unknown_worktree");
|
|
4403
4702
|
if (wt.isMain) throw badRequest("the main worktree cannot be removed", "main_worktree");
|
|
4703
|
+
if (!wt.prunable && !req.force) {
|
|
4704
|
+
const state = await checkoutState(ctx, wt.path);
|
|
4705
|
+
if (state.busy) throw new HttpError(409, `a ${state.busy} is in progress in ${wt.path}; removing it throws that away`, "needs_force");
|
|
4706
|
+
}
|
|
4404
4707
|
const args = ["worktree", "remove"];
|
|
4405
4708
|
if (req.force) args.push("--force");
|
|
4406
4709
|
args.push(wt.path);
|
|
@@ -4418,187 +4721,6 @@ async function removeWorktree(ctx, req) {
|
|
|
4418
4721
|
return dropBranch(ctx, wt, req.deleteBranch);
|
|
4419
4722
|
}
|
|
4420
4723
|
|
|
4421
|
-
// packages/server/src/state.ts
|
|
4422
|
-
import os from "os";
|
|
4423
|
-
import path5 from "path";
|
|
4424
|
-
import { mkdir as mkdir2, open, readFile as readFile2, rename, stat as stat3, unlink, writeFile } from "fs/promises";
|
|
4425
|
-
function dataDir() {
|
|
4426
|
-
const xdg = process.env.XDG_DATA_HOME;
|
|
4427
|
-
const base = xdg?.trim() ? xdg : path5.join(os.homedir(), ".local", "share");
|
|
4428
|
-
return path5.join(base, "warden");
|
|
4429
|
-
}
|
|
4430
|
-
function repoHash(repoRoot) {
|
|
4431
|
-
return sha1(repoRoot).slice(0, 12);
|
|
4432
|
-
}
|
|
4433
|
-
function stateFilePath(repoRoot, baseDir = dataDir()) {
|
|
4434
|
-
return path5.join(baseDir, repoHash(repoRoot), "state.json");
|
|
4435
|
-
}
|
|
4436
|
-
function defaultPrefs() {
|
|
4437
|
-
return { viewMode: "unified", nvimSocketByRoot: {}, autoRefresh: true, railOpen: false, ignoreDebug: true };
|
|
4438
|
-
}
|
|
4439
|
-
function defaultState(repoRoot) {
|
|
4440
|
-
return { schemaVersion: 1, repoRoot, targets: {}, issues: [], todos: [], checkpoints: [], prefs: defaultPrefs() };
|
|
4441
|
-
}
|
|
4442
|
-
function ensureTarget(state, key) {
|
|
4443
|
-
let t = state.targets[key];
|
|
4444
|
-
if (!t) {
|
|
4445
|
-
t = { viewed: {}, comments: [] };
|
|
4446
|
-
state.targets[key] = t;
|
|
4447
|
-
}
|
|
4448
|
-
return t;
|
|
4449
|
-
}
|
|
4450
|
-
var usable = (v) => !!v && typeof v === "object" && typeof v.id === "string";
|
|
4451
|
-
var usableCheckpoint = (v) => {
|
|
4452
|
-
const c = v;
|
|
4453
|
-
return !!c && typeof c === "object" && Number.isInteger(c.id) && typeof c.tree === "string" && (c.worktree === void 0 || typeof c.worktree === "string");
|
|
4454
|
-
};
|
|
4455
|
-
var usableComment = (v) => usable(v) && typeof v.anchor === "object" && !!v.anchor;
|
|
4456
|
-
function normalise(raw2, repoRoot) {
|
|
4457
|
-
const base = defaultState(repoRoot);
|
|
4458
|
-
if (!raw2 || typeof raw2 !== "object") return base;
|
|
4459
|
-
const r = raw2;
|
|
4460
|
-
if (r.schemaVersion !== 1) return base;
|
|
4461
|
-
const targets = {};
|
|
4462
|
-
for (const [k, v] of Object.entries(r.targets ?? {})) {
|
|
4463
|
-
if (!v || typeof v !== "object") continue;
|
|
4464
|
-
targets[k] = {
|
|
4465
|
-
viewed: typeof v.viewed === "object" && v.viewed ? v.viewed : {},
|
|
4466
|
-
comments: Array.isArray(v.comments) ? v.comments.filter(usableComment) : [],
|
|
4467
|
-
...typeof v.head === "string" ? { head: v.head } : {}
|
|
4468
|
-
};
|
|
4469
|
-
}
|
|
4470
|
-
migrateLocalViews(targets);
|
|
4471
|
-
return {
|
|
4472
|
-
schemaVersion: 1,
|
|
4473
|
-
repoRoot: r.repoRoot ?? repoRoot,
|
|
4474
|
-
targets,
|
|
4475
|
-
issues: Array.isArray(r.issues) ? r.issues.filter(usable) : [],
|
|
4476
|
-
todos: Array.isArray(r.todos) ? r.todos.filter(usable) : [],
|
|
4477
|
-
checkpoints: Array.isArray(r.checkpoints) ? r.checkpoints.filter(usableCheckpoint) : [],
|
|
4478
|
-
prefs: {
|
|
4479
|
-
...defaultPrefs(),
|
|
4480
|
-
...r.prefs ?? {},
|
|
4481
|
-
nvimSocketByRoot: r.prefs?.nvimSocketByRoot ?? {},
|
|
4482
|
-
autoRefresh: typeof r.prefs?.autoRefresh === "boolean" ? r.prefs.autoRefresh : true,
|
|
4483
|
-
railOpen: typeof r.prefs?.railOpen === "boolean" ? r.prefs.railOpen : false,
|
|
4484
|
-
ignoreDebug: typeof r.prefs?.ignoreDebug === "boolean" ? r.prefs.ignoreDebug : true
|
|
4485
|
-
}
|
|
4486
|
-
};
|
|
4487
|
-
}
|
|
4488
|
-
function migrateLocalViews(targets) {
|
|
4489
|
-
for (const key of Object.keys(targets).sort()) {
|
|
4490
|
-
const scope = commentScopeKey(key);
|
|
4491
|
-
if (scope === key) continue;
|
|
4492
|
-
const src = targets[key];
|
|
4493
|
-
delete targets[key];
|
|
4494
|
-
if (src.comments.length === 0) continue;
|
|
4495
|
-
let dst = targets[scope];
|
|
4496
|
-
if (!dst) {
|
|
4497
|
-
dst = { viewed: {}, comments: [] };
|
|
4498
|
-
targets[scope] = dst;
|
|
4499
|
-
}
|
|
4500
|
-
dst.comments.push(...src.comments);
|
|
4501
|
-
}
|
|
4502
|
-
}
|
|
4503
|
-
var LOCK_STALE_MS = 5e3;
|
|
4504
|
-
var LOCK_WAIT_MS = 5e3;
|
|
4505
|
-
async function sleep(ms) {
|
|
4506
|
-
await new Promise((r) => setTimeout(r, ms));
|
|
4507
|
-
}
|
|
4508
|
-
var StateStore = class {
|
|
4509
|
-
constructor(file, repoRoot) {
|
|
4510
|
-
this.file = file;
|
|
4511
|
-
this.repoRoot = repoRoot;
|
|
4512
|
-
}
|
|
4513
|
-
file;
|
|
4514
|
-
repoRoot;
|
|
4515
|
-
queue = Promise.resolve();
|
|
4516
|
-
async load() {
|
|
4517
|
-
try {
|
|
4518
|
-
const text = await readFile2(this.file, "utf8");
|
|
4519
|
-
return normalise(JSON.parse(text), this.repoRoot);
|
|
4520
|
-
} catch (e) {
|
|
4521
|
-
const err = e;
|
|
4522
|
-
if (err.code === "ENOENT") return defaultState(this.repoRoot);
|
|
4523
|
-
if (e instanceof SyntaxError) {
|
|
4524
|
-
try {
|
|
4525
|
-
await rename(this.file, `${this.file}.corrupt-${Date.now()}`);
|
|
4526
|
-
} catch {
|
|
4527
|
-
}
|
|
4528
|
-
return defaultState(this.repoRoot);
|
|
4529
|
-
}
|
|
4530
|
-
throw e;
|
|
4531
|
-
}
|
|
4532
|
-
}
|
|
4533
|
-
/** Apply a mutation under lock. The callback receives the freshest on-disk state. */
|
|
4534
|
-
update(fn) {
|
|
4535
|
-
const run2 = async () => {
|
|
4536
|
-
await mkdir2(path5.dirname(this.file), { recursive: true });
|
|
4537
|
-
const release = await this.acquireLock();
|
|
4538
|
-
try {
|
|
4539
|
-
const state = await this.load();
|
|
4540
|
-
const result = await fn(state);
|
|
4541
|
-
await this.writeAtomic(state);
|
|
4542
|
-
return result;
|
|
4543
|
-
} finally {
|
|
4544
|
-
await release();
|
|
4545
|
-
}
|
|
4546
|
-
};
|
|
4547
|
-
const p = this.queue.then(run2, run2);
|
|
4548
|
-
this.queue = p.catch(() => void 0);
|
|
4549
|
-
return p;
|
|
4550
|
-
}
|
|
4551
|
-
async writeAtomic(state) {
|
|
4552
|
-
const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`;
|
|
4553
|
-
await writeFile(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
4554
|
-
await rename(tmp, this.file);
|
|
4555
|
-
}
|
|
4556
|
-
async acquireLock() {
|
|
4557
|
-
const lock = `${this.file}.lock`;
|
|
4558
|
-
const started = Date.now();
|
|
4559
|
-
for (; ; ) {
|
|
4560
|
-
try {
|
|
4561
|
-
const fh = await open(lock, "wx");
|
|
4562
|
-
await fh.writeFile(String(process.pid));
|
|
4563
|
-
await fh.close();
|
|
4564
|
-
return async () => {
|
|
4565
|
-
try {
|
|
4566
|
-
await unlink(lock);
|
|
4567
|
-
} catch {
|
|
4568
|
-
}
|
|
4569
|
-
};
|
|
4570
|
-
} catch (e) {
|
|
4571
|
-
const err = e;
|
|
4572
|
-
if (err.code !== "EEXIST") throw e;
|
|
4573
|
-
try {
|
|
4574
|
-
const st = await stat3(lock);
|
|
4575
|
-
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
4576
|
-
await unlink(lock).catch(() => void 0);
|
|
4577
|
-
continue;
|
|
4578
|
-
}
|
|
4579
|
-
} catch {
|
|
4580
|
-
continue;
|
|
4581
|
-
}
|
|
4582
|
-
if (Date.now() - started > LOCK_WAIT_MS) {
|
|
4583
|
-
throw new Error(`timed out waiting for state lock ${lock}`);
|
|
4584
|
-
}
|
|
4585
|
-
await sleep(20);
|
|
4586
|
-
}
|
|
4587
|
-
}
|
|
4588
|
-
}
|
|
4589
|
-
};
|
|
4590
|
-
function forgetWorktreeTargets(state, worktreePath) {
|
|
4591
|
-
const prefix = `worktree:${worktreePath}:`;
|
|
4592
|
-
const gone = /* @__PURE__ */ new Set();
|
|
4593
|
-
for (const key of Object.keys(state.targets)) {
|
|
4594
|
-
if (!key.startsWith(prefix)) continue;
|
|
4595
|
-
for (const c of state.targets[key].comments) gone.add(c.id);
|
|
4596
|
-
delete state.targets[key];
|
|
4597
|
-
}
|
|
4598
|
-
if (gone.size) for (const issue of state.issues) issue.commentIds = issue.commentIds.filter((id) => !gone.has(id));
|
|
4599
|
-
state.checkpoints = state.checkpoints.filter((c) => c.worktree !== worktreePath);
|
|
4600
|
-
}
|
|
4601
|
-
|
|
4602
4724
|
// packages/server/src/export.ts
|
|
4603
4725
|
var LANG_BY_EXT = {
|
|
4604
4726
|
ts: "ts",
|
|
@@ -4646,29 +4768,116 @@ function langForPath(filePath) {
|
|
|
4646
4768
|
if (!m?.[1]) return "";
|
|
4647
4769
|
return LANG_BY_EXT[m[1].toLowerCase()] ?? m[1].toLowerCase();
|
|
4648
4770
|
}
|
|
4771
|
+
function shortId(id) {
|
|
4772
|
+
return id.slice(0, 8);
|
|
4773
|
+
}
|
|
4774
|
+
var quote = (text) => text.replace(/\r\n/g, "\n").split("\n").map((l) => l ? `> ${l}` : ">").join("\n");
|
|
4649
4775
|
function formatCommentSection(c) {
|
|
4650
4776
|
const range = c.startLine === c.endLine ? `${c.startLine}` : `${c.startLine}-${c.endLine}`;
|
|
4651
4777
|
const width = String(c.endLine).length;
|
|
4652
4778
|
const code = c.codeSnippet.map((line, i) => `${String(c.startLine + i).padStart(width, " ")} | ${line}`).join("\n");
|
|
4653
4779
|
const lang = langForPath(c.filePath);
|
|
4654
|
-
const body = c.body.replace(/\r\n/g, "\n").split("\n").map((l) => l ? `> ${l}` : ">").join("\n");
|
|
4655
4780
|
const fence = code.includes("```") ? "````" : "```";
|
|
4656
4781
|
const orphanNote = c.status === "orphaned" ? " [orphaned: original location no longer exists]" : "";
|
|
4657
|
-
|
|
4782
|
+
const parts = [`## ${c.filePath}:${range} (${c.side}) [id: ${shortId(c.id)}]${orphanNote}`, `${fence}${lang}`, code, fence, quote(c.body)];
|
|
4783
|
+
for (const r of c.replies ?? []) parts.push(`${r.author === "agent" ? "Agent" : "Reviewer"} replied:
|
|
4784
|
+
${quote(r.body)}`);
|
|
4785
|
+
return parts.join("\n");
|
|
4658
4786
|
}
|
|
4659
|
-
function
|
|
4787
|
+
function replyFooter(replyCommand) {
|
|
4788
|
+
return `When you have dealt with a comment, answer it with \`${replyCommand} reply <id> "<what you changed, or why you did not>"\` so the reviewer sees your answer beside it.`;
|
|
4789
|
+
}
|
|
4790
|
+
function formatCommentsExport({ repoRoot, comments, replyCommand }) {
|
|
4660
4791
|
const targets = [...new Set(comments.map((c) => c.targetKey))];
|
|
4661
4792
|
const head = ["# Review comments", `Target: ${targets.join(", ") || "-"}`, `Repo: ${repoRoot}`, `Count: ${comments.length}`];
|
|
4662
4793
|
const sections = comments.map(formatCommentSection);
|
|
4663
|
-
|
|
4794
|
+
const tail = replyCommand && comments.length > 0 ? [replyFooter(replyCommand)] : [];
|
|
4795
|
+
return [head.join("\n"), ...sections, ...tail].join("\n\n") + "\n";
|
|
4664
4796
|
}
|
|
4665
|
-
function
|
|
4666
|
-
const
|
|
4667
|
-
const
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4797
|
+
function formatTodoExport({ todo, comments, replyCommand }) {
|
|
4798
|
+
const body = todo.body.replace(/\r\n/g, "\n").trim();
|
|
4799
|
+
const parts = [todo.title, ...body ? [body] : [], ...comments.map(formatCommentSection)];
|
|
4800
|
+
if (replyCommand && comments.length > 0) parts.push(replyFooter(replyCommand));
|
|
4801
|
+
return parts.join("\n\n");
|
|
4802
|
+
}
|
|
4803
|
+
|
|
4804
|
+
// packages/server/src/feedback.ts
|
|
4805
|
+
import { realpath as realpath3 } from "fs/promises";
|
|
4806
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
4807
|
+
async function real(p) {
|
|
4808
|
+
try {
|
|
4809
|
+
return await realpath3(p);
|
|
4810
|
+
} catch {
|
|
4811
|
+
return p;
|
|
4812
|
+
}
|
|
4813
|
+
}
|
|
4814
|
+
function worktreeOf(c, mainRoot) {
|
|
4815
|
+
return commentWorktree(c) ?? mainRoot;
|
|
4816
|
+
}
|
|
4817
|
+
async function commentsIn(state, worktreeRoot, mainRoot) {
|
|
4818
|
+
const want = await real(worktreeRoot);
|
|
4819
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
4820
|
+
const out = [];
|
|
4821
|
+
for (const t of Object.values(state.targets)) {
|
|
4822
|
+
for (const c of t.comments) {
|
|
4823
|
+
const wt = worktreeOf(c, mainRoot);
|
|
4824
|
+
let p = resolved.get(wt);
|
|
4825
|
+
if (!p) {
|
|
4826
|
+
p = real(wt);
|
|
4827
|
+
resolved.set(wt, p);
|
|
4828
|
+
}
|
|
4829
|
+
if (await p === want) out.push(c);
|
|
4830
|
+
}
|
|
4831
|
+
}
|
|
4832
|
+
return out;
|
|
4833
|
+
}
|
|
4834
|
+
async function takeFeedback(store, opts) {
|
|
4835
|
+
if (opts.peek) return (await commentsIn(await store.load(), opts.worktreeRoot, opts.mainRoot)).filter(awaitsAgent);
|
|
4836
|
+
return store.update(async (s) => {
|
|
4837
|
+
const ids = new Set((await commentsIn(s, opts.worktreeRoot, opts.mainRoot)).filter(awaitsAgent).map((c) => c.id));
|
|
4838
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4839
|
+
const taken = [];
|
|
4840
|
+
for (const t of Object.values(s.targets)) {
|
|
4841
|
+
t.comments = t.comments.map((c) => {
|
|
4842
|
+
if (!ids.has(c.id)) return c;
|
|
4843
|
+
const next = { ...c, exportedAt: now, updatedAt: now, status: c.status === "active" ? "exported" : c.status };
|
|
4844
|
+
taken.push(next);
|
|
4845
|
+
return next;
|
|
4846
|
+
});
|
|
4847
|
+
}
|
|
4848
|
+
return taken;
|
|
4849
|
+
});
|
|
4850
|
+
}
|
|
4851
|
+
function locate(state, ref) {
|
|
4852
|
+
const needle = ref.trim().toLowerCase();
|
|
4853
|
+
if (!needle) throw badRequest("a comment id is required", "bad_comment_id");
|
|
4854
|
+
const hits = [];
|
|
4855
|
+
for (const t of Object.values(state.targets)) {
|
|
4856
|
+
t.comments.forEach((c, index) => {
|
|
4857
|
+
if (c.id === needle) hits.unshift({ list: t.comments, index });
|
|
4858
|
+
else if (c.id.startsWith(needle)) hits.push({ list: t.comments, index });
|
|
4859
|
+
});
|
|
4860
|
+
}
|
|
4861
|
+
const exact = hits[0] && hits[0].list[hits[0].index].id === needle;
|
|
4862
|
+
if (hits.length === 0) throw notFound(`no comment with id ${ref}; it may have been resolved or deleted`, "unknown_comment");
|
|
4863
|
+
if (!exact && hits.length > 1) throw badRequest(`id ${ref} matches ${hits.length} comments; give more of it`, "ambiguous_comment");
|
|
4864
|
+
return hits[0];
|
|
4865
|
+
}
|
|
4866
|
+
async function addReply(store, ref, author, body) {
|
|
4867
|
+
if (!body.trim()) throw badRequest("reply must not be empty", "empty_reply");
|
|
4868
|
+
return store.update((s) => {
|
|
4869
|
+
const { list, index } = locate(s, ref);
|
|
4870
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4871
|
+
const reply2 = { id: randomUUID3(), author, body: body.trim(), at: now };
|
|
4872
|
+
const prev = list[index];
|
|
4873
|
+
const next = { ...prev, replies: [...prev.replies ?? [], reply2], updatedAt: now };
|
|
4874
|
+
if (author === "reviewer") {
|
|
4875
|
+
delete next.exportedAt;
|
|
4876
|
+
if (next.status === "exported") next.status = "active";
|
|
4877
|
+
}
|
|
4878
|
+
list[index] = next;
|
|
4879
|
+
return next;
|
|
4880
|
+
});
|
|
4672
4881
|
}
|
|
4673
4882
|
|
|
4674
4883
|
// packages/server/src/nvim.ts
|
|
@@ -4738,6 +4947,11 @@ async function findSockets(dirs = socketDirs()) {
|
|
|
4738
4947
|
for (const d of dirs) await visit(d, 0);
|
|
4739
4948
|
return [...found.entries()].map(([socket, pid]) => ({ socket, pid }));
|
|
4740
4949
|
}
|
|
4950
|
+
function chooseNvim(matching, wanted) {
|
|
4951
|
+
for (const w of wanted) if (w && matching.some((i) => i.socket === w)) return { socket: w };
|
|
4952
|
+
if (matching.length === 1) return { socket: matching[0].socket };
|
|
4953
|
+
return { error: matching.length ? "ambiguous" : "none" };
|
|
4954
|
+
}
|
|
4741
4955
|
var NvimService = class {
|
|
4742
4956
|
constructor(dirs) {
|
|
4743
4957
|
this.dirs = dirs;
|
|
@@ -4792,7 +5006,8 @@ var NvimService = class {
|
|
|
4792
5006
|
|
|
4793
5007
|
// packages/server/src/tmux.ts
|
|
4794
5008
|
import { execFile as execFile3 } from "child_process";
|
|
4795
|
-
import
|
|
5009
|
+
import path7 from "path";
|
|
5010
|
+
import { realpath as realpath4 } from "fs/promises";
|
|
4796
5011
|
var runTmux = (args) => new Promise((resolve, reject) => {
|
|
4797
5012
|
execFile3("tmux", args, { encoding: "utf8", timeout: 5e3, maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
|
|
4798
5013
|
if (!error) return resolve(stdout);
|
|
@@ -4802,34 +5017,48 @@ var runTmux = (args) => new Promise((resolve, reject) => {
|
|
|
4802
5017
|
);
|
|
4803
5018
|
});
|
|
4804
5019
|
});
|
|
5020
|
+
function sessionNameFor(worktreePath) {
|
|
5021
|
+
return path7.basename(worktreePath).replace(/[.:#]/g, "_") || "warden";
|
|
5022
|
+
}
|
|
4805
5023
|
var TmuxService = class {
|
|
4806
5024
|
constructor(run2 = runTmux) {
|
|
4807
5025
|
this.run = run2;
|
|
4808
5026
|
}
|
|
4809
5027
|
run;
|
|
4810
|
-
async
|
|
4811
|
-
const
|
|
4812
|
-
|
|
5028
|
+
async list() {
|
|
5029
|
+
const output = await this.run(["list-sessions", "-F", "#{session_id} #{session_name} #{session_path}"]).catch((e) => {
|
|
5030
|
+
if (e instanceof HttpError && /no server running|error connecting/i.test(e.message)) return "";
|
|
5031
|
+
throw e;
|
|
5032
|
+
});
|
|
4813
5033
|
const sessions = [];
|
|
4814
5034
|
for (const line of output.trimEnd().split("\n")) {
|
|
4815
5035
|
const [id, name, ...parts] = line.split(" ");
|
|
4816
|
-
const
|
|
4817
|
-
if (!id || !/^\$\d+$/.test(id) || !name || !
|
|
4818
|
-
|
|
5036
|
+
const dir = parts.join(" ");
|
|
5037
|
+
if (!id || !/^\$\d+$/.test(id) || !name || !dir) continue;
|
|
5038
|
+
sessions.push({ id, name, path: dir });
|
|
4819
5039
|
}
|
|
4820
5040
|
return sessions;
|
|
4821
5041
|
}
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
5042
|
+
/**
|
|
5043
|
+
* A detached session in the worktree, named after its directory, with a shell and nothing run in
|
|
5044
|
+
* it. One already there for the same directory is handed back rather than doubled; one of that
|
|
5045
|
+
* name elsewhere is refused, not replaced — it is someone's work.
|
|
5046
|
+
*/
|
|
5047
|
+
async openSession(worktreePath) {
|
|
5048
|
+
const directory = await realpath4(worktreePath);
|
|
5049
|
+
const name = sessionNameFor(directory);
|
|
5050
|
+
const existing = (await this.list()).find((x) => x.name === name);
|
|
5051
|
+
if (existing) {
|
|
5052
|
+
if (await realpath4(existing.path).catch(() => null) === directory) return { session: name, created: false };
|
|
5053
|
+
throw new HttpError(409, `\u5DF2\u6709\u540C\u540D tmux session ${name}\uFF0C\u5DE5\u4F5C\u76EE\u5F55\u662F ${existing.path}`, "tmux_name_taken");
|
|
5054
|
+
}
|
|
5055
|
+
await this.run(["new-session", "-d", "-s", name, "-c", directory.replaceAll("#", "##")]);
|
|
5056
|
+
return { session: name, created: true };
|
|
4828
5057
|
}
|
|
4829
5058
|
};
|
|
4830
5059
|
|
|
4831
5060
|
// packages/server/src/watcher.ts
|
|
4832
|
-
import
|
|
5061
|
+
import path8 from "path";
|
|
4833
5062
|
import { stat as stat5 } from "fs/promises";
|
|
4834
5063
|
var DEFAULT_POLL_INTERVAL_MS = 1500;
|
|
4835
5064
|
function statusPaths(out) {
|
|
@@ -4926,7 +5155,7 @@ var RepoWatcher = class {
|
|
|
4926
5155
|
const stats = await Promise.all(
|
|
4927
5156
|
statusPaths(statusRes.stdout).map(async (rel) => {
|
|
4928
5157
|
try {
|
|
4929
|
-
const st = await stat5(
|
|
5158
|
+
const st = await stat5(path8.join(this.root, rel));
|
|
4930
5159
|
return `${rel} ${st.mtimeMs} ${st.size}`;
|
|
4931
5160
|
} catch {
|
|
4932
5161
|
return `${rel} -`;
|
|
@@ -4938,7 +5167,7 @@ var RepoWatcher = class {
|
|
|
4938
5167
|
};
|
|
4939
5168
|
|
|
4940
5169
|
// packages/server/src/static.ts
|
|
4941
|
-
import
|
|
5170
|
+
import path9 from "path";
|
|
4942
5171
|
import { readFile as readFile3, stat as stat6 } from "fs/promises";
|
|
4943
5172
|
var MIME = {
|
|
4944
5173
|
".html": "text/html; charset=utf-8",
|
|
@@ -4956,20 +5185,20 @@ var MIME = {
|
|
|
4956
5185
|
".txt": "text/plain; charset=utf-8"
|
|
4957
5186
|
};
|
|
4958
5187
|
async function serveStaticFile(c, webDir) {
|
|
4959
|
-
const root =
|
|
5188
|
+
const root = path9.resolve(webDir);
|
|
4960
5189
|
let rel;
|
|
4961
5190
|
try {
|
|
4962
5191
|
rel = decodeURIComponent(c.req.path);
|
|
4963
5192
|
} catch {
|
|
4964
5193
|
return c.text("Bad Request", 400);
|
|
4965
5194
|
}
|
|
4966
|
-
let target =
|
|
4967
|
-
if (target !== root && !target.startsWith(root +
|
|
5195
|
+
let target = path9.resolve(root, "." + rel);
|
|
5196
|
+
if (target !== root && !target.startsWith(root + path9.sep)) return c.text("Forbidden", 403);
|
|
4968
5197
|
try {
|
|
4969
5198
|
const st = await stat6(target);
|
|
4970
|
-
if (st.isDirectory()) target =
|
|
5199
|
+
if (st.isDirectory()) target = path9.join(target, "index.html");
|
|
4971
5200
|
} catch {
|
|
4972
|
-
target =
|
|
5201
|
+
target = path9.join(root, "index.html");
|
|
4973
5202
|
}
|
|
4974
5203
|
let body;
|
|
4975
5204
|
try {
|
|
@@ -4977,7 +5206,7 @@ async function serveStaticFile(c, webDir) {
|
|
|
4977
5206
|
} catch {
|
|
4978
5207
|
return c.text("Not Found", 404);
|
|
4979
5208
|
}
|
|
4980
|
-
const ext =
|
|
5209
|
+
const ext = path9.extname(target).toLowerCase();
|
|
4981
5210
|
const isAsset = rel.startsWith("/assets/");
|
|
4982
5211
|
return new Response(new Uint8Array(body), {
|
|
4983
5212
|
status: 200,
|
|
@@ -4990,6 +5219,7 @@ async function serveStaticFile(c, webDir) {
|
|
|
4990
5219
|
|
|
4991
5220
|
// packages/server/src/app.ts
|
|
4992
5221
|
var SSE_HEARTBEAT_MS = 15e3;
|
|
5222
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost"]);
|
|
4993
5223
|
var LISTING_HINT_TTL_MS = 5 * 6e4;
|
|
4994
5224
|
function createApp(opts) {
|
|
4995
5225
|
const { repo, store } = opts;
|
|
@@ -5014,6 +5244,10 @@ function createApp(opts) {
|
|
|
5014
5244
|
return list;
|
|
5015
5245
|
};
|
|
5016
5246
|
const checkpoints = checkpointStore(store.file);
|
|
5247
|
+
const stateStamp = () => stat7(store.file).then(
|
|
5248
|
+
(st) => st.mtimeMs,
|
|
5249
|
+
() => 0
|
|
5250
|
+
);
|
|
5017
5251
|
const worktreeCtx = async (key) => {
|
|
5018
5252
|
let list = await worktrees();
|
|
5019
5253
|
try {
|
|
@@ -5041,6 +5275,14 @@ function createApp(opts) {
|
|
|
5041
5275
|
untracked: explicit?.untracked ?? hint?.untracked
|
|
5042
5276
|
});
|
|
5043
5277
|
};
|
|
5278
|
+
const checkpointHandoff = (comments) => {
|
|
5279
|
+
const worktrees2 = new Set(comments.map((x) => tryParseTargetKey(x.targetKey)?.worktree));
|
|
5280
|
+
for (const wt of worktrees2) {
|
|
5281
|
+
takeCheckpoint(store, wt ?? repo.commonRoot, wt, { handoff: true }).catch((e) => {
|
|
5282
|
+
console.error(`warden: no checkpoint for the comments handed over in ${wt ?? repo.commonRoot}: ${e instanceof Error ? e.message : e}`);
|
|
5283
|
+
});
|
|
5284
|
+
}
|
|
5285
|
+
};
|
|
5044
5286
|
const findComment = (state, id) => {
|
|
5045
5287
|
for (const [targetKey, t] of Object.entries(state.targets)) {
|
|
5046
5288
|
const index = t.comments.findIndex((c) => c.id === id);
|
|
@@ -5048,6 +5290,13 @@ function createApp(opts) {
|
|
|
5048
5290
|
}
|
|
5049
5291
|
return void 0;
|
|
5050
5292
|
};
|
|
5293
|
+
const linkable = (state, raw2) => {
|
|
5294
|
+
if (!Array.isArray(raw2) || raw2.some((x) => typeof x !== "string")) throw badRequest("commentIds must be a list of ids");
|
|
5295
|
+
const ids = [...new Set(raw2)];
|
|
5296
|
+
const missing = ids.find((x) => !findComment(state, x));
|
|
5297
|
+
if (missing) throw notFound(`no comment with id ${missing}`, "unknown_comment");
|
|
5298
|
+
return ids;
|
|
5299
|
+
};
|
|
5051
5300
|
app.onError((err, c) => {
|
|
5052
5301
|
if (err instanceof HttpError) {
|
|
5053
5302
|
return c.json({ error: err.message, code: err.code }, err.status);
|
|
@@ -5055,26 +5304,41 @@ function createApp(opts) {
|
|
|
5055
5304
|
console.error(err);
|
|
5056
5305
|
return c.json({ error: err instanceof Error ? err.message : String(err), code: "internal" }, 500);
|
|
5057
5306
|
});
|
|
5307
|
+
app.use("*", async (c, next) => {
|
|
5308
|
+
const host = new URL(c.req.url).hostname;
|
|
5309
|
+
if (!LOOPBACK_HOSTS.has(host)) {
|
|
5310
|
+
throw new HttpError(403, `requests for host ${host} are refused; open warden at http://127.0.0.1 or http://localhost`, "bad_host");
|
|
5311
|
+
}
|
|
5312
|
+
await next();
|
|
5313
|
+
});
|
|
5058
5314
|
const api = new Hono2();
|
|
5059
5315
|
api.use("*", async (c, next) => {
|
|
5060
|
-
if (c.req.method !== "GET" && c.req.method !== "HEAD"
|
|
5061
|
-
|
|
5316
|
+
if (c.req.method !== "GET" && c.req.method !== "HEAD") {
|
|
5317
|
+
const origin = c.req.header("origin");
|
|
5318
|
+
if (c.req.header("sec-fetch-site") === "cross-site" || origin && origin !== new URL(c.req.url).origin) {
|
|
5319
|
+
throw new HttpError(403, "cross-site requests are refused", "cross_site");
|
|
5320
|
+
}
|
|
5062
5321
|
}
|
|
5063
5322
|
await next();
|
|
5064
5323
|
});
|
|
5065
5324
|
api.get("/ping", (c) => c.text(opts.instanceToken ?? ""));
|
|
5066
5325
|
api.get("/repo", async (c) => {
|
|
5067
5326
|
const state = await store.load();
|
|
5068
|
-
const
|
|
5327
|
+
const own = repo.root === repo.commonRoot ? "working" : formatTargetKey({ kind: "working", worktree: repo.root });
|
|
5328
|
+
const info = await getRepoInfo(repo, own);
|
|
5069
5329
|
worktreeCache = { at: Date.now(), list: info.worktrees };
|
|
5070
5330
|
const last = state.prefs.lastTarget;
|
|
5071
5331
|
const target = last ? tryParseTargetKey(last) : void 0;
|
|
5072
|
-
const usable2 = !!target && (!target.worktree || info.worktrees.some((w) => w.path === target.worktree)) &&
|
|
5332
|
+
const usable2 = !!target && (!target.worktree || info.worktrees.some((w) => w.path === target.worktree)) && // A server started in a linked worktree is there to review that one, not whatever the main
|
|
5333
|
+
// worktree's page last looked at: the preference is shared by every server on the repository.
|
|
5334
|
+
(repo.root === repo.commonRoot || target.worktree === repo.root) && (target.kind !== "checkpoint" || !!findCheckpoint(state, target.worktree, target.id));
|
|
5073
5335
|
if (last && usable2) info.defaultTarget = last;
|
|
5074
5336
|
else if (last) {
|
|
5075
|
-
|
|
5076
|
-
s
|
|
5077
|
-
|
|
5337
|
+
if (repo.root === repo.commonRoot) {
|
|
5338
|
+
await store.update((s) => {
|
|
5339
|
+
s.prefs.lastTarget = "working";
|
|
5340
|
+
});
|
|
5341
|
+
}
|
|
5078
5342
|
}
|
|
5079
5343
|
return c.json(info);
|
|
5080
5344
|
});
|
|
@@ -5175,15 +5439,9 @@ function createApp(opts) {
|
|
|
5175
5439
|
});
|
|
5176
5440
|
api.post("/targets/:key/checkpoints", async (c) => {
|
|
5177
5441
|
const ctx = await worktreeCtx(c.req.param("key"));
|
|
5178
|
-
const
|
|
5179
|
-
const
|
|
5180
|
-
|
|
5181
|
-
const newest = checkpointsOf(s, ctx.target.worktree).at(-1);
|
|
5182
|
-
const unchanged = newest?.tree === tree;
|
|
5183
|
-
const checkpoint = unchanged ? newest : addCheckpoint(s, ctx.target.worktree, tree, head);
|
|
5184
|
-
return { checkpoint, unchanged, targetKey: checkpointKey(checkpoint) };
|
|
5185
|
-
});
|
|
5186
|
-
return c.json(res, res.unchanged ? 200 : 201);
|
|
5442
|
+
const { checkpoint, unchanged } = await takeCheckpoint(store, ctx.cwd, ctx.target.worktree);
|
|
5443
|
+
const res = { checkpoint, unchanged, targetKey: checkpointKey(checkpoint) };
|
|
5444
|
+
return c.json(res, unchanged ? 200 : 201);
|
|
5187
5445
|
});
|
|
5188
5446
|
api.delete("/targets/:key/checkpoints/:id", async (c) => {
|
|
5189
5447
|
const ctx = await worktreeCtx(c.req.param("key"));
|
|
@@ -5236,7 +5494,7 @@ function createApp(opts) {
|
|
|
5236
5494
|
if (!anchored) throw badRequest("selection is not fully visible in the diff on that side (must be inside one hunk)", "bad_selection");
|
|
5237
5495
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5238
5496
|
const comment = {
|
|
5239
|
-
id:
|
|
5497
|
+
id: randomUUID4(),
|
|
5240
5498
|
targetKey: key,
|
|
5241
5499
|
filePath: body.filePath,
|
|
5242
5500
|
side: body.side,
|
|
@@ -5307,12 +5565,19 @@ function createApp(opts) {
|
|
|
5307
5565
|
if (!t) return false;
|
|
5308
5566
|
const before = t.comments.length;
|
|
5309
5567
|
t.comments = t.comments.filter((x) => x.id !== id);
|
|
5310
|
-
|
|
5568
|
+
unlinkComments(s, [id]);
|
|
5311
5569
|
return t.comments.length !== before;
|
|
5312
5570
|
});
|
|
5313
5571
|
if (!removed) throw notFound("comment not found");
|
|
5314
5572
|
return c.json({ ok: true });
|
|
5315
5573
|
});
|
|
5574
|
+
api.post("/targets/:key/comments/:id/replies", async (c) => {
|
|
5575
|
+
const body = await c.req.json();
|
|
5576
|
+
if (typeof body.body !== "string") throw badRequest("body is required");
|
|
5577
|
+
const found = findComment(await store.load(), c.req.param("id"));
|
|
5578
|
+
if (!found || found.targetKey !== commentScopeKey(c.req.param("key"))) throw notFound("comment not found");
|
|
5579
|
+
return c.json(await addReply(store, found.comment.id, "reviewer", body.body), 201);
|
|
5580
|
+
});
|
|
5316
5581
|
api.post("/targets/:key/comments/reanchor", async (c) => {
|
|
5317
5582
|
const key = c.req.param("key");
|
|
5318
5583
|
const ctx = await targetCtx(key);
|
|
@@ -5353,7 +5618,6 @@ function createApp(opts) {
|
|
|
5353
5618
|
const comments = state.targets[scope]?.comments ?? [];
|
|
5354
5619
|
const prevHead = state.targets[scope]?.head;
|
|
5355
5620
|
const head = await revParse(ctx.cwd, "HEAD") ?? "";
|
|
5356
|
-
const committed = local && prevHead !== void 0 && prevHead !== head;
|
|
5357
5621
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5358
5622
|
const located = /* @__PURE__ */ new Map();
|
|
5359
5623
|
await Promise.all(
|
|
@@ -5377,6 +5641,19 @@ function createApp(opts) {
|
|
|
5377
5641
|
}
|
|
5378
5642
|
})
|
|
5379
5643
|
);
|
|
5644
|
+
const landed = /* @__PURE__ */ new Set();
|
|
5645
|
+
const lost = comments.filter((cm) => !located.has(cm.id));
|
|
5646
|
+
if (local && lost.length && prevHead && head && prevHead !== head && await mergeBase(ctx.cwd, prevHead, head) === prevHead) {
|
|
5647
|
+
const range = await worktreeCtx(
|
|
5648
|
+
formatTargetKey({ kind: "range", base: prevHead, head, ...ctx.target.worktree ? { worktree: ctx.target.worktree } : {} })
|
|
5649
|
+
);
|
|
5650
|
+
await Promise.all(
|
|
5651
|
+
lost.map(async (cm) => {
|
|
5652
|
+
const committedDiff = await getFileDiff(range, cm.filePath).catch(() => void 0);
|
|
5653
|
+
if (committedDiff && reanchorComment(cm, committedDiff, now).status !== "orphaned") landed.add(cm.id);
|
|
5654
|
+
})
|
|
5655
|
+
);
|
|
5656
|
+
}
|
|
5380
5657
|
const result = await store.update((s) => {
|
|
5381
5658
|
const t = ensureTarget(s, scope);
|
|
5382
5659
|
const dropped = [];
|
|
@@ -5384,7 +5661,7 @@ function createApp(opts) {
|
|
|
5384
5661
|
const patch = located.get(cm.id);
|
|
5385
5662
|
if (!patch) {
|
|
5386
5663
|
if (!comments.some((x) => x.id === cm.id)) return [cm];
|
|
5387
|
-
if (
|
|
5664
|
+
if (landed.has(cm.id) && !cm.replies?.length) {
|
|
5388
5665
|
dropped.push(cm.id);
|
|
5389
5666
|
return [];
|
|
5390
5667
|
}
|
|
@@ -5394,9 +5671,7 @@ function createApp(opts) {
|
|
|
5394
5671
|
const unchanged = cm.targetKey === next.targetKey && cm.status === next.status && cm.startLine === next.startLine && cm.endLine === next.endLine && cm.anchor.hunkHash === next.anchor.hunkHash;
|
|
5395
5672
|
return [unchanged ? cm : { ...next, updatedAt: now }];
|
|
5396
5673
|
});
|
|
5397
|
-
|
|
5398
|
-
for (const issue of s.issues) issue.commentIds = issue.commentIds.filter((x) => x !== id);
|
|
5399
|
-
}
|
|
5674
|
+
unlinkComments(s, dropped);
|
|
5400
5675
|
t.head = head;
|
|
5401
5676
|
return t.comments;
|
|
5402
5677
|
});
|
|
@@ -5407,6 +5682,7 @@ function createApp(opts) {
|
|
|
5407
5682
|
const body = await c.req.json();
|
|
5408
5683
|
if (!Array.isArray(body.commentIds)) throw badRequest("commentIds required");
|
|
5409
5684
|
const ids = body.commentIds.filter((x) => typeof x === "string");
|
|
5685
|
+
let handed = [];
|
|
5410
5686
|
const res = await store.update((s) => {
|
|
5411
5687
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5412
5688
|
const selected = [];
|
|
@@ -5418,94 +5694,14 @@ function createApp(opts) {
|
|
|
5418
5694
|
s.targets[found.targetKey].comments[found.index] = next;
|
|
5419
5695
|
selected.push(next);
|
|
5420
5696
|
}
|
|
5697
|
+
handed = selected;
|
|
5421
5698
|
return {
|
|
5422
|
-
text: formatCommentsExport({ repoRoot: repo.root, comments: selected }),
|
|
5699
|
+
text: formatCommentsExport({ repoRoot: repo.root, comments: selected, replyCommand: opts.replyCommand }),
|
|
5423
5700
|
count: selected.length,
|
|
5424
5701
|
commentIds: selected.map((x) => x.id)
|
|
5425
5702
|
};
|
|
5426
5703
|
});
|
|
5427
|
-
|
|
5428
|
-
});
|
|
5429
|
-
api.get("/issues", async (c) => {
|
|
5430
|
-
const s = await store.load();
|
|
5431
|
-
return c.json({ issues: s.issues });
|
|
5432
|
-
});
|
|
5433
|
-
api.post("/issues", async (c) => {
|
|
5434
|
-
const body = await c.req.json();
|
|
5435
|
-
if (!body.title?.trim()) throw badRequest("title is required");
|
|
5436
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5437
|
-
const issue = {
|
|
5438
|
-
id: randomUUID2(),
|
|
5439
|
-
title: body.title.trim(),
|
|
5440
|
-
body: typeof body.body === "string" ? body.body : "",
|
|
5441
|
-
status: body.status === "closed" ? "closed" : "open",
|
|
5442
|
-
commentIds: Array.isArray(body.commentIds) ? body.commentIds.filter((x) => typeof x === "string") : [],
|
|
5443
|
-
createdAt: now,
|
|
5444
|
-
updatedAt: now
|
|
5445
|
-
};
|
|
5446
|
-
await store.update((s) => {
|
|
5447
|
-
s.issues = insertAfter(s.issues, issue, typeof body.after === "string" ? body.after : void 0);
|
|
5448
|
-
});
|
|
5449
|
-
return c.json(issue, 201);
|
|
5450
|
-
});
|
|
5451
|
-
api.post("/issues/:id/move", async (c) => {
|
|
5452
|
-
const id = c.req.param("id");
|
|
5453
|
-
const body = await c.req.json();
|
|
5454
|
-
if (body.before !== null && typeof body.before !== "string") throw badRequest("before must be an id or null");
|
|
5455
|
-
const issues = await store.update((s) => {
|
|
5456
|
-
const next = moveBefore(s.issues, id, body.before);
|
|
5457
|
-
if (!next) throw notFound("issue not found");
|
|
5458
|
-
s.issues = next;
|
|
5459
|
-
return s.issues;
|
|
5460
|
-
});
|
|
5461
|
-
return c.json({ issues });
|
|
5462
|
-
});
|
|
5463
|
-
api.patch("/issues/:id", async (c) => {
|
|
5464
|
-
const id = c.req.param("id");
|
|
5465
|
-
const body = await c.req.json();
|
|
5466
|
-
const updated = await store.update((s) => {
|
|
5467
|
-
const issue = s.issues.find((i) => i.id === id);
|
|
5468
|
-
if (!issue) throw notFound("issue not found");
|
|
5469
|
-
if (typeof body.title === "string" && body.title.trim()) issue.title = body.title.trim();
|
|
5470
|
-
if (typeof body.body === "string") issue.body = body.body;
|
|
5471
|
-
if (body.status === "open" || body.status === "closed") issue.status = body.status;
|
|
5472
|
-
if (Array.isArray(body.commentIds)) issue.commentIds = [...new Set(body.commentIds.filter((x) => typeof x === "string"))];
|
|
5473
|
-
issue.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5474
|
-
return issue;
|
|
5475
|
-
});
|
|
5476
|
-
return c.json(updated);
|
|
5477
|
-
});
|
|
5478
|
-
api.delete("/issues/:id", async (c) => {
|
|
5479
|
-
const id = c.req.param("id");
|
|
5480
|
-
const removed = await store.update((s) => {
|
|
5481
|
-
const before = s.issues.length;
|
|
5482
|
-
s.issues = s.issues.filter((i) => i.id !== id);
|
|
5483
|
-
return s.issues.length !== before;
|
|
5484
|
-
});
|
|
5485
|
-
if (!removed) throw notFound("issue not found");
|
|
5486
|
-
return c.json({ ok: true });
|
|
5487
|
-
});
|
|
5488
|
-
api.post("/issues/:id/export", async (c) => {
|
|
5489
|
-
const id = c.req.param("id");
|
|
5490
|
-
const res = await store.update((s) => {
|
|
5491
|
-
const issue = s.issues.find((i) => i.id === id);
|
|
5492
|
-
if (!issue) throw notFound("issue not found");
|
|
5493
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5494
|
-
const comments = [];
|
|
5495
|
-
for (const cid of issue.commentIds) {
|
|
5496
|
-
const found = findComment(s, cid);
|
|
5497
|
-
if (!found) continue;
|
|
5498
|
-
const next = { ...found.comment, exportedAt: now, updatedAt: now };
|
|
5499
|
-
if (next.status === "active") next.status = "exported";
|
|
5500
|
-
s.targets[found.targetKey].comments[found.index] = next;
|
|
5501
|
-
comments.push(next);
|
|
5502
|
-
}
|
|
5503
|
-
return {
|
|
5504
|
-
text: formatIssueExport({ repoRoot: repo.root, issue, comments }),
|
|
5505
|
-
count: comments.length,
|
|
5506
|
-
commentIds: comments.map((x) => x.id)
|
|
5507
|
-
};
|
|
5508
|
-
});
|
|
5704
|
+
checkpointHandoff(handed);
|
|
5509
5705
|
return c.json(res);
|
|
5510
5706
|
});
|
|
5511
5707
|
const knownRoot = async (rootParam) => {
|
|
@@ -5529,7 +5725,7 @@ function createApp(opts) {
|
|
|
5529
5725
|
const branch = body.branch?.trim() || await currentBranch(await knownRoot(body.root));
|
|
5530
5726
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5531
5727
|
const todo = {
|
|
5532
|
-
id:
|
|
5728
|
+
id: randomUUID4(),
|
|
5533
5729
|
branch,
|
|
5534
5730
|
title: body.title.trim(),
|
|
5535
5731
|
body: typeof body.body === "string" ? body.body : "",
|
|
@@ -5538,6 +5734,10 @@ function createApp(opts) {
|
|
|
5538
5734
|
updatedAt: now
|
|
5539
5735
|
};
|
|
5540
5736
|
await store.update((s) => {
|
|
5737
|
+
if (body.commentIds !== void 0) {
|
|
5738
|
+
const ids = linkable(s, body.commentIds);
|
|
5739
|
+
if (ids.length) todo.commentIds = ids;
|
|
5740
|
+
}
|
|
5541
5741
|
s.todos = insertAfter(s.todos, todo, typeof body.after === "string" ? body.after : void 0);
|
|
5542
5742
|
});
|
|
5543
5743
|
return c.json(todo, 201);
|
|
@@ -5564,11 +5764,40 @@ function createApp(opts) {
|
|
|
5564
5764
|
if (typeof body.title === "string" && body.title.trim()) todo.title = body.title.trim();
|
|
5565
5765
|
if (typeof body.body === "string") todo.body = body.body;
|
|
5566
5766
|
if (body.status === "open" || body.status === "done") todo.status = body.status;
|
|
5767
|
+
if (body.commentIds !== void 0) {
|
|
5768
|
+
const ids = linkable(s, body.commentIds);
|
|
5769
|
+
if (ids.length) todo.commentIds = ids;
|
|
5770
|
+
else delete todo.commentIds;
|
|
5771
|
+
}
|
|
5567
5772
|
todo.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5568
5773
|
return todo;
|
|
5569
5774
|
});
|
|
5570
5775
|
return c.json(updated);
|
|
5571
5776
|
});
|
|
5777
|
+
api.post("/todos/:id/export", async (c) => {
|
|
5778
|
+
const id = c.req.param("id");
|
|
5779
|
+
const handed = [];
|
|
5780
|
+
const res = await store.update((s) => {
|
|
5781
|
+
const todo = s.todos.find((t) => t.id === id);
|
|
5782
|
+
if (!todo) throw notFound("todo not found");
|
|
5783
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5784
|
+
for (const cid of todo.commentIds ?? []) {
|
|
5785
|
+
const found = findComment(s, cid);
|
|
5786
|
+
if (!found) continue;
|
|
5787
|
+
const next = { ...found.comment, exportedAt: now, updatedAt: now };
|
|
5788
|
+
if (next.status === "active") next.status = "exported";
|
|
5789
|
+
s.targets[found.targetKey].comments[found.index] = next;
|
|
5790
|
+
handed.push(next);
|
|
5791
|
+
}
|
|
5792
|
+
return {
|
|
5793
|
+
text: formatTodoExport({ todo, comments: handed, replyCommand: opts.replyCommand }),
|
|
5794
|
+
count: handed.length,
|
|
5795
|
+
commentIds: handed.map((x) => x.id)
|
|
5796
|
+
};
|
|
5797
|
+
});
|
|
5798
|
+
checkpointHandoff(handed);
|
|
5799
|
+
return c.json(res);
|
|
5800
|
+
});
|
|
5572
5801
|
api.delete("/todos/:id", async (c) => {
|
|
5573
5802
|
const id = c.req.param("id");
|
|
5574
5803
|
const removed = await store.update((s) => {
|
|
@@ -5599,13 +5828,14 @@ function createApp(opts) {
|
|
|
5599
5828
|
if (ref && !isValidRef(ref)) throw badRequest("invalid ref");
|
|
5600
5829
|
if (filePath && (filePath.startsWith("/") || filePath.split("/").includes(".."))) throw badRequest("invalid path");
|
|
5601
5830
|
if (q.length > 200 || author.length > 200) throw badRequest("search text too long");
|
|
5602
|
-
const args = ["log", `--max-count=${limit + 1}`, `--skip=${offset}`, `--format=${COMMIT_FORMAT}`];
|
|
5831
|
+
const args = ["log", "--no-show-signature", `--max-count=${limit + 1}`, `--skip=${offset}`, `--format=${COMMIT_FORMAT}`];
|
|
5603
5832
|
if (firstParent) args.push("--first-parent");
|
|
5604
5833
|
if (q || author) args.push("--fixed-strings", "--regexp-ignore-case");
|
|
5605
5834
|
if (q) args.push(`--grep=${q}`);
|
|
5606
5835
|
if (author) args.push(`--author=${author}`);
|
|
5607
5836
|
if (ref) args.push(ref);
|
|
5608
|
-
|
|
5837
|
+
args.push("--");
|
|
5838
|
+
if (filePath) args.push(literal(filePath));
|
|
5609
5839
|
let stdout = "";
|
|
5610
5840
|
try {
|
|
5611
5841
|
stdout = (await runGit(args, { cwd })).stdout;
|
|
@@ -5619,7 +5849,7 @@ function createApp(opts) {
|
|
|
5619
5849
|
if (q && offset === 0 && /^[0-9a-f]{4,40}$/i.test(q)) {
|
|
5620
5850
|
const sha = await revParse(cwd, `${q}^{commit}`);
|
|
5621
5851
|
if (sha && !commits.some((x) => x.sha === sha)) {
|
|
5622
|
-
const hit = parseCommitLog((await runGit(["log", "--max-count=1", `--format=${COMMIT_FORMAT}`, sha], { cwd })).stdout)[0];
|
|
5852
|
+
const hit = parseCommitLog((await runGit(["log", "--no-show-signature", "--max-count=1", `--format=${COMMIT_FORMAT}`, sha, "--"], { cwd })).stdout)[0];
|
|
5623
5853
|
if (hit) commits = [hit, ...commits];
|
|
5624
5854
|
}
|
|
5625
5855
|
}
|
|
@@ -5638,17 +5868,40 @@ function createApp(opts) {
|
|
|
5638
5868
|
return c.json(res);
|
|
5639
5869
|
});
|
|
5640
5870
|
const tmux = opts.tmux ?? new TmuxService();
|
|
5641
|
-
api.
|
|
5642
|
-
api.post("/tmux/windows", async (c) => {
|
|
5871
|
+
api.post("/tmux/sessions", async (c) => {
|
|
5643
5872
|
const body = await c.req.json();
|
|
5644
|
-
if (!body || typeof body.path !== "string"
|
|
5873
|
+
if (!body || typeof body.path !== "string") throw badRequest("path is required");
|
|
5645
5874
|
const wt = (await worktrees(true)).find((w) => w.path === body.path && !w.bare);
|
|
5646
5875
|
if (!wt) throw badRequest("unknown worktree", "unknown_worktree");
|
|
5647
|
-
|
|
5876
|
+
const res = await tmux.openSession(wt.path);
|
|
5877
|
+
return c.json(res, res.created ? 201 : 200);
|
|
5648
5878
|
});
|
|
5649
5879
|
api.get("/worktrees", async (c) => {
|
|
5650
|
-
const list = await listWorktreesDetailed(repo);
|
|
5880
|
+
const [list, state] = await Promise.all([listWorktreesDetailed(repo), store.load()]);
|
|
5651
5881
|
const [branches, newSlot] = await Promise.all([listBranches(repo, list), nextSlot(repo, list)]);
|
|
5882
|
+
const review = /* @__PURE__ */ new Map();
|
|
5883
|
+
const latest = /* @__PURE__ */ new Map();
|
|
5884
|
+
for (const t of Object.values(state.targets)) {
|
|
5885
|
+
for (const cm of t.comments) {
|
|
5886
|
+
const at = commentWorktree(cm) ?? repo.commonRoot;
|
|
5887
|
+
const r = review.get(at) ?? { toAgent: 0, toReviewer: 0 };
|
|
5888
|
+
const seen = latest.get(at) ?? { agent: "", reviewer: "" };
|
|
5889
|
+
if (awaitsAgent(cm)) {
|
|
5890
|
+
r.toAgent++;
|
|
5891
|
+
if (cm.updatedAt >= seen.agent) [seen.agent, r.toAgentTarget] = [cm.updatedAt, cm.targetKey];
|
|
5892
|
+
}
|
|
5893
|
+
if (awaitsReviewer(cm)) {
|
|
5894
|
+
r.toReviewer++;
|
|
5895
|
+
if (cm.updatedAt >= seen.reviewer) [seen.reviewer, r.toReviewerTarget] = [cm.updatedAt, cm.targetKey];
|
|
5896
|
+
}
|
|
5897
|
+
review.set(at, r);
|
|
5898
|
+
latest.set(at, seen);
|
|
5899
|
+
}
|
|
5900
|
+
}
|
|
5901
|
+
for (const wt of list) {
|
|
5902
|
+
const r = review.get(wt.path);
|
|
5903
|
+
if (r && (r.toAgent || r.toReviewer)) wt.review = r;
|
|
5904
|
+
}
|
|
5652
5905
|
const res = { worktrees: list, branches, newSlot };
|
|
5653
5906
|
return c.json(res);
|
|
5654
5907
|
});
|
|
@@ -5656,12 +5909,18 @@ function createApp(opts) {
|
|
|
5656
5909
|
const res = { remotes: await lookupRemoteBranches(repo, c.req.query("branch") ?? "") };
|
|
5657
5910
|
return c.json(res);
|
|
5658
5911
|
});
|
|
5912
|
+
let worktreeWrites = Promise.resolve();
|
|
5913
|
+
const oneAtATime = (fn) => {
|
|
5914
|
+
const run2 = worktreeWrites.then(fn, fn);
|
|
5915
|
+
worktreeWrites = run2.catch(() => void 0);
|
|
5916
|
+
return run2;
|
|
5917
|
+
};
|
|
5659
5918
|
api.post("/worktrees", async (c) => {
|
|
5660
5919
|
const body = await c.req.json();
|
|
5661
5920
|
if (typeof body.branch !== "string") throw badRequest("branch is required");
|
|
5662
5921
|
if (body.base !== void 0 && typeof body.base !== "string") throw badRequest("base must be a ref");
|
|
5663
5922
|
if (body.slot !== void 0 && (!Number.isInteger(body.slot) || body.slot < 1)) throw badRequest("slot must be a positive integer", "invalid_slot");
|
|
5664
|
-
const res = await checkoutWorktree(repo, body);
|
|
5923
|
+
const res = await oneAtATime(() => checkoutWorktree(repo, body));
|
|
5665
5924
|
worktreeCache = null;
|
|
5666
5925
|
await store.update((s) => forgetWorktreeTargets(s, res.worktree.path));
|
|
5667
5926
|
return c.json(res, 201);
|
|
@@ -5669,14 +5928,14 @@ function createApp(opts) {
|
|
|
5669
5928
|
api.post("/worktrees/release", async (c) => {
|
|
5670
5929
|
const body = await c.req.json();
|
|
5671
5930
|
if (typeof body.path !== "string") throw badRequest("path is required");
|
|
5672
|
-
const res = await releaseWorktree(repo, body);
|
|
5931
|
+
const res = await oneAtATime(() => releaseWorktree(repo, body));
|
|
5673
5932
|
worktreeCache = null;
|
|
5674
5933
|
return c.json(res);
|
|
5675
5934
|
});
|
|
5676
5935
|
api.post("/worktrees/remove", async (c) => {
|
|
5677
5936
|
const body = await c.req.json();
|
|
5678
5937
|
if (typeof body.path !== "string") throw badRequest("path is required");
|
|
5679
|
-
const res = await removeWorktree(repo, body);
|
|
5938
|
+
const res = await oneAtATime(() => removeWorktree(repo, body));
|
|
5680
5939
|
worktreeCache = null;
|
|
5681
5940
|
return c.json(res);
|
|
5682
5941
|
});
|
|
@@ -5695,7 +5954,19 @@ function createApp(opts) {
|
|
|
5695
5954
|
const event = { type: "changed", ...change };
|
|
5696
5955
|
void enqueue(() => stream2.writeSSE({ event: "changed", data: JSON.stringify(event) }));
|
|
5697
5956
|
});
|
|
5698
|
-
|
|
5957
|
+
let stamp = await stateStamp();
|
|
5958
|
+
const statePoll = setInterval(() => {
|
|
5959
|
+
void stateStamp().then((next) => {
|
|
5960
|
+
if (next === stamp) return;
|
|
5961
|
+
stamp = next;
|
|
5962
|
+
const event = { type: "state", at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
5963
|
+
void enqueue(() => stream2.writeSSE({ event: "state", data: JSON.stringify(event) }));
|
|
5964
|
+
});
|
|
5965
|
+
}, opts.watchIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
|
|
5966
|
+
stream2.onAbort(() => {
|
|
5967
|
+
clearInterval(statePoll);
|
|
5968
|
+
release();
|
|
5969
|
+
});
|
|
5699
5970
|
try {
|
|
5700
5971
|
while (!stream2.aborted && !stream2.closed) {
|
|
5701
5972
|
await stream2.sleep(SSE_HEARTBEAT_MS);
|
|
@@ -5703,6 +5974,7 @@ function createApp(opts) {
|
|
|
5703
5974
|
await enqueue(() => stream2.write(": ping\n\n"));
|
|
5704
5975
|
}
|
|
5705
5976
|
} finally {
|
|
5977
|
+
clearInterval(statePoll);
|
|
5706
5978
|
release();
|
|
5707
5979
|
}
|
|
5708
5980
|
});
|
|
@@ -5714,9 +5986,8 @@ function createApp(opts) {
|
|
|
5714
5986
|
const instances = NvimService.matching(scan.instances, root);
|
|
5715
5987
|
const state = await store.load();
|
|
5716
5988
|
const preferred = state.prefs.nvimSocketByRoot[root];
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
else if (instances.length === 1) selected = instances[0].socket;
|
|
5989
|
+
const choice = chooseNvim(instances, [preferred]);
|
|
5990
|
+
const selected = "socket" in choice ? choice.socket : void 0;
|
|
5720
5991
|
const res = { root, nvimAvailable: scan.nvimAvailable, instances, selected, scannedAt: scan.scannedAt };
|
|
5721
5992
|
return c.json(res);
|
|
5722
5993
|
});
|
|
@@ -5730,19 +6001,38 @@ function createApp(opts) {
|
|
|
5730
6001
|
});
|
|
5731
6002
|
api.post("/nvim/open", async (c) => {
|
|
5732
6003
|
const body = await c.req.json();
|
|
5733
|
-
if (!body.
|
|
5734
|
-
if (!
|
|
5735
|
-
const
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
6004
|
+
if (!body.absPath) throw badRequest("absPath is required");
|
|
6005
|
+
if (!path10.isAbsolute(body.absPath)) throw badRequest("absPath must be absolute");
|
|
6006
|
+
const root = await knownRoot(body.root);
|
|
6007
|
+
const wanted = [body.socket, (await store.load()).prefs.nvimSocketByRoot[root]];
|
|
6008
|
+
const line = Number(body.line) || 1;
|
|
6009
|
+
const pick = async (force) => {
|
|
6010
|
+
const scan = await nvim.scan(force);
|
|
6011
|
+
if (!scan.nvimAvailable) throw badRequest("nvim is not on PATH", "nvim_unavailable");
|
|
6012
|
+
return { matching: NvimService.matching(scan.instances, root), choice: chooseNvim(NvimService.matching(scan.instances, root), wanted) };
|
|
6013
|
+
};
|
|
6014
|
+
let { choice } = await pick(false);
|
|
6015
|
+
if ("error" in choice) choice = (await pick(true)).choice;
|
|
6016
|
+
if ("error" in choice) {
|
|
6017
|
+
if (choice.error === "none") throw notFound(`no nvim is open inside ${root}`, "nvim_none");
|
|
6018
|
+
throw new HttpError(409, `several nvim instances are open inside ${root}; select one`, "nvim_ambiguous");
|
|
5739
6019
|
}
|
|
5740
6020
|
try {
|
|
5741
|
-
await nvim.open(
|
|
6021
|
+
await nvim.open(choice.socket, body.absPath, line);
|
|
6022
|
+
const res = { ok: true, socket: choice.socket };
|
|
6023
|
+
return c.json(res);
|
|
5742
6024
|
} catch (e) {
|
|
5743
|
-
|
|
6025
|
+
const failure = new HttpError(502, e instanceof Error ? e.message : String(e), "nvim_failed");
|
|
6026
|
+
const fresh = await pick(true);
|
|
6027
|
+
if (fresh.matching.some((i) => i.socket === choice.socket) || "error" in fresh.choice) throw failure;
|
|
6028
|
+
try {
|
|
6029
|
+
await nvim.open(fresh.choice.socket, body.absPath, line);
|
|
6030
|
+
} catch (e2) {
|
|
6031
|
+
throw new HttpError(502, e2 instanceof Error ? e2.message : String(e2), "nvim_failed");
|
|
6032
|
+
}
|
|
6033
|
+
const res = { ok: true, socket: fresh.choice.socket };
|
|
6034
|
+
return c.json(res);
|
|
5744
6035
|
}
|
|
5745
|
-
return c.json({ ok: true });
|
|
5746
6036
|
});
|
|
5747
6037
|
app.route("/api", api);
|
|
5748
6038
|
app.notFound((c) => {
|
|
@@ -5791,14 +6081,14 @@ function reachableFromWindows(port, token, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
|
5791
6081
|
}
|
|
5792
6082
|
|
|
5793
6083
|
// packages/server/src/update.ts
|
|
5794
|
-
import
|
|
5795
|
-
import { mkdir as mkdir3, readFile as readFile5, writeFile
|
|
6084
|
+
import path11 from "path";
|
|
6085
|
+
import { mkdir as mkdir3, readFile as readFile5, writeFile } from "fs/promises";
|
|
5796
6086
|
var PACKAGE = "@xbghc/warden";
|
|
5797
6087
|
var LATEST_URL = "https://registry.npmjs.org/@xbghc%2fwarden/latest";
|
|
5798
6088
|
var FETCH_TIMEOUT_MS = 3e3;
|
|
5799
6089
|
var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
5800
6090
|
function updateCacheFile(baseDir = dataDir()) {
|
|
5801
|
-
return
|
|
6091
|
+
return path11.join(baseDir, "update-check.json");
|
|
5802
6092
|
}
|
|
5803
6093
|
function updateCheckEnabled(env = process.env) {
|
|
5804
6094
|
return !env.WARDEN_NO_UPDATE_CHECK && !env.NO_UPDATE_NOTIFIER && !env.CI;
|
|
@@ -5832,8 +6122,8 @@ async function readCache(file, now) {
|
|
|
5832
6122
|
}
|
|
5833
6123
|
async function writeCache(file, cache) {
|
|
5834
6124
|
try {
|
|
5835
|
-
await mkdir3(
|
|
5836
|
-
await
|
|
6125
|
+
await mkdir3(path11.dirname(file), { recursive: true });
|
|
6126
|
+
await writeFile(file, JSON.stringify(cache));
|
|
5837
6127
|
} catch {
|
|
5838
6128
|
}
|
|
5839
6129
|
}
|
|
@@ -5877,8 +6167,8 @@ async function startServer(opts) {
|
|
|
5877
6167
|
const repo = await resolveRepo(opts.repoPath);
|
|
5878
6168
|
const stateFile = opts.stateFile ?? stateFilePath(repo.commonRoot);
|
|
5879
6169
|
const store = new StateStore(stateFile, repo.commonRoot);
|
|
5880
|
-
const instanceToken =
|
|
5881
|
-
const app = createApp({ repo, store, nvim: new NvimService(), webDir: opts.webDir, instanceToken, update: opts.update });
|
|
6170
|
+
const instanceToken = randomUUID5();
|
|
6171
|
+
const app = createApp({ repo, store, nvim: new NvimService(), webDir: opts.webDir, instanceToken, update: opts.update, replyCommand: opts.replyCommand });
|
|
5882
6172
|
const listen = (p) => new Promise((resolve, reject) => {
|
|
5883
6173
|
const s = serve({ fetch: app.fetch, hostname: HOST, port: p }, () => resolve(s));
|
|
5884
6174
|
s.once("error", reject);
|
|
@@ -5920,8 +6210,68 @@ async function startServer(opts) {
|
|
|
5920
6210
|
};
|
|
5921
6211
|
}
|
|
5922
6212
|
|
|
6213
|
+
// bin/agent.ts
|
|
6214
|
+
var AGENT_COMMANDS = ["feedback", "reply"];
|
|
6215
|
+
function agentUsage(cmd) {
|
|
6216
|
+
return `Commands for the agent, run inside the worktree under review:
|
|
6217
|
+
${cmd} feedback [--peek] Print the review comments not yet handed over, and mark them handed
|
|
6218
|
+
over (--peek leaves them as they are)
|
|
6219
|
+
${cmd} reply <id> <message> Answer a comment; <id> is the one in its heading. A message of "-",
|
|
6220
|
+
or none with stdin piped, is read from stdin
|
|
6221
|
+
`;
|
|
6222
|
+
}
|
|
6223
|
+
async function readStdin() {
|
|
6224
|
+
const chunks = [];
|
|
6225
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
6226
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
6227
|
+
}
|
|
6228
|
+
async function openStore() {
|
|
6229
|
+
const repo = await resolveRepo(process.cwd());
|
|
6230
|
+
return { store: new StateStore(stateFilePath(repo.commonRoot), repo.commonRoot), root: repo.root, commonRoot: repo.commonRoot };
|
|
6231
|
+
}
|
|
6232
|
+
async function feedback(args, replyCommand) {
|
|
6233
|
+
const unknown = args.find((a) => a !== "--peek");
|
|
6234
|
+
if (unknown) throw new Error(`unknown argument: ${unknown}`);
|
|
6235
|
+
const { store, root, commonRoot } = await openStore();
|
|
6236
|
+
const peek = args.includes("--peek");
|
|
6237
|
+
const comments = await takeFeedback(store, { worktreeRoot: root, mainRoot: commonRoot, peek });
|
|
6238
|
+
if (comments.length === 0) {
|
|
6239
|
+
console.log("No review comments are waiting for you.");
|
|
6240
|
+
return;
|
|
6241
|
+
}
|
|
6242
|
+
if (!peek) {
|
|
6243
|
+
await takeCheckpoint(store, root, root === commonRoot ? void 0 : root, { handoff: true }).catch((e) => {
|
|
6244
|
+
console.error(`warden feedback: no checkpoint taken: ${e instanceof Error ? e.message : e}`);
|
|
6245
|
+
});
|
|
6246
|
+
}
|
|
6247
|
+
process.stdout.write(formatCommentsExport({ repoRoot: root, comments, replyCommand }));
|
|
6248
|
+
}
|
|
6249
|
+
async function reply(args) {
|
|
6250
|
+
const [id, ...words] = args;
|
|
6251
|
+
if (!id) throw new Error("reply needs a comment id");
|
|
6252
|
+
let body = words.join(" ");
|
|
6253
|
+
if (body === "-" || !body && !process.stdin.isTTY) body = await readStdin();
|
|
6254
|
+
if (!body.trim()) throw new Error("reply needs a message");
|
|
6255
|
+
const { store } = await openStore();
|
|
6256
|
+
const c = await addReply(store, id, "agent", body);
|
|
6257
|
+
const range = c.startLine === c.endLine ? `${c.startLine}` : `${c.startLine}-${c.endLine}`;
|
|
6258
|
+
console.log(`Replied to ${shortId(c.id)} (${c.filePath}:${range}).`);
|
|
6259
|
+
}
|
|
6260
|
+
async function runAgentCommand(argv, replyCommand) {
|
|
6261
|
+
const [cmd, ...rest] = argv;
|
|
6262
|
+
if (!cmd || !AGENT_COMMANDS.includes(cmd)) return void 0;
|
|
6263
|
+
try {
|
|
6264
|
+
if (cmd === "feedback") await feedback(rest, replyCommand);
|
|
6265
|
+
else await reply(rest);
|
|
6266
|
+
return 0;
|
|
6267
|
+
} catch (e) {
|
|
6268
|
+
console.error(`warden ${cmd}: ${e instanceof HttpError || e instanceof Error ? e.message : String(e)}`);
|
|
6269
|
+
return 1;
|
|
6270
|
+
}
|
|
6271
|
+
}
|
|
6272
|
+
|
|
5923
6273
|
// bin/cli.ts
|
|
5924
|
-
var VERSION = true ? "0.
|
|
6274
|
+
var VERSION = true ? "0.16.1" : "dev";
|
|
5925
6275
|
function parseArgs(argv) {
|
|
5926
6276
|
const args = { repoPath: process.cwd(), open: true, updateCheck: true, help: false, version: false };
|
|
5927
6277
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -5940,14 +6290,17 @@ function parseArgs(argv) {
|
|
|
5940
6290
|
} else if (a === "--help" || a === "-h") args.help = true;
|
|
5941
6291
|
else if (a === "--version" || a === "-v") args.version = true;
|
|
5942
6292
|
else if (a.startsWith("-")) throw new Error(`unknown option: ${a}`);
|
|
5943
|
-
else args.repoPath =
|
|
6293
|
+
else args.repoPath = path12.resolve(a);
|
|
5944
6294
|
}
|
|
5945
6295
|
return args;
|
|
5946
6296
|
}
|
|
6297
|
+
var viaNpx = fileURLToPath(import.meta.url).includes(`${path12.sep}_npx${path12.sep}`);
|
|
6298
|
+
var selfCommand = viaNpx ? "npx @xbghc/warden" : "warden";
|
|
5947
6299
|
function usage() {
|
|
5948
6300
|
return `warden \u2014 local git diff review UI
|
|
5949
6301
|
|
|
5950
6302
|
Usage: warden [repoPath] [options]
|
|
6303
|
+
warden <command> [args]
|
|
5951
6304
|
|
|
5952
6305
|
Options:
|
|
5953
6306
|
--port <n>, -p <n> Listen on a fixed port (default: first free port from 4100, and under WSL
|
|
@@ -5957,6 +6310,9 @@ Options:
|
|
|
5957
6310
|
(also: WARDEN_NO_UPDATE_CHECK=1, NO_UPDATE_NOTIFIER=1, CI)
|
|
5958
6311
|
-h, --help Show this help
|
|
5959
6312
|
-v, --version Print version
|
|
6313
|
+
|
|
6314
|
+
${agentUsage("warden")}
|
|
6315
|
+
A repository in a directory named after a command is given as ./<name>.
|
|
5960
6316
|
`;
|
|
5961
6317
|
}
|
|
5962
6318
|
async function openBrowser(url) {
|
|
@@ -5985,20 +6341,22 @@ async function openBrowser(url) {
|
|
|
5985
6341
|
return false;
|
|
5986
6342
|
}
|
|
5987
6343
|
function resolveWebDir() {
|
|
5988
|
-
const here =
|
|
6344
|
+
const here = path12.dirname(fileURLToPath(import.meta.url));
|
|
5989
6345
|
const candidates = [
|
|
5990
6346
|
process.env.WARDEN_WEB_DIR,
|
|
5991
|
-
|
|
6347
|
+
path12.join(here, "web"),
|
|
5992
6348
|
// dist/cli.js -> dist/web
|
|
5993
|
-
|
|
6349
|
+
path12.join(here, "..", "dist", "web")
|
|
5994
6350
|
// bin/cli.ts (tsx dev) -> dist/web
|
|
5995
6351
|
].filter((p) => !!p);
|
|
5996
6352
|
for (const dir of candidates) {
|
|
5997
|
-
if (existsSync2(
|
|
6353
|
+
if (existsSync2(path12.join(dir, "index.html"))) return dir;
|
|
5998
6354
|
}
|
|
5999
6355
|
return void 0;
|
|
6000
6356
|
}
|
|
6001
6357
|
async function main() {
|
|
6358
|
+
const code = await runAgentCommand(process.argv.slice(2), selfCommand);
|
|
6359
|
+
if (code !== void 0) process.exit(code);
|
|
6002
6360
|
let args;
|
|
6003
6361
|
try {
|
|
6004
6362
|
args = parseArgs(process.argv.slice(2));
|
|
@@ -6019,12 +6377,11 @@ async function main() {
|
|
|
6019
6377
|
if (!webDir) {
|
|
6020
6378
|
console.error("warning: built web assets not found (dist/web). Only the /api endpoints will be served.");
|
|
6021
6379
|
}
|
|
6022
|
-
const viaNpx = fileURLToPath(import.meta.url).includes(`${path11.sep}_npx${path11.sep}`);
|
|
6023
6380
|
const checking = args.updateCheck && VERSION !== "dev" && updateCheckEnabled();
|
|
6024
6381
|
const update = checking ? checkForUpdate({ current: VERSION, npx: viaNpx }) : Promise.resolve(null);
|
|
6025
6382
|
let server;
|
|
6026
6383
|
try {
|
|
6027
|
-
server = await startServer({ repoPath: args.repoPath, port: args.port, webDir, update });
|
|
6384
|
+
server = await startServer({ repoPath: args.repoPath, port: args.port, webDir, update, replyCommand: selfCommand });
|
|
6028
6385
|
} catch (e) {
|
|
6029
6386
|
console.error(`warden: ${e.message}`);
|
|
6030
6387
|
process.exit(1);
|