@zdreamer/forge 0.1.0 → 0.1.2
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 +162 -9
- package/dist/server/src/app/collection.d.ts +10 -0
- package/dist/server/src/app/collection.js +29 -0
- package/dist/server/src/app/config.d.ts +12 -0
- package/dist/server/src/app/config.js +52 -0
- package/dist/server/src/app/http.d.ts +13 -0
- package/dist/server/src/app/http.js +19 -0
- package/dist/server/src/app/listener.d.ts +9 -0
- package/dist/server/src/app/listener.js +61 -0
- package/dist/server/src/app/startup.d.ts +16 -0
- package/dist/server/src/app/startup.js +60 -0
- package/dist/server/src/app/wire.d.ts +11 -0
- package/dist/server/src/app/wire.js +29 -0
- package/dist/server/src/app/workspace-routes.d.ts +11 -0
- package/dist/server/src/app/workspace-routes.js +15 -0
- package/dist/server/src/components/api-contract/index.d.ts +1 -0
- package/dist/server/src/components/api-contract/internal/types.d.ts +27 -0
- package/dist/server/src/components/diagnostic-ui/index.d.ts +1 -0
- package/dist/server/src/components/diagnostic-ui/index.js +1 -0
- package/dist/server/src/components/diagnostic-ui/internal/page.d.ts +1 -0
- package/dist/server/src/components/diagnostic-ui/internal/page.js +129 -0
- package/dist/server/src/components/diagnostic-ui/internal/routes.d.ts +2 -0
- package/dist/server/src/components/diagnostic-ui/internal/routes.js +11 -0
- package/dist/server/src/components/workspace-changes/index.d.ts +2 -0
- package/dist/server/src/components/workspace-changes/index.js +1 -0
- package/dist/server/src/components/workspace-changes/internal/boundaries.d.ts +3 -0
- package/dist/server/src/components/workspace-changes/internal/boundaries.js +56 -0
- package/dist/server/src/components/workspace-changes/internal/collect.d.ts +13 -0
- package/dist/server/src/components/workspace-changes/internal/collect.js +157 -0
- package/dist/server/src/components/workspace-changes/internal/comparison.d.ts +13 -0
- package/dist/server/src/components/workspace-changes/internal/comparison.js +229 -0
- package/dist/server/src/components/workspace-changes/internal/types.d.ts +10 -0
- package/dist/server/src/components/workspace-changes/internal/types.js +1 -0
- package/dist/server/src/components/workspace-policy/authoring/index.d.ts +1 -0
- package/dist/server/src/components/workspace-policy/authoring/index.js +1 -0
- package/dist/{components/workspace-policy → server/src/components/workspace-policy/authoring}/internal/types.d.ts +4 -2
- package/dist/server/src/components/workspace-policy/authoring/internal/types.js +1 -0
- package/dist/{components → server/src/components}/workspace-policy/index.d.ts +2 -1
- package/dist/{components → server/src/components}/workspace-policy/internal/load.d.ts +1 -1
- package/dist/{components → server/src/components}/workspace-policy/internal/load.js +3 -0
- package/dist/{components → server/src/components}/workspace-policy/internal/repositories.d.ts +3 -2
- package/dist/{components → server/src/components}/workspace-policy/internal/repositories.js +12 -6
- package/dist/server/src/lib/git/index.d.ts +2 -0
- package/dist/server/src/lib/git/index.js +1 -0
- package/dist/server/src/lib/git/internal/process.d.ts +16 -0
- package/dist/server/src/lib/git/internal/process.js +166 -0
- package/dist/server/src/lib/loopback-access/index.d.ts +1 -0
- package/dist/server/src/lib/loopback-access/index.js +1 -0
- package/dist/server/src/lib/loopback-access/internal/request-guard.d.ts +9 -0
- package/dist/server/src/lib/loopback-access/internal/request-guard.js +17 -0
- package/dist/server/src/main.js +33 -0
- package/dist/server/src/workspace.d.ts +1 -0
- package/dist/server/src/workspace.js +1 -0
- package/package.json +10 -5
- package/dist/main.js +0 -23
- package/dist/workspace.d.ts +0 -1
- /package/dist/{app → server/src/app}/inspect.d.ts +0 -0
- /package/dist/{app → server/src/app}/inspect.js +0 -0
- /package/dist/{components/workspace-policy/internal/types.js → server/src/components/api-contract/index.js} +0 -0
- /package/dist/{workspace.js → server/src/components/api-contract/internal/types.js} +0 -0
- /package/dist/{components → server/src/components}/workspace-policy/index.js +0 -0
- /package/dist/{main.d.ts → server/src/main.d.ts} +0 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
export const page = `<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<link rel="icon" href="data:,">
|
|
7
|
+
<title>Forge</title>
|
|
8
|
+
<style>
|
|
9
|
+
body { font: 16px system-ui, sans-serif; margin: 2rem auto; padding: 0 1rem; max-width: 75rem; color: #202830; background: #fafbfc; }
|
|
10
|
+
button { font: inherit; padding: .5rem 1rem; cursor: pointer; }
|
|
11
|
+
button:disabled { cursor: wait; }
|
|
12
|
+
li { margin: .75rem 0; overflow-wrap: anywhere; }
|
|
13
|
+
.path { font-family: ui-monospace, monospace; }
|
|
14
|
+
.error { color: #a02020; }
|
|
15
|
+
section { border-top: 1px solid #ccd3da; margin-top: 1.5rem; }
|
|
16
|
+
</style>
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<h1>Forge workspace</h1>
|
|
20
|
+
<p>Read-only workspace diagnostic. Refresh reads current changes. Restart the server to reload policy and repository participation.</p>
|
|
21
|
+
<button id="refresh" type="button">Refresh</button>
|
|
22
|
+
<p id="status" role="status" aria-live="polite">Loading…</p>
|
|
23
|
+
<section aria-labelledby="identity-heading">
|
|
24
|
+
<h2 id="identity-heading">Workspace and repositories</h2>
|
|
25
|
+
<p id="identity-error" class="error" role="alert"></p>
|
|
26
|
+
<div id="identity"></div>
|
|
27
|
+
</section>
|
|
28
|
+
<section aria-labelledby="changes-heading">
|
|
29
|
+
<h2 id="changes-heading">Current changes</h2>
|
|
30
|
+
<p id="changes-error" class="error" role="alert"></p>
|
|
31
|
+
<div id="changes"></div>
|
|
32
|
+
</section>
|
|
33
|
+
<script>
|
|
34
|
+
"use strict";
|
|
35
|
+
const refreshButton = document.getElementById("refresh");
|
|
36
|
+
const status = document.getElementById("status");
|
|
37
|
+
const identity = document.getElementById("identity");
|
|
38
|
+
const changes = document.getElementById("changes");
|
|
39
|
+
const identityError = document.getElementById("identity-error");
|
|
40
|
+
const changesError = document.getElementById("changes-error");
|
|
41
|
+
function textElement(tag, text, className) {
|
|
42
|
+
const element = document.createElement(tag);
|
|
43
|
+
element.textContent = text;
|
|
44
|
+
if (className) element.className = className;
|
|
45
|
+
return element;
|
|
46
|
+
}
|
|
47
|
+
async function request(path) {
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
let timer;
|
|
50
|
+
try {
|
|
51
|
+
return await Promise.race([
|
|
52
|
+
(async () => {
|
|
53
|
+
const response = await fetch(path, { cache: "no-store", signal: controller.signal });
|
|
54
|
+
if (!response.ok) throw new Error("Request failed (HTTP " + response.status + ")");
|
|
55
|
+
return response.json();
|
|
56
|
+
})(),
|
|
57
|
+
new Promise((_, reject) => {
|
|
58
|
+
// Allow the server's 30-second collection deadline to return its response first.
|
|
59
|
+
timer = setTimeout(() => {
|
|
60
|
+
reject(new Error("Request timed out"));
|
|
61
|
+
controller.abort();
|
|
62
|
+
}, 35000);
|
|
63
|
+
})
|
|
64
|
+
]);
|
|
65
|
+
} finally {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function showIdentity(data) {
|
|
70
|
+
document.title = "🛠️ " + data.workspace.name;
|
|
71
|
+
const root = textElement("p", data.workspace.root, "path");
|
|
72
|
+
const list = document.createElement("ul");
|
|
73
|
+
for (const repo of data.repositories) {
|
|
74
|
+
list.append(textElement("li", repo.path + " — " + repo.status + (repo.gitRoot ? " — " + repo.gitRoot : ""), "path"));
|
|
75
|
+
}
|
|
76
|
+
identity.replaceChildren(root, list);
|
|
77
|
+
}
|
|
78
|
+
function showChanges(data) {
|
|
79
|
+
if (data.changes.length === 0) {
|
|
80
|
+
changes.replaceChildren(textElement("p", "No uncommitted changes."));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const list = document.createElement("ul");
|
|
84
|
+
for (const change of data.changes) {
|
|
85
|
+
const item = document.createElement("li");
|
|
86
|
+
item.append(textElement("p", change.repo + " · " + change.path + " — " + change.kind, "path"));
|
|
87
|
+
if (change.from !== undefined) item.append(textElement("p", "From: " + change.from, "path"));
|
|
88
|
+
const memberships = document.createElement("ul");
|
|
89
|
+
for (const id of change.codebases) {
|
|
90
|
+
memberships.append(textElement("li", id + " — " + data.codebases[id].guidance, "path"));
|
|
91
|
+
}
|
|
92
|
+
item.append(change.codebases.length ? memberships : textElement("p", "No codebase membership."));
|
|
93
|
+
list.append(item);
|
|
94
|
+
}
|
|
95
|
+
changes.replaceChildren(list);
|
|
96
|
+
}
|
|
97
|
+
async function refresh() {
|
|
98
|
+
if (refreshButton.disabled) return;
|
|
99
|
+
refreshButton.disabled = true;
|
|
100
|
+
status.textContent = "Loading…";
|
|
101
|
+
changes.replaceChildren();
|
|
102
|
+
identityError.textContent = "";
|
|
103
|
+
changesError.textContent = "";
|
|
104
|
+
try {
|
|
105
|
+
async function load(path, show, region, label) {
|
|
106
|
+
try {
|
|
107
|
+
show(await request(path));
|
|
108
|
+
return true;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (path === "/api/workspace") document.title = "Forge";
|
|
111
|
+
region.textContent = label + ": " + error.message + ". Use Refresh to retry.";
|
|
112
|
+
status.textContent = "Refresh failed. Use Refresh to retry.";
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const results = await Promise.all([
|
|
117
|
+
load("/api/workspace", showIdentity, identityError, "Workspace"),
|
|
118
|
+
load("/api/changes", showChanges, changesError, "Changes")
|
|
119
|
+
]);
|
|
120
|
+
status.textContent = results.every(Boolean) ? "Up to date." : "Refresh failed. Use Refresh to retry.";
|
|
121
|
+
} finally {
|
|
122
|
+
refreshButton.disabled = false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
refreshButton.addEventListener("click", refresh);
|
|
126
|
+
refresh();
|
|
127
|
+
</script>
|
|
128
|
+
</body>
|
|
129
|
+
</html>`;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { page } from "./page.js";
|
|
3
|
+
export function createDiagnosticRoutes() {
|
|
4
|
+
const routes = new Hono();
|
|
5
|
+
routes.get("/", (context) => context.html(page));
|
|
6
|
+
routes.all("/", (context) => {
|
|
7
|
+
context.header("Allow", "GET, HEAD");
|
|
8
|
+
return context.json({ error: { code: "method_not_allowed", message: "Method not allowed" } }, 405);
|
|
9
|
+
});
|
|
10
|
+
return routes;
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createWorkspaceChanges } from "./internal/collect.js";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function inside(root: string, path: string): boolean;
|
|
2
|
+
/** Check only candidate ancestors; never traverse a nested repository or a symlink. */
|
|
3
|
+
export declare function createScope(repositoryRoot: string, workspaceRoot: string, participantRoot: string, exclusions: readonly string[]): (path: string) => Promise<boolean>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { validPath } from "./comparison.js";
|
|
4
|
+
export function inside(root, path) {
|
|
5
|
+
const offset = relative(root, path);
|
|
6
|
+
return !isAbsolute(offset) && offset !== ".." && !offset.startsWith(`..${sep}`);
|
|
7
|
+
}
|
|
8
|
+
function absent(error) {
|
|
9
|
+
return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
10
|
+
}
|
|
11
|
+
/** Check only candidate ancestors; never traverse a nested repository or a symlink. */
|
|
12
|
+
export function createScope(repositoryRoot, workspaceRoot, participantRoot, exclusions) {
|
|
13
|
+
const directories = new Map();
|
|
14
|
+
return async (path) => {
|
|
15
|
+
validPath(path);
|
|
16
|
+
if (path.split("/").some((part) => part.toLowerCase() === ".git"))
|
|
17
|
+
return false;
|
|
18
|
+
const absolute = resolve(repositoryRoot, path);
|
|
19
|
+
if (!inside(workspaceRoot, absolute) || !inside(participantRoot, absolute))
|
|
20
|
+
return false;
|
|
21
|
+
if (exclusions.some((root) => inside(root, absolute)))
|
|
22
|
+
return false;
|
|
23
|
+
let current = participantRoot;
|
|
24
|
+
for (const part of relative(participantRoot, absolute).split(sep)) {
|
|
25
|
+
current = resolve(current, part);
|
|
26
|
+
let state = directories.get(current);
|
|
27
|
+
if (state === undefined) {
|
|
28
|
+
try {
|
|
29
|
+
const stat = await lstat(current);
|
|
30
|
+
state = stat.isDirectory() && !stat.isSymbolicLink() ? "ordinary" : "stop";
|
|
31
|
+
if (state === "ordinary") {
|
|
32
|
+
try {
|
|
33
|
+
await lstat(resolve(current, ".git"));
|
|
34
|
+
state = "boundary";
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (!absent(error))
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (!absent(error))
|
|
44
|
+
throw error;
|
|
45
|
+
state = "stop";
|
|
46
|
+
}
|
|
47
|
+
directories.set(current, state);
|
|
48
|
+
}
|
|
49
|
+
if (state === "boundary")
|
|
50
|
+
return false;
|
|
51
|
+
if (state === "stop")
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type GitProcess } from "../../../lib/git/index.ts";
|
|
2
|
+
import type { RepositoryResolution, WorkspacePolicy } from "../../workspace-policy/index.ts";
|
|
3
|
+
import type { CollectedWorkspaceChanges } from "./types.ts";
|
|
4
|
+
interface Deps {
|
|
5
|
+
readonly root: string;
|
|
6
|
+
readonly repositories: readonly RepositoryResolution[];
|
|
7
|
+
readonly codebasesForChange: WorkspacePolicy["codebasesForChange"];
|
|
8
|
+
readonly git: GitProcess;
|
|
9
|
+
}
|
|
10
|
+
export declare function createWorkspaceChanges(deps: Deps): {
|
|
11
|
+
collect(signal?: AbortSignal): Promise<CollectedWorkspaceChanges>;
|
|
12
|
+
};
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
2
|
+
import { relative, resolve, sep } from "node:path";
|
|
3
|
+
import { gitFailure, preflightRepository } from "../../../lib/git/index.js";
|
|
4
|
+
import { createScope, inside } from "./boundaries.js";
|
|
5
|
+
import { compare, line } from "./comparison.js";
|
|
6
|
+
function memberships(value) {
|
|
7
|
+
if (!Array.isArray(value))
|
|
8
|
+
throw new TypeError("Policy memberships must be an array");
|
|
9
|
+
return Array.from(value, (item) => {
|
|
10
|
+
if (item === null ||
|
|
11
|
+
typeof item !== "object" ||
|
|
12
|
+
!("id" in item) ||
|
|
13
|
+
!("guidance" in item) ||
|
|
14
|
+
typeof item.id !== "string" ||
|
|
15
|
+
item.id.trim().length === 0 ||
|
|
16
|
+
typeof item.guidance !== "string" ||
|
|
17
|
+
item.guidance.trim().length === 0) {
|
|
18
|
+
throw new TypeError("Policy memberships need nonempty id and guidance strings");
|
|
19
|
+
}
|
|
20
|
+
return { id: item.id, guidance: item.guidance };
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export function createWorkspaceChanges(deps) {
|
|
24
|
+
const repositories = deps.repositories.map((repository) => ({ ...repository }));
|
|
25
|
+
const root = resolve(deps.root);
|
|
26
|
+
return {
|
|
27
|
+
async collect(signal) {
|
|
28
|
+
if ((await realpath(root)) !== root)
|
|
29
|
+
throw new Error("Workspace location changed");
|
|
30
|
+
const changes = [];
|
|
31
|
+
const collected = new Array(repositories.length);
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
signal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
34
|
+
let firstFailure;
|
|
35
|
+
let cursor = 0;
|
|
36
|
+
const definitions = new Map();
|
|
37
|
+
async function lane() {
|
|
38
|
+
while (cursor < repositories.length) {
|
|
39
|
+
const number = cursor++;
|
|
40
|
+
const repository = repositories[number];
|
|
41
|
+
if (!repository)
|
|
42
|
+
continue;
|
|
43
|
+
signal?.throwIfAborted();
|
|
44
|
+
if (repository.status === "missing")
|
|
45
|
+
continue;
|
|
46
|
+
try {
|
|
47
|
+
const location = resolve(root, repository.path);
|
|
48
|
+
const participantRoot = await realpath(location);
|
|
49
|
+
if (!inside(root, participantRoot) || !(await lstat(participantRoot)).isDirectory()) {
|
|
50
|
+
throw new Error("Participating repository location changed");
|
|
51
|
+
}
|
|
52
|
+
const probe = await deps.git(participantRoot, ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-path", "objects"], { signal });
|
|
53
|
+
if (probe.code !== 0)
|
|
54
|
+
throw gitFailure("rev-parse", probe);
|
|
55
|
+
// Frame with the known root: splitting lines would corrupt newline-bearing roots.
|
|
56
|
+
const prefix = Buffer.from(`${repository.gitRoot}\n`);
|
|
57
|
+
if (!probe.stdout.subarray(0, prefix.length).equals(prefix))
|
|
58
|
+
throw new Error("Participating repository Git root changed");
|
|
59
|
+
const gitRoot = await realpath(repository.gitRoot);
|
|
60
|
+
const objectPath = line(probe.stdout.subarray(prefix.length));
|
|
61
|
+
if (gitRoot !== repository.gitRoot ||
|
|
62
|
+
!inside(gitRoot, participantRoot) ||
|
|
63
|
+
(repository.path !== "." && gitRoot !== participantRoot)) {
|
|
64
|
+
throw new Error("Participating repository Git root changed");
|
|
65
|
+
}
|
|
66
|
+
await preflightRepository(deps.git, gitRoot, signal, objectPath);
|
|
67
|
+
const exclusions = repositories.flatMap((item, index) => {
|
|
68
|
+
if (index === number)
|
|
69
|
+
return [];
|
|
70
|
+
const logical = resolve(root, item.path);
|
|
71
|
+
const paths = [logical];
|
|
72
|
+
// Missing descendants have no canonical Git root; map them through a parent alias.
|
|
73
|
+
if (logical !== location && inside(location, logical)) {
|
|
74
|
+
paths.push(resolve(participantRoot, relative(location, logical)));
|
|
75
|
+
}
|
|
76
|
+
if (item.path !== "." && item.status === "available")
|
|
77
|
+
paths.push(item.gitRoot);
|
|
78
|
+
return paths.filter((path) => path !== participantRoot && inside(participantRoot, path));
|
|
79
|
+
});
|
|
80
|
+
const pathspec = (path) => relative(gitRoot, path).split(sep).join("/");
|
|
81
|
+
const scope = [
|
|
82
|
+
"--",
|
|
83
|
+
`:(top,literal)${pathspec(participantRoot)}`,
|
|
84
|
+
...new Set(exclusions.map((path) => `:(top,exclude,literal)${pathspec(path)}`)),
|
|
85
|
+
];
|
|
86
|
+
const inScope = createScope(gitRoot, root, participantRoot, exclusions);
|
|
87
|
+
const parsed = await compare(deps.git, gitRoot, scope, inScope, signal);
|
|
88
|
+
const scoped = [];
|
|
89
|
+
for (const item of parsed) {
|
|
90
|
+
signal?.throwIfAborted();
|
|
91
|
+
const destination = await inScope(item.path);
|
|
92
|
+
if (item.from === undefined) {
|
|
93
|
+
if (destination)
|
|
94
|
+
scoped.push(item);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const source = await inScope(item.from);
|
|
98
|
+
if (destination && source)
|
|
99
|
+
scoped.push(item);
|
|
100
|
+
else if (destination)
|
|
101
|
+
scoped.push({ path: item.path, kind: "added" });
|
|
102
|
+
else if (source && item.kind === "renamed")
|
|
103
|
+
scoped.push({ path: item.from, kind: "deleted" });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const publishedPath = (path) => repository.path === "."
|
|
107
|
+
? relative(root, resolve(gitRoot, path)).split(sep).join("/")
|
|
108
|
+
: `${repository.path}/${path}`;
|
|
109
|
+
scoped.sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)));
|
|
110
|
+
const entries = scoped.map((item) => {
|
|
111
|
+
const change = {
|
|
112
|
+
repo: repository.path,
|
|
113
|
+
path: publishedPath(item.path),
|
|
114
|
+
kind: item.kind,
|
|
115
|
+
...(item.from === undefined ? {} : { from: publishedPath(item.from) }),
|
|
116
|
+
};
|
|
117
|
+
return change;
|
|
118
|
+
});
|
|
119
|
+
entries.sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)));
|
|
120
|
+
collected[number] = entries;
|
|
121
|
+
}
|
|
122
|
+
catch (cause) {
|
|
123
|
+
const error = new Error(`Change collection failed for repository ${repository.path}`, { cause });
|
|
124
|
+
firstFailure ??= error;
|
|
125
|
+
controller.abort(error);
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Join both lanes so failed collections cannot leave a peer's Git/filter work alive.
|
|
131
|
+
const settled = await Promise.allSettled([lane(), lane()]);
|
|
132
|
+
const failed = settled.find((result) => result.status === "rejected");
|
|
133
|
+
if (failed?.status === "rejected")
|
|
134
|
+
throw firstFailure ?? failed.reason;
|
|
135
|
+
signal.throwIfAborted();
|
|
136
|
+
// Policy is synchronous and observes declaration order, independent of completion order.
|
|
137
|
+
for (const batch of collected)
|
|
138
|
+
for (const change of batch ?? []) {
|
|
139
|
+
try {
|
|
140
|
+
const ids = new Set();
|
|
141
|
+
for (const { id, guidance } of memberships(deps.codebasesForChange(change))) {
|
|
142
|
+
const previous = definitions.get(id);
|
|
143
|
+
if (previous && previous.guidance !== guidance)
|
|
144
|
+
throw new Error(`Conflicting guidance for codebase ${id}`);
|
|
145
|
+
definitions.set(id, { guidance });
|
|
146
|
+
ids.add(id);
|
|
147
|
+
}
|
|
148
|
+
changes.push({ ...change, codebases: [...ids] });
|
|
149
|
+
}
|
|
150
|
+
catch (cause) {
|
|
151
|
+
throw new Error(`Change collection failed for repository ${change.repo}`, { cause });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { codebases: Object.fromEntries(definitions), changes };
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type GitProcess } from "../../../lib/git/index.ts";
|
|
2
|
+
import type { WorkspaceChange } from "../../workspace-policy/index.ts";
|
|
3
|
+
export interface ComparedChange {
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly kind: WorkspaceChange["kind"];
|
|
6
|
+
readonly from?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function decode(bytes: Buffer): string;
|
|
9
|
+
export declare function line(bytes: Buffer): string;
|
|
10
|
+
export declare function validPath(path: string): void;
|
|
11
|
+
/** Raw records precede numstat records; -z gives renames separate path records in both sections. */
|
|
12
|
+
export declare function parseComparison(bytes: Buffer): ComparedChange[];
|
|
13
|
+
export declare function compare(git: GitProcess, root: string, scope: readonly string[], inScope: (path: string) => Promise<boolean>, signal?: AbortSignal): Promise<ComparedChange[]>;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { lstat, readFile, readlink } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { gitFailure } from "../../../lib/git/index.js";
|
|
4
|
+
export function decode(bytes) {
|
|
5
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
6
|
+
}
|
|
7
|
+
export function line(bytes) {
|
|
8
|
+
if (bytes.at(-1) !== 10)
|
|
9
|
+
throw new Error("Git returned a truncated line");
|
|
10
|
+
return decode(bytes.subarray(0, -1));
|
|
11
|
+
}
|
|
12
|
+
export function validPath(path) {
|
|
13
|
+
if (!path || path.startsWith("/") || path.split("/").some((part) => !part || part === "." || part === ".."))
|
|
14
|
+
throw new Error("Git returned an invalid path");
|
|
15
|
+
}
|
|
16
|
+
function records(bytes) {
|
|
17
|
+
if (!bytes.length)
|
|
18
|
+
return [];
|
|
19
|
+
if (bytes.at(-1) !== 0)
|
|
20
|
+
throw new Error("Git returned truncated NUL records");
|
|
21
|
+
return decode(bytes.subarray(0, -1)).split("\0");
|
|
22
|
+
}
|
|
23
|
+
/** Raw records precede numstat records; -z gives renames separate path records in both sections. */
|
|
24
|
+
export function parseComparison(bytes) {
|
|
25
|
+
const fields = records(bytes);
|
|
26
|
+
const raw = [];
|
|
27
|
+
let index = 0;
|
|
28
|
+
while (fields[index]?.startsWith(":")) {
|
|
29
|
+
const match = /^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]{40}|[0-9a-f]{64}) ([0-9a-f]{40}|[0-9a-f]{64}) ([AMDT]|[RC][0-9]{1,3})$/.exec(fields[index++] ?? "");
|
|
30
|
+
if (!match)
|
|
31
|
+
throw new Error("Git returned an invalid raw diff record");
|
|
32
|
+
const path = fields[index++];
|
|
33
|
+
if (path === undefined)
|
|
34
|
+
throw new Error("Git returned a truncated raw path");
|
|
35
|
+
validPath(path);
|
|
36
|
+
const status = match[5]?.[0];
|
|
37
|
+
let destination = path;
|
|
38
|
+
if (status === "R" || status === "C") {
|
|
39
|
+
const next = fields[index++];
|
|
40
|
+
if (next === undefined)
|
|
41
|
+
throw new Error("Git returned a truncated rename");
|
|
42
|
+
validPath(next);
|
|
43
|
+
destination = next;
|
|
44
|
+
}
|
|
45
|
+
raw.push({
|
|
46
|
+
change: {
|
|
47
|
+
path: destination,
|
|
48
|
+
kind: status === "A" || status === "C"
|
|
49
|
+
? "added"
|
|
50
|
+
: status === "D"
|
|
51
|
+
? "deleted"
|
|
52
|
+
: status === "R"
|
|
53
|
+
? "renamed"
|
|
54
|
+
: "modified",
|
|
55
|
+
...(status === "R" ? { from: path } : {}),
|
|
56
|
+
},
|
|
57
|
+
statOnlyCandidate: status === "M" && match[1] === match[2] && /^0+$/.test(match[4] ?? ""),
|
|
58
|
+
oldMode: match[1],
|
|
59
|
+
newMode: match[2],
|
|
60
|
+
source: path,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const stats = new Set();
|
|
64
|
+
const rawPaths = new Set(raw.map(({ change }) => change.path));
|
|
65
|
+
while (index < fields.length) {
|
|
66
|
+
const stat = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(fields[index++] ?? "");
|
|
67
|
+
if (!stat || (stat[1] === "-") !== (stat[2] === "-"))
|
|
68
|
+
throw new Error("Git returned an invalid numstat record");
|
|
69
|
+
let path = stat[3];
|
|
70
|
+
if (!path) {
|
|
71
|
+
const from = fields[index++];
|
|
72
|
+
const to = fields[index++];
|
|
73
|
+
if (from === undefined || to === undefined)
|
|
74
|
+
throw new Error("Git returned truncated numstat rename paths");
|
|
75
|
+
validPath(from);
|
|
76
|
+
path = to;
|
|
77
|
+
}
|
|
78
|
+
validPath(path);
|
|
79
|
+
if (!rawPaths.has(path))
|
|
80
|
+
throw new Error("Git returned numstat without a raw change");
|
|
81
|
+
stats.add(path);
|
|
82
|
+
}
|
|
83
|
+
const changes = [];
|
|
84
|
+
for (const { change, statOnlyCandidate, oldMode, newMode, source } of raw) {
|
|
85
|
+
// Raw diff alone includes stat noise and staged reversals when index refresh is disabled.
|
|
86
|
+
// Only a same-mode worktree M without a numstat entry is noise. Zero counts, binary
|
|
87
|
+
// markers and structural/mode changes remain changes even without textual lines.
|
|
88
|
+
if (statOnlyCandidate && !stats.has(change.path))
|
|
89
|
+
continue;
|
|
90
|
+
const oldLink = oldMode === "160000";
|
|
91
|
+
const newLink = newMode === "160000";
|
|
92
|
+
if (oldLink && newLink)
|
|
93
|
+
continue;
|
|
94
|
+
if (oldLink) {
|
|
95
|
+
if (newMode !== "000000")
|
|
96
|
+
changes.push({ path: change.path, kind: "added" });
|
|
97
|
+
}
|
|
98
|
+
else if (newLink) {
|
|
99
|
+
if (oldMode !== "000000")
|
|
100
|
+
changes.push({ path: source, kind: "deleted" });
|
|
101
|
+
}
|
|
102
|
+
else
|
|
103
|
+
changes.push(change);
|
|
104
|
+
}
|
|
105
|
+
return changes;
|
|
106
|
+
}
|
|
107
|
+
export async function compare(git, root, scope, inScope, signal) {
|
|
108
|
+
const run = async (args, input) => {
|
|
109
|
+
const result = await git(root, args, { signal, ...(input === undefined ? {} : { input }) });
|
|
110
|
+
if (result.code !== 0)
|
|
111
|
+
throw gitFailure(args[0] ?? "comparison", result);
|
|
112
|
+
return result.stdout;
|
|
113
|
+
};
|
|
114
|
+
const untracked = new Set();
|
|
115
|
+
for (const record of records(await run(["ls-files", "--unmerged", "--others", "--exclude-standard", "-t", "-z", ...scope]))) {
|
|
116
|
+
if (record.startsWith("? ")) {
|
|
117
|
+
const path = record.slice(2);
|
|
118
|
+
if (!path.endsWith("/") && (await inScope(path)))
|
|
119
|
+
untracked.add(path);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
const tab = record.indexOf("\t");
|
|
123
|
+
if (tab < 0 || !/^M [0-7]{6} [0-9a-f]+ [123]$/.test(record.slice(0, tab)))
|
|
124
|
+
throw new Error("Invalid unmerged record");
|
|
125
|
+
const path = record.slice(tab + 1);
|
|
126
|
+
if (await inScope(path))
|
|
127
|
+
throw new Error(`Unresolved merge conflict: ${path.slice(0, 200)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const head = await git(root, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], { signal });
|
|
131
|
+
let baseline;
|
|
132
|
+
if (head.code === 0)
|
|
133
|
+
baseline = line(head.stdout);
|
|
134
|
+
else {
|
|
135
|
+
const symbolic = await git(root, ["symbolic-ref", "--quiet", "HEAD"], { signal });
|
|
136
|
+
if (symbolic.code !== 0)
|
|
137
|
+
throw gitFailure("HEAD resolution", head);
|
|
138
|
+
const ref = line(symbolic.stdout);
|
|
139
|
+
if (!ref.startsWith("refs/heads/"))
|
|
140
|
+
throw new Error("Invalid unborn HEAD reference");
|
|
141
|
+
const exists = await git(root, ["show-ref", "--verify", "--quiet", ref], { signal });
|
|
142
|
+
if (exists.code !== 1 || exists.stderr.length)
|
|
143
|
+
throw gitFailure("HEAD resolution", head);
|
|
144
|
+
baseline = line(await run(["hash-object", "-t", "tree", "--stdin"], Buffer.alloc(0)));
|
|
145
|
+
}
|
|
146
|
+
if (!/^[0-9a-f]{40,64}$/.test(baseline))
|
|
147
|
+
throw new Error("Invalid baseline object ID");
|
|
148
|
+
let changes = parseComparison(await run([
|
|
149
|
+
"diff",
|
|
150
|
+
"--raw",
|
|
151
|
+
"--numstat",
|
|
152
|
+
"-z",
|
|
153
|
+
"--no-abbrev",
|
|
154
|
+
"--find-renames",
|
|
155
|
+
"--no-ext-diff",
|
|
156
|
+
"--no-textconv",
|
|
157
|
+
"--ignore-submodules=all",
|
|
158
|
+
baseline,
|
|
159
|
+
...scope,
|
|
160
|
+
]));
|
|
161
|
+
changes = changes.flatMap((item) => item.from !== undefined && untracked.has(item.from)
|
|
162
|
+
? [
|
|
163
|
+
{ path: item.from, kind: "deleted" },
|
|
164
|
+
{ path: item.path, kind: "added" },
|
|
165
|
+
]
|
|
166
|
+
: [item]);
|
|
167
|
+
const ambiguous = changes.filter((item) => item.kind === "deleted" && untracked.has(item.path));
|
|
168
|
+
if (ambiguous.length) {
|
|
169
|
+
const config = records(await run(["config", "--null", "--list"]));
|
|
170
|
+
const configValue = (key) => config
|
|
171
|
+
.filter((entry) => entry.slice(0, entry.indexOf("\n")).toLowerCase() === key)
|
|
172
|
+
.at(-1)
|
|
173
|
+
?.split("\n")
|
|
174
|
+
.slice(1)
|
|
175
|
+
.join("\n");
|
|
176
|
+
const disabled = (value) => value !== undefined && /^(false|no|off|0)$/i.test(value);
|
|
177
|
+
const fileMode = !disabled(configValue("core.filemode"));
|
|
178
|
+
const symlinks = !disabled(configValue("core.symlinks"));
|
|
179
|
+
const entries = new Map();
|
|
180
|
+
for (let start = 0; start < ambiguous.length; start += 100) {
|
|
181
|
+
const batch = ambiguous.slice(start, start + 100);
|
|
182
|
+
for (const record of records(await run(["ls-tree", "-z", baseline, "--", ...batch.map(({ path }) => `:(top,literal)${path}`)]))) {
|
|
183
|
+
const match = /^([0-7]{6}) (blob|tree|commit) ([0-9a-f]+)\t([\s\S]+)$/.exec(record);
|
|
184
|
+
if (!match)
|
|
185
|
+
throw new Error("Invalid HEAD tree entry");
|
|
186
|
+
entries.set(match[4], { mode: match[1], oid: match[3] });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const item of ambiguous) {
|
|
190
|
+
signal?.throwIfAborted();
|
|
191
|
+
const entry = entries.get(item.path);
|
|
192
|
+
if (!entry || !["100644", "100755", "120000"].includes(entry.mode))
|
|
193
|
+
throw new Error("Missing ordinary HEAD reconciliation entry");
|
|
194
|
+
const absolute = resolve(root, item.path);
|
|
195
|
+
let stat;
|
|
196
|
+
try {
|
|
197
|
+
stat = await lstat(absolute);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
|
|
201
|
+
untracked.delete(item.path);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
if (!stat.isFile() && !stat.isSymbolicLink())
|
|
207
|
+
continue;
|
|
208
|
+
let mode;
|
|
209
|
+
let oid;
|
|
210
|
+
if (stat.isSymbolicLink() || (!symlinks && entry.mode === "120000")) {
|
|
211
|
+
mode = "120000";
|
|
212
|
+
const bytes = stat.isSymbolicLink()
|
|
213
|
+
? await readlink(absolute, { encoding: "buffer" })
|
|
214
|
+
: await readFile(absolute);
|
|
215
|
+
oid = line(await run(["hash-object", "--no-filters", "--stdin"], bytes));
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
mode = fileMode ? (stat.mode & 0o100 ? "100755" : "100644") : entry.mode === "100755" ? "100755" : "100644";
|
|
219
|
+
oid = line(await run(["hash-object", `--path=${item.path}`, "--", item.path]));
|
|
220
|
+
}
|
|
221
|
+
untracked.delete(item.path);
|
|
222
|
+
changes = changes.filter((change) => change !== item);
|
|
223
|
+
if (mode !== entry.mode || oid !== entry.oid)
|
|
224
|
+
changes.push({ path: item.path, kind: "modified" });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
changes.push(...[...untracked].map((path) => ({ path, kind: "added" })));
|
|
228
|
+
return changes;
|
|
229
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { WorkspaceChange } from "../../workspace-policy/index.ts";
|
|
2
|
+
export interface CollectedChange extends WorkspaceChange {
|
|
3
|
+
readonly codebases: readonly string[];
|
|
4
|
+
}
|
|
5
|
+
export interface CollectedWorkspaceChanges {
|
|
6
|
+
readonly codebases: Readonly<Record<string, {
|
|
7
|
+
readonly guidance: string;
|
|
8
|
+
}>>;
|
|
9
|
+
readonly changes: readonly CollectedChange[];
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { CodebaseContext, RepositoryDeclaration, WorkspaceChange, WorkspacePolicy, } from "./internal/types.ts";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|