@akanjs/devkit 3.0.0-alpha.9 → 3.0.0-alpha.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/DEV_RUNTIME_KNOBS.md +27 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/agentsIndex.test.ts +10 -0
- package/agentsIndex.ts +21 -1
- package/aiEditor.ts +1 -1
- package/akanApp/BackendImportGraph.test.ts +120 -0
- package/akanApp/BackendImportGraph.ts +167 -0
- package/akanApp/akanApp.host.test.ts +16 -678
- package/akanApp/akanApp.host.ts +113 -578
- package/akanApp/devHostPolicy.test.ts +542 -0
- package/akanApp/devHostPolicy.ts +442 -0
- package/akanApp/index.ts +2 -0
- package/akanConfig/akanConfig.test.ts +182 -14
- package/akanConfig/akanConfig.ts +143 -47
- package/akanConfig/types.ts +9 -0
- package/akanContext.ts +30 -14
- package/akanMcpContract.ts +25 -4
- package/appSelectionMemory.ts +28 -0
- package/applicationBuildRunner.test.ts +1 -1
- package/applicationBuildRunner.ts +45 -21
- package/artifact/implicitRootLayout.test.ts +67 -0
- package/artifact/implicitRootLayout.ts +27 -7
- package/artifact/routeSeedIndex.test.ts +1 -0
- package/biome.base.json +86 -47
- package/biomeBase.ts +6 -5
- package/capacitorApp.ts +10 -0
- package/cloud/globalConfig.ts +9 -2
- package/codegenLock.test.ts +137 -0
- package/codegenLock.ts +136 -0
- package/commandDecorators/argMeta.ts +9 -1
- package/commandDecorators/command.ts +94 -7
- package/commandDecorators/commandBuilder.ts +20 -5
- package/commandDecorators/commandDecorators.test.ts +115 -3
- package/commandDecorators/helpFormatter.ts +4 -0
- package/dependencyScanner.test.ts +99 -0
- package/dependencyScanner.ts +27 -24
- package/devkitUtils.test.ts +0 -104
- package/executors.test.ts +129 -4
- package/executors.ts +105 -24
- package/fileEditor.ts +19 -19
- package/fileSys.ts +19 -1
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/autoImportSync.test.ts +58 -0
- package/frontendBuild/autoImportSync.ts +7 -2
- package/frontendBuild/buildRouteClient.test.ts +47 -24
- package/frontendBuild/clientBuildTypes.ts +4 -0
- package/frontendBuild/clientEntriesBundler.ts +4 -1
- package/frontendBuild/clientEntryDiscovery.ts +2 -2
- package/frontendBuild/csrArtifactBuilder.ts +116 -84
- package/frontendBuild/cssCompiler.ts +123 -12
- package/frontendBuild/cssImportResolver.ts +8 -7
- package/frontendBuild/devGeneratedIndexSync.ts +3 -2
- package/frontendBuild/fontOptimizer.ts +37 -18
- package/frontendBuild/fontPruner.test.ts +220 -0
- package/frontendBuild/fontPruner.ts +206 -0
- package/frontendBuild/frontendBuild.test.ts +178 -10
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/index.ts +1 -1
- package/frontendBuild/pagesBundleBuilder.ts +3 -3
- package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
- package/frontendBuild/routeClientBuilder.ts +12 -5
- package/frontendBuild/sourceMtimeIndex.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
- package/frontendBuild/styleContract.ts +14 -20
- package/frontendBuild/themeValidator.ts +22 -17
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/frontendBuild/watchRootResolver.test.ts +67 -0
- package/frontendBuild/watchRootResolver.ts +13 -1
- package/incrementalBuilder/devWatchBatch.test.ts +18 -20
- package/incrementalBuilder/devWatchBatch.ts +6 -2
- package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
- package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
- package/index.ts +0 -7
- package/integration/devStabilityHarness.ts +2 -10
- package/libSource.test.ts +112 -0
- package/libSource.ts +126 -0
- package/lint/__fixtures__/README.md +40 -0
- package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
- package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
- package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
- package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
- package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
- package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
- package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
- package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
- package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
- package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
- package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
- package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
- package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
- package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
- package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
- package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
- package/lint/gritRules.test.ts +178 -0
- package/lint/no-arbitrary-color.grit +1 -1
- package/lint/no-async-component-in-ui.grit +35 -0
- package/lint/no-bang-comment-in-client.grit +23 -10
- package/lint/no-daisyui-legacy-class.grit +26 -9
- package/lint/no-deprecated-log-level.grit +17 -0
- package/lint/no-init-fetch-in-client.grit +47 -0
- package/lint/no-inline-color.grit +10 -8
- package/lint/no-interpolated-arbitrary-class.grit +3 -3
- package/lint/no-model-type-in-util-zone.grit +58 -0
- package/lint/no-raw-palette-class.grit +3 -3
- package/lint/no-unpublished-form-setter.grit +41 -0
- package/lint/non-scalar-props-restricted.grit +16 -7
- package/linter.test.ts +80 -0
- package/linter.ts +99 -25
- package/package.json +5 -8
- package/prompter.ts +9 -4
- package/qualityScanner.test.ts +116 -0
- package/qualityScanner.ts +89 -21
- package/recipeScanner.ts +4 -1
- package/scanInfo.ts +32 -26
- package/semver.test.ts +26 -0
- package/semver.ts +31 -0
- package/slicePlanner.test.ts +182 -0
- package/slicePlanner.ts +235 -0
- package/ssrScanner.test.ts +301 -0
- package/subspace.test.ts +418 -0
- package/subspace.ts +723 -0
- package/subspaceConfig.ts +76 -0
- package/transforms/asyncDefaultExportDetector.ts +103 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/transforms/transforms.test.ts +5 -5
- package/transforms/tsconfigPackageResolver.test.ts +230 -0
- package/tsconfig.json +6 -2
- package/typeChecker.ts +1 -1
- package/types.ts +1 -0
- package/uploadRelease.ts +2 -2
- package/workflow/executor.test.ts +146 -0
- package/workspaceLayout.test.ts +56 -4
- package/workspaceLayout.ts +48 -2
- package/builder.ts +0 -164
- package/extractDeps.ts +0 -86
- package/frontendBuild/styleGuard.test.ts +0 -165
- package/frontendBuild/styleGuard.ts +0 -322
- package/getCredentials.ts +0 -19
- package/getModelFileData.ts +0 -62
- package/src/capacitorApp.ts +0 -282
- package/streamAi.ts +0 -45
- package/ui/MultiScrollList.tsx +0 -242
- package/ui/ScrollList.tsx +0 -107
- package/ui/index.ts +0 -2
- package/useStdoutDimensions.ts +0 -20
package/subspace.ts
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
import { mkdir, readdir, rename, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AppExecutor, Executor, WorkspaceExecutor } from "./executors";
|
|
4
|
+
import { FileSys } from "./fileSys";
|
|
5
|
+
import { LibSource } from "./libSource";
|
|
6
|
+
import { type SlicePlan, SlicePlanner } from "./slicePlanner";
|
|
7
|
+
import type { SubspaceConfig, SubspaceDeclaration } from "./subspaceConfig";
|
|
8
|
+
import type { PackageJson } from "./types";
|
|
9
|
+
|
|
10
|
+
export interface SubspaceIncomingCommit {
|
|
11
|
+
sha: string;
|
|
12
|
+
author: string;
|
|
13
|
+
subject: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SubspaceAnchor {
|
|
17
|
+
/** Subspace commit that last carried a push, located by the one file only a push writes. */
|
|
18
|
+
commit: string | null;
|
|
19
|
+
/** Commits the subspace gained since then — customer work that has never been in the workspace. */
|
|
20
|
+
incoming: SubspaceIncomingCommit[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SubspaceStatus {
|
|
24
|
+
name: string;
|
|
25
|
+
branch: string;
|
|
26
|
+
/** False when the subspace has no such branch: a push refuses it rather than creating it. */
|
|
27
|
+
hasBranch: boolean;
|
|
28
|
+
anchor: SubspaceAnchor;
|
|
29
|
+
/** Slice paths where workspace and subspace disagree — what a push would change. */
|
|
30
|
+
behindPaths: string[];
|
|
31
|
+
/** Libraries the subspace edited on its own: the drift this mechanism exists to stop. */
|
|
32
|
+
driftedLibs: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SubspaceDiffSection {
|
|
36
|
+
files: string[];
|
|
37
|
+
patch: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SubspaceDiffResult {
|
|
41
|
+
name: string;
|
|
42
|
+
branch: string;
|
|
43
|
+
/** What `akan subspace push` would change in the subspace, split so library changes are impossible to miss. */
|
|
44
|
+
app: SubspaceDiffSection;
|
|
45
|
+
libs: SubspaceDiffSection;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SubspacePushResult {
|
|
49
|
+
name: string;
|
|
50
|
+
outcome: "pushed" | "skipped" | "refused";
|
|
51
|
+
reason?: string;
|
|
52
|
+
commit?: string;
|
|
53
|
+
changedFiles: number;
|
|
54
|
+
/** Workspace-root dependencies left out of this subspace's manifest, because its apps do not use them. */
|
|
55
|
+
prunedDependencies?: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SubspacePullResult {
|
|
59
|
+
name: string;
|
|
60
|
+
applied: string[];
|
|
61
|
+
/** Library hunks, left on disk as a patch instead of applied unless `adoptLibs` was passed. */
|
|
62
|
+
libPatch: { path: string; files: string[] } | null;
|
|
63
|
+
ignored: string[];
|
|
64
|
+
incoming: SubspaceIncomingCommit[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One customer repo, mirrored from this workspace.
|
|
69
|
+
*
|
|
70
|
+
* Push is a squashed snapshot of the workspace's own tracked files, so a subspace's history never
|
|
71
|
+
* carries the workspace's — which is also what keeps one customer's commit messages out of another's
|
|
72
|
+
* repo. Pull is the reverse and rare: it compares the subspace against the last push it received rather
|
|
73
|
+
* than against the workspace, so the diff is exactly the customer's own work however far the workspace
|
|
74
|
+
* has moved on.
|
|
75
|
+
*/
|
|
76
|
+
export class Subspace {
|
|
77
|
+
static readonly anchorFile = "akan.subspace.json";
|
|
78
|
+
/** Never reaches a subspace: it names every other customer's repo. */
|
|
79
|
+
static readonly workspaceOnlyEntries = ["akan.subspace.ts"];
|
|
80
|
+
/** Workspace members, which are replaced wholesale rather than overlaid. */
|
|
81
|
+
static readonly memberDirs = ["apps", "libs"];
|
|
82
|
+
/** Subspace-owned in both directions: env values belong to the repo that deploys, not to the workspace. */
|
|
83
|
+
static readonly subspaceOwnedDirs = ["env"];
|
|
84
|
+
static readonly secretsMarkers = ["# akan:secrets (managed by akan.config.ts — do not edit)", "# akan:secrets:end"];
|
|
85
|
+
static readonly holdDir = ".akan-subspace-hold";
|
|
86
|
+
|
|
87
|
+
#workspace: WorkspaceExecutor;
|
|
88
|
+
#config: SubspaceConfig;
|
|
89
|
+
#declaration: SubspaceDeclaration;
|
|
90
|
+
|
|
91
|
+
constructor(workspace: WorkspaceExecutor, config: SubspaceConfig, declaration: SubspaceDeclaration) {
|
|
92
|
+
this.#workspace = workspace;
|
|
93
|
+
this.#config = config;
|
|
94
|
+
this.#declaration = declaration;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get name() {
|
|
98
|
+
return this.#declaration.name;
|
|
99
|
+
}
|
|
100
|
+
get apps() {
|
|
101
|
+
return this.#declaration.apps;
|
|
102
|
+
}
|
|
103
|
+
get #remote() {
|
|
104
|
+
return `subspace-${this.#declaration.name}`;
|
|
105
|
+
}
|
|
106
|
+
get #clonePath() {
|
|
107
|
+
return path.join(this.#workspace.workspaceRoot, ".akan/subspace", this.#declaration.name);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async #git(args: string[]) {
|
|
111
|
+
return await this.#workspace.spawn("git", args);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async branch() {
|
|
115
|
+
return (await this.#git(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async headSha() {
|
|
119
|
+
return (await this.#git(["rev-parse", "--short", "HEAD"])).trim();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Adds the subspace as a remote if absent, then fetches the branch into this workspace's object store. */
|
|
123
|
+
async fetch(branch: string) {
|
|
124
|
+
const remotes = (await this.#git(["remote"])).split("\n").map((line) => line.trim());
|
|
125
|
+
if (remotes.includes(this.#remote)) await this.#git(["remote", "set-url", this.#remote, this.#declaration.repo]);
|
|
126
|
+
else await this.#git(["remote", "add", this.#remote, this.#declaration.repo]);
|
|
127
|
+
try {
|
|
128
|
+
await this.#git(["fetch", "--quiet", this.#remote, branch]);
|
|
129
|
+
return true;
|
|
130
|
+
} catch {
|
|
131
|
+
//? A subspace that has never had this branch is not an error here — `push` refuses it by name.
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The last push, located by the one file only a push writes. A tag or a recorded sha would have to be
|
|
138
|
+
* kept in step by hand; this file is already in the subspace's history and cannot drift out of it.
|
|
139
|
+
*/
|
|
140
|
+
async anchor(branch: string): Promise<SubspaceAnchor> {
|
|
141
|
+
const ref = `${this.#remote}/${branch}`;
|
|
142
|
+
const commit = (await this.#git(["log", "-1", "--format=%H", ref, "--", Subspace.anchorFile])).trim();
|
|
143
|
+
if (!commit) return { commit: null, incoming: [] };
|
|
144
|
+
const log = await this.#git(["log", "--format=%H%x09%an%x09%s", `${commit}..${ref}`]);
|
|
145
|
+
const incoming = log
|
|
146
|
+
.split("\n")
|
|
147
|
+
.filter((line) => !!line.trim())
|
|
148
|
+
.map((line) => {
|
|
149
|
+
const [sha = "", author = "", ...subject] = line.split("\t");
|
|
150
|
+
return { sha: sha.slice(0, 12), author, subject: subject.join("\t") };
|
|
151
|
+
});
|
|
152
|
+
return { commit, incoming };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** App and lib directories this subspace carries. Libraries come from each app's closure, never declared. */
|
|
156
|
+
async slice() {
|
|
157
|
+
const plans = await Promise.all(
|
|
158
|
+
this.#declaration.apps.map(
|
|
159
|
+
async (appName) => await new SlicePlanner(AppExecutor.from(this.#workspace, appName)).plan(),
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
const libs = [...new Set(plans.flatMap((plan) => plan.libs))].sort();
|
|
163
|
+
const paths = [...this.#declaration.apps.map((app) => `apps/${app}`), ...libs.map((lib) => `libs/${lib}`)];
|
|
164
|
+
return { plans, libs, paths };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#isSubspaceOwned(file: string) {
|
|
168
|
+
const segments = file.split("/");
|
|
169
|
+
if (!Subspace.memberDirs.includes(segments[0] ?? "")) return false;
|
|
170
|
+
return Subspace.subspaceOwnedDirs.includes(segments[2] ?? "");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A library manifest always differs: the subspace's copy carries the `akan.source` stamp a push writes and
|
|
175
|
+
* the workspace's does not. Comparing it with that key removed is what keeps `status` and `diff` from
|
|
176
|
+
* reporting every library as changed forever.
|
|
177
|
+
*/
|
|
178
|
+
#isStamped(file: string) {
|
|
179
|
+
return /^libs\/[^/]+\/package\.json$/.test(file);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async #differsBeyondStamp(ref: string, file: string) {
|
|
183
|
+
const [theirs, ours] = await Promise.all([
|
|
184
|
+
this.#git(["show", `${ref}:${file}`]).catch(() => ""),
|
|
185
|
+
this.#git(["show", `HEAD:${file}`]).catch(() => ""),
|
|
186
|
+
]);
|
|
187
|
+
if (!theirs || !ours) return true;
|
|
188
|
+
const normalize = (content: string) => {
|
|
189
|
+
try {
|
|
190
|
+
const manifest = JSON.parse(content) as Record<string, unknown>;
|
|
191
|
+
delete manifest[LibSource.manifestKey];
|
|
192
|
+
return JSON.stringify(manifest);
|
|
193
|
+
} catch {
|
|
194
|
+
return content;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
return normalize(theirs) !== normalize(ours);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async #meaningfulFiles(ref: string, files: string[]) {
|
|
201
|
+
const kept = await Promise.all(
|
|
202
|
+
files.map(async (file) => (this.#isStamped(file) ? await this.#differsBeyondStamp(ref, file) : true)),
|
|
203
|
+
);
|
|
204
|
+
return files.filter((_, index) => kept[index]);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async status(branch: string): Promise<SubspaceStatus> {
|
|
208
|
+
const hasBranch = await this.fetch(branch);
|
|
209
|
+
const [{ paths, libs }, anchor] = await Promise.all([
|
|
210
|
+
this.slice(),
|
|
211
|
+
hasBranch ? this.anchor(branch) : Promise.resolve<SubspaceAnchor>({ commit: null, incoming: [] }),
|
|
212
|
+
]);
|
|
213
|
+
if (!hasBranch) return { name: this.name, branch, hasBranch, anchor, behindPaths: [], driftedLibs: [] };
|
|
214
|
+
const ref = `${this.#remote}/${branch}`;
|
|
215
|
+
const diff = await this.#git(["diff", "--name-only", "HEAD", ref, "--", ...paths]);
|
|
216
|
+
const behindPaths = await this.#meaningfulFiles(
|
|
217
|
+
ref,
|
|
218
|
+
diff
|
|
219
|
+
.split("\n")
|
|
220
|
+
.filter((file) => !!file.trim())
|
|
221
|
+
.filter((file) => !this.#isSubspaceOwned(file)),
|
|
222
|
+
);
|
|
223
|
+
const driftedLibs = libs.filter((lib) => behindPaths.some((file) => file.startsWith(`libs/${lib}/`)));
|
|
224
|
+
return { name: this.name, branch, hasBranch, anchor, behindPaths, driftedLibs };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async #ensureClone(branch: string) {
|
|
228
|
+
if (!(await FileSys.dirExists(path.join(this.#clonePath, ".git")))) {
|
|
229
|
+
await rm(this.#clonePath, { recursive: true, force: true });
|
|
230
|
+
await mkdir(path.dirname(this.#clonePath), { recursive: true });
|
|
231
|
+
await this.#workspace.spawn("git", ["clone", "--quiet", this.#declaration.repo, this.#clonePath]);
|
|
232
|
+
}
|
|
233
|
+
const clone = new Executor(`subspace-${this.name}`, this.#clonePath);
|
|
234
|
+
await clone.spawn("git", ["fetch", "--quiet", "origin", branch]);
|
|
235
|
+
await clone.spawn("git", ["checkout", "--quiet", "-B", branch, `origin/${branch}`]);
|
|
236
|
+
await clone.spawn("git", ["clean", "-qfd"]);
|
|
237
|
+
return clone;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Moves the subspace's env trees aside, so replacing the members cannot take them with it. */
|
|
241
|
+
async #holdSubspaceOwned() {
|
|
242
|
+
const held: { from: string; to: string }[] = [];
|
|
243
|
+
const holdRoot = path.join(this.#clonePath, Subspace.holdDir);
|
|
244
|
+
await rm(holdRoot, { recursive: true, force: true });
|
|
245
|
+
for (const member of Subspace.memberDirs) {
|
|
246
|
+
const memberRoot = path.join(this.#clonePath, member);
|
|
247
|
+
if (!(await FileSys.dirExists(memberRoot))) continue;
|
|
248
|
+
for (const name of await readdir(memberRoot)) {
|
|
249
|
+
for (const owned of Subspace.subspaceOwnedDirs) {
|
|
250
|
+
const from = path.join(memberRoot, name, owned);
|
|
251
|
+
if (!(await FileSys.dirExists(from))) continue;
|
|
252
|
+
const to = path.join(holdRoot, member, name, owned);
|
|
253
|
+
await mkdir(path.dirname(to), { recursive: true });
|
|
254
|
+
await rename(from, to);
|
|
255
|
+
held.push({ from, to });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return held;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Puts back only the files the workspace does not ship. The workspace still owns the tracked env switch files
|
|
264
|
+
* (`env.server.ts` / `env.client.ts`), which is what keeps them in step; everything else under `env/` —
|
|
265
|
+
* the per-environment values, which the workspace gitignores and never had — belongs to the subspace and wins.
|
|
266
|
+
*/
|
|
267
|
+
async #restoreSubspaceOwned(held: { from: string; to: string }[]) {
|
|
268
|
+
for (const { from, to } of held) await Subspace.#copyMissing(to, from);
|
|
269
|
+
await rm(path.join(this.#clonePath, Subspace.holdDir), { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
static async #copyMissing(from: string, to: string) {
|
|
273
|
+
await mkdir(to, { recursive: true });
|
|
274
|
+
for (const entry of await readdir(from, { withFileTypes: true })) {
|
|
275
|
+
const source = path.join(from, entry.name);
|
|
276
|
+
const target = path.join(to, entry.name);
|
|
277
|
+
if (entry.isDirectory()) await Subspace.#copyMissing(source, target);
|
|
278
|
+
else if (!(await FileSys.entryExists(target))) await rename(source, target);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* `git archive` emits exactly the tracked files at HEAD, which is why the copy needs no exclude list of
|
|
284
|
+
* its own: generated barrels, the `(libs)`/`public/libs` symlinks, env values, secrets and the lockfile
|
|
285
|
+
* are all outside git and cannot enter the archive. Directory arguments only — a file list would put
|
|
286
|
+
* route paths like `page/(docs)/…` through a shell.
|
|
287
|
+
*/
|
|
288
|
+
async #extract(paths: string[], excludes: string[] = []) {
|
|
289
|
+
const pathspec = paths.map((entry) => `'${entry}'`).join(" ");
|
|
290
|
+
const excludeArgs = excludes.map((entry) => `--exclude='${entry}'`).join(" ");
|
|
291
|
+
await this.#workspace.exec(
|
|
292
|
+
`git archive HEAD -- ${pathspec} | tar -x -C '${this.#clonePath}' ${excludeArgs}`.trim(),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Verify-only, and an allowlist rather than a filter so no secret can reach a customer's clone even by
|
|
298
|
+
* accident. `AKAN_PUBLIC_*` values are the ones akan embeds in every client bundle, so they are public
|
|
299
|
+
* by construction; the cloud workspace id is pinned to `local` rather than copied.
|
|
300
|
+
*/
|
|
301
|
+
#localEnv() {
|
|
302
|
+
const { serveDomain } = WorkspaceExecutor.getBaseDevEnv();
|
|
303
|
+
return {
|
|
304
|
+
AKAN_WORKSPACE_ID: "local",
|
|
305
|
+
AKAN_PUBLIC_REPO_NAME: this.name,
|
|
306
|
+
AKAN_PUBLIC_SERVE_DOMAIN: serveDomain,
|
|
307
|
+
AKAN_PUBLIC_ENV: "local",
|
|
308
|
+
AKAN_PUBLIC_OPERATION_MODE: "local",
|
|
309
|
+
AKAN_PUBLIC_LOG_LEVEL: "warn",
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The CLI identifies a workspace root by `package.json` + `tsconfig.json` + `.env`, so a clone with no
|
|
315
|
+
* `.env` cannot run `akan sync` at all — and one can never arrive with the slice, since every akan
|
|
316
|
+
* workspace gitignores it and the subspace's real values are its own. Excluded through the clone's
|
|
317
|
+
* `.git/info/exclude` rather than through the copied `.gitignore`: a workspace that omitted the pattern
|
|
318
|
+
* would otherwise commit this file into the customer's repo.
|
|
319
|
+
*/
|
|
320
|
+
async #writeLocalEnv() {
|
|
321
|
+
const excludePath = path.join(this.#clonePath, ".git/info/exclude");
|
|
322
|
+
const exclude = (await FileSys.fileExists(excludePath)) ? await FileSys.readText(excludePath) : "";
|
|
323
|
+
if (!exclude.split("\n").includes("/.env")) {
|
|
324
|
+
await mkdir(path.dirname(excludePath), { recursive: true });
|
|
325
|
+
await FileSys.writeText(excludePath, exclude.trim() ? `${exclude.replace(/\n*$/, "\n")}/.env\n` : "/.env\n");
|
|
326
|
+
}
|
|
327
|
+
const envPath = path.join(this.#clonePath, ".env");
|
|
328
|
+
if (await FileSys.fileExists(envPath)) return;
|
|
329
|
+
const lines = Object.entries(this.#localEnv()).map(([key, value]) => `${key}=${value}`);
|
|
330
|
+
await FileSys.writeText(envPath, `${lines.join("\n")}\n`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* A workspace's root manifest is the union of every app it holds, so shipping it verbatim installs
|
|
335
|
+
* every other customer's dependency tree in this repo. It is rebuilt from this subspace's own slices
|
|
336
|
+
* instead, keeping the workspace's exact version specs.
|
|
337
|
+
*/
|
|
338
|
+
async #rewriteManifest(plans: SlicePlan[]) {
|
|
339
|
+
const manifestPath = path.join(this.#clonePath, "package.json");
|
|
340
|
+
if (!(await FileSys.fileExists(manifestPath))) return [];
|
|
341
|
+
const rootPackageJson = (await FileSys.readJson(manifestPath)) as PackageJson;
|
|
342
|
+
const { packageJson, pruned, warnings } = await SlicePlanner.pruneDependencies(
|
|
343
|
+
this.#workspace,
|
|
344
|
+
rootPackageJson,
|
|
345
|
+
plans.flatMap((plan) => plan.requiredDependencies),
|
|
346
|
+
);
|
|
347
|
+
for (const warning of warnings) this.#workspace.logger.warn(warning);
|
|
348
|
+
await FileSys.writeJson(manifestPath, {
|
|
349
|
+
...packageJson,
|
|
350
|
+
name: this.name,
|
|
351
|
+
description: `${this.name} workspace`,
|
|
352
|
+
});
|
|
353
|
+
return pruned;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async #applySlice(clone: Executor, branch: string) {
|
|
357
|
+
const { libs, paths, plans } = await this.slice();
|
|
358
|
+
const held = await this.#holdSubspaceOwned();
|
|
359
|
+
for (const member of Subspace.memberDirs)
|
|
360
|
+
await rm(path.join(this.#clonePath, member), { recursive: true, force: true });
|
|
361
|
+
|
|
362
|
+
//* The shell is overlaid, never reconciled: a subspace owns its deployment (its own CI files and
|
|
363
|
+
//* workflows live there), so a root entry the workspace does not have is left alone rather than deleted.
|
|
364
|
+
await this.#extract(["."], ["apps/*", "libs/*", "pkgs/*"]);
|
|
365
|
+
await this.#extract(paths);
|
|
366
|
+
await this.#restoreSubspaceOwned(held);
|
|
367
|
+
|
|
368
|
+
for (const entry of [...Subspace.workspaceOnlyEntries, ...this.#config.exclude])
|
|
369
|
+
await rm(path.join(this.#clonePath, entry), { recursive: true, force: true });
|
|
370
|
+
|
|
371
|
+
await this.#rewriteGitignore();
|
|
372
|
+
const pruned = await this.#rewriteManifest(plans);
|
|
373
|
+
await this.#rewriteWorkspaceSection(libs);
|
|
374
|
+
await this.#writeLocalEnv();
|
|
375
|
+
for (const lib of libs) await this.#stampLib(clone, lib, branch);
|
|
376
|
+
return { libs, pruned };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The `akan:secrets` block akan generates lists every app in the workspace by name, so it is filtered down to
|
|
381
|
+
* this subspace's own apps. `bun.lock` is un-ignored because a subspace commits its lockfile — that is what
|
|
382
|
+
* makes two subspaces on one branch resolve the same dependency tree rather than merely the same ranges.
|
|
383
|
+
*/
|
|
384
|
+
async #rewriteGitignore() {
|
|
385
|
+
const gitignorePath = path.join(this.#clonePath, ".gitignore");
|
|
386
|
+
if (!(await FileSys.fileExists(gitignorePath))) return;
|
|
387
|
+
const [begin = "", end = ""] = Subspace.secretsMarkers;
|
|
388
|
+
const lines = (await FileSys.readText(gitignorePath)).split("\n");
|
|
389
|
+
const beginIdx = lines.indexOf(begin);
|
|
390
|
+
const endIdx = lines.indexOf(end);
|
|
391
|
+
const filtered =
|
|
392
|
+
beginIdx >= 0 && endIdx > beginIdx
|
|
393
|
+
? [
|
|
394
|
+
...lines.slice(0, beginIdx + 1),
|
|
395
|
+
...lines
|
|
396
|
+
.slice(beginIdx + 1, endIdx)
|
|
397
|
+
.filter((line) => this.#declaration.apps.some((app) => line.startsWith(`apps/${app}/`))),
|
|
398
|
+
...lines.slice(endIdx),
|
|
399
|
+
]
|
|
400
|
+
: lines;
|
|
401
|
+
await FileSys.writeText(gitignorePath, filtered.filter((line) => line.trim() !== "**/bun.lock").join("\n"));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** The generated `## Workspace` block names every app and library in the workspace. */
|
|
405
|
+
async #rewriteWorkspaceSection(libs: string[]) {
|
|
406
|
+
const replacements = [
|
|
407
|
+
[/^- Repo: .*$/m, `- Repo: ${this.name}`],
|
|
408
|
+
[/^- Apps: .*$/m, `- Apps: ${this.#declaration.apps.join(", ")}`],
|
|
409
|
+
[/^- Libraries: .*$/m, `- Libraries: ${libs.length ? libs.join(", ") : "(none)"}`],
|
|
410
|
+
] as const;
|
|
411
|
+
for (const file of ["AGENTS.md", "CLAUDE.md"]) {
|
|
412
|
+
const filePath = path.join(this.#clonePath, file);
|
|
413
|
+
if (!(await FileSys.fileExists(filePath))) continue;
|
|
414
|
+
const content = await FileSys.readText(filePath);
|
|
415
|
+
const rewritten = replacements.reduce((text, [pattern, value]) => text.replace(pattern, value), content);
|
|
416
|
+
if (rewritten !== content) await FileSys.writeText(filePath, rewritten);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Written onto the clone's manifest directly rather than through `LibSource`, which addresses a library
|
|
422
|
+
* by its position in the *workspace's* workspace. The hash follows the same rule — the library's own git files
|
|
423
|
+
* with `env/` and the stamp itself left out — and is only written when it would change, so an
|
|
424
|
+
* up-to-date subspace stays clean and the push is skipped.
|
|
425
|
+
*/
|
|
426
|
+
async #stampLib(clone: Executor, lib: string, branch: string) {
|
|
427
|
+
const manifestPath = path.join(this.#clonePath, "libs", lib, "package.json");
|
|
428
|
+
if (!(await FileSys.fileExists(manifestPath))) return;
|
|
429
|
+
const origin = `${this.#workspace.repoName}#${branch}`;
|
|
430
|
+
const sha = await this.headSha();
|
|
431
|
+
const [hash, previous] = await Promise.all([this.#hashLib(clone, lib), this.#committedStamp(clone, lib)]);
|
|
432
|
+
const manifest = (await FileSys.readJson(manifestPath)) as Record<string, unknown>;
|
|
433
|
+
const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
|
|
434
|
+
//* The extraction overwrote the manifest with the workspace's, stamp and all, so the previous stamp has to
|
|
435
|
+
//* come from the clone's HEAD. Reusing its `syncedAt` when nothing else moved is what leaves the file
|
|
436
|
+
//* byte-identical to what is committed — otherwise every push is dirty and none is ever skipped.
|
|
437
|
+
const unchanged = previous?.origin === origin && previous.sha === sha && previous.hash === hash;
|
|
438
|
+
const syncedAt = unchanged ? previous.syncedAt : new Date().toISOString();
|
|
439
|
+
manifest[LibSource.manifestKey] = { ...akan, source: { origin, sha, hash, syncedAt } };
|
|
440
|
+
await FileSys.writeJson(manifestPath, manifest);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async #committedStamp(clone: Executor, lib: string) {
|
|
444
|
+
try {
|
|
445
|
+
const committed = await clone.spawn("git", ["show", `HEAD:libs/${lib}/package.json`]);
|
|
446
|
+
const manifest = JSON.parse(committed) as Record<string, unknown>;
|
|
447
|
+
const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
|
|
448
|
+
return akan.source as { origin: string; sha: string; hash: string; syncedAt: string } | undefined;
|
|
449
|
+
} catch {
|
|
450
|
+
//? First push: the library is not in the subspace's history yet.
|
|
451
|
+
return undefined;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async #hashLib(clone: Executor, lib: string) {
|
|
456
|
+
const listed = await clone.spawn("git", [
|
|
457
|
+
"ls-files",
|
|
458
|
+
"-z",
|
|
459
|
+
"--cached",
|
|
460
|
+
"--others",
|
|
461
|
+
"--exclude-standard",
|
|
462
|
+
"--",
|
|
463
|
+
`libs/${lib}`,
|
|
464
|
+
]);
|
|
465
|
+
const files = listed
|
|
466
|
+
.split("\0")
|
|
467
|
+
.filter((file) => !!file && !this.#isSubspaceOwned(file))
|
|
468
|
+
.sort();
|
|
469
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
470
|
+
for (const file of files) {
|
|
471
|
+
const content = await FileSys.readText(path.join(this.#clonePath, file));
|
|
472
|
+
hasher.update(file);
|
|
473
|
+
hasher.update("\0");
|
|
474
|
+
if (file === `libs/${lib}/package.json`) {
|
|
475
|
+
const manifest = JSON.parse(content) as Record<string, unknown>;
|
|
476
|
+
delete manifest[LibSource.manifestKey];
|
|
477
|
+
hasher.update(JSON.stringify(manifest));
|
|
478
|
+
} else hasher.update(content);
|
|
479
|
+
hasher.update("\0");
|
|
480
|
+
}
|
|
481
|
+
return hasher.digest("hex").slice(0, 32);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Direction is subspace → workspace, so the patch reads as what a push would apply rather than its inverse. */
|
|
485
|
+
async diff(branch: string, filter?: string | null): Promise<SubspaceDiffResult> {
|
|
486
|
+
if (!(await this.fetch(branch))) throw new Error(`Subspace "${this.name}" has no branch "${branch}"`);
|
|
487
|
+
const { libs, paths } = await this.slice();
|
|
488
|
+
const libPaths = libs.map((lib) => `libs/${lib}`);
|
|
489
|
+
const appPaths = paths.filter((entry) => !libPaths.includes(entry));
|
|
490
|
+
const [app, libSection] = await Promise.all([
|
|
491
|
+
this.#diffSection(branch, appPaths, filter),
|
|
492
|
+
this.#diffSection(branch, libPaths, filter),
|
|
493
|
+
]);
|
|
494
|
+
return { name: this.name, branch, app, libs: libSection };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async #diffSection(branch: string, paths: string[], filter?: string | null): Promise<SubspaceDiffSection> {
|
|
498
|
+
const scoped = filter ? paths.filter((entry) => entry.startsWith(filter) || filter.startsWith(entry)) : paths;
|
|
499
|
+
if (!scoped.length) return { files: [], patch: "" };
|
|
500
|
+
const from = `${this.#remote}/${branch}`;
|
|
501
|
+
const pathspec = filter ? [filter] : scoped;
|
|
502
|
+
const names = await this.#git(["diff", "--name-only", from, "HEAD", "--", ...pathspec]);
|
|
503
|
+
const files = await this.#meaningfulFiles(
|
|
504
|
+
from,
|
|
505
|
+
names
|
|
506
|
+
.split("\n")
|
|
507
|
+
.filter((file) => !!file.trim())
|
|
508
|
+
.filter((file) => !this.#isSubspaceOwned(file)),
|
|
509
|
+
);
|
|
510
|
+
if (!files.length) return { files: [], patch: "" };
|
|
511
|
+
//? argv, not a shell: a route path such as `page/(docs)/…` would need quoting through one.
|
|
512
|
+
return { files, patch: await this.#git(["diff", from, "HEAD", "--", ...files]) };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Refuses the subspace rather than throwing, so one customer repo that fails to install or sync does not
|
|
517
|
+
* abort the push to the rest of the subspaces. The env is passed explicitly so the child sees the values
|
|
518
|
+
* written into the clone instead of inheriting the workspace's own.
|
|
519
|
+
*/
|
|
520
|
+
async #verify(clone: Executor) {
|
|
521
|
+
const env = { ...process.env, ...this.#localEnv() };
|
|
522
|
+
try {
|
|
523
|
+
await clone.spawn("bun", ["install"], { env });
|
|
524
|
+
for (const app of this.#declaration.apps) await clone.spawn("bunx", ["akan", "sync", app], { env });
|
|
525
|
+
return null;
|
|
526
|
+
} catch (error) {
|
|
527
|
+
return error instanceof Error ? error.message : String(error);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async push(branch: string, { verify = true }: { verify?: boolean } = {}): Promise<SubspacePushResult> {
|
|
532
|
+
this.#config.assertPushable(branch);
|
|
533
|
+
if (await this.#workspace.hasChanges())
|
|
534
|
+
return { name: this.name, outcome: "refused", reason: "workspace working tree is dirty", changedFiles: 0 };
|
|
535
|
+
if (!(await this.fetch(branch)))
|
|
536
|
+
return { name: this.name, outcome: "refused", reason: `subspace has no branch "${branch}"`, changedFiles: 0 };
|
|
537
|
+
|
|
538
|
+
const clone = await this.#ensureClone(branch);
|
|
539
|
+
const { pruned } = await this.#applySlice(clone, branch);
|
|
540
|
+
|
|
541
|
+
const dirty = (await clone.spawn("git", ["status", "--porcelain"])).trim();
|
|
542
|
+
if (!dirty)
|
|
543
|
+
return {
|
|
544
|
+
name: this.name,
|
|
545
|
+
outcome: "skipped",
|
|
546
|
+
reason: "already up to date",
|
|
547
|
+
changedFiles: 0,
|
|
548
|
+
prunedDependencies: pruned,
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const changedFiles = dirty.split("\n").length;
|
|
552
|
+
if (verify) {
|
|
553
|
+
const failure = await this.#verify(clone);
|
|
554
|
+
if (failure) return { name: this.name, outcome: "refused", reason: failure, changedFiles };
|
|
555
|
+
}
|
|
556
|
+
await this.#writeAnchorFile(branch);
|
|
557
|
+
await clone.spawn("git", ["add", "-A"]);
|
|
558
|
+
const message = `chore(subspace): sync from ${this.#workspace.repoName}@${await this.headSha()}`;
|
|
559
|
+
await clone.spawn("git", ["commit", "--quiet", "-m", message]);
|
|
560
|
+
//* Never force: a diverged subspace holds customer commits, and `pull` is how those come back.
|
|
561
|
+
await clone.spawn("git", ["push", "--quiet", "origin", branch]);
|
|
562
|
+
return {
|
|
563
|
+
name: this.name,
|
|
564
|
+
outcome: "pushed",
|
|
565
|
+
commit: (await clone.spawn("git", ["rev-parse", "--short", "HEAD"])).trim(),
|
|
566
|
+
changedFiles,
|
|
567
|
+
prunedDependencies: pruned,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async #writeAnchorFile(branch: string) {
|
|
572
|
+
await FileSys.writeJson(path.join(this.#clonePath, Subspace.anchorFile), {
|
|
573
|
+
workspace: this.#workspace.repoName,
|
|
574
|
+
hubSha: await this.headSha(),
|
|
575
|
+
branch,
|
|
576
|
+
apps: this.#declaration.apps,
|
|
577
|
+
syncedAt: new Date().toISOString(),
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async pull(branch: string, { adoptLibs = false }: { adoptLibs?: boolean } = {}): Promise<SubspacePullResult> {
|
|
582
|
+
if (!(await this.fetch(branch))) throw new Error(`Subspace "${this.name}" has no branch "${branch}"`);
|
|
583
|
+
const anchor = await this.anchor(branch);
|
|
584
|
+
if (!anchor.commit)
|
|
585
|
+
throw new Error(`Subspace "${this.name}" carries no ${Subspace.anchorFile} — it has never received a push`);
|
|
586
|
+
if (!anchor.incoming.length) return { name: this.name, applied: [], libPatch: null, ignored: [], incoming: [] };
|
|
587
|
+
|
|
588
|
+
const range = `${anchor.commit}..${this.#remote}/${branch}`;
|
|
589
|
+
const changed = (await this.#git(["diff", "--name-only", range])).split("\n").filter((file) => !!file.trim());
|
|
590
|
+
const appPrefixes = this.#declaration.apps.map((app) => `apps/${app}/`);
|
|
591
|
+
const appFiles = changed.filter(
|
|
592
|
+
(file) => appPrefixes.some((prefix) => file.startsWith(prefix)) && !this.#isSubspaceOwned(file),
|
|
593
|
+
);
|
|
594
|
+
const libFiles = changed.filter((file) => file.startsWith("libs/") && !this.#isSubspaceOwned(file));
|
|
595
|
+
const ignored = changed.filter((file) => !appFiles.includes(file) && !libFiles.includes(file));
|
|
596
|
+
|
|
597
|
+
const applied = adoptLibs ? [...appFiles, ...libFiles] : appFiles;
|
|
598
|
+
if (applied.length) await this.#applyPatch(range, applied, "incoming");
|
|
599
|
+
const saved = !adoptLibs && libFiles.length ? await this.#savePatch(range, libFiles, "libs") : null;
|
|
600
|
+
const libPatch = saved ? { path: saved.path, files: saved.files } : null;
|
|
601
|
+
return { name: this.name, applied, libPatch, ignored, incoming: anchor.incoming };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Left uncommitted in the workspace's working tree: a conflict is a normal 3-way conflict for a person. */
|
|
605
|
+
async #applyPatch(range: string, files: string[], label: string) {
|
|
606
|
+
const { absolute } = await this.#savePatch(range, files, label);
|
|
607
|
+
await this.#git(["apply", "--3way", absolute]);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* A library hunk is never applied by default: the workspace is the one copy every other subspace is pushed from,
|
|
612
|
+
* so adopting one customer's edit silently would ship it to all of them.
|
|
613
|
+
*/
|
|
614
|
+
async #savePatch(range: string, files: string[], label: string) {
|
|
615
|
+
const patchPath = path.join(this.#workspace.workspaceRoot, ".akan/subspace", `${this.name}-${label}.patch`);
|
|
616
|
+
await mkdir(path.dirname(patchPath), { recursive: true });
|
|
617
|
+
await FileSys.writeText(patchPath, await this.#git(["diff", range, "--", ...files]));
|
|
618
|
+
return { path: path.relative(this.#workspace.workspaceRoot, patchPath), absolute: patchPath, files };
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export function formatSubspaceStatuses(statuses: SubspaceStatus[]) {
|
|
623
|
+
const sections = [
|
|
624
|
+
"Akan Subspace Status",
|
|
625
|
+
`branch: ${statuses[0]?.branch ?? "(none)"}`,
|
|
626
|
+
"",
|
|
627
|
+
...statuses.flatMap((status) => {
|
|
628
|
+
if (!status.hasBranch) return [` ${status.name}: no such branch in the subspace — push is refused`];
|
|
629
|
+
const behind = status.behindPaths.length ? `${status.behindPaths.length} file(s) behind` : "up to date";
|
|
630
|
+
const incoming = status.anchor.incoming.length
|
|
631
|
+
? `${status.anchor.incoming.length} customer commit(s) to pull`
|
|
632
|
+
: "no customer commits";
|
|
633
|
+
const drift = status.driftedLibs.length ? ` DRIFTED LIBS: ${status.driftedLibs.join(", ")}` : null;
|
|
634
|
+
return [
|
|
635
|
+
` ${status.name}: ${behind}, ${incoming}`,
|
|
636
|
+
...status.anchor.incoming.map((commit) => ` ${commit.sha} ${commit.author} ${commit.subject}`),
|
|
637
|
+
...(drift ? [drift] : []),
|
|
638
|
+
];
|
|
639
|
+
}),
|
|
640
|
+
];
|
|
641
|
+
return sections.join("\n");
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export function formatSubspacePushResults(results: SubspacePushResult[]) {
|
|
645
|
+
const sections = [
|
|
646
|
+
"Akan Subspace Push",
|
|
647
|
+
"",
|
|
648
|
+
...results.flatMap((result) => {
|
|
649
|
+
const detail =
|
|
650
|
+
result.outcome === "pushed" ? `${result.commit} (${result.changedFiles} files)` : (result.reason ?? "");
|
|
651
|
+
const [first = "", ...rest] = detail.split("\n");
|
|
652
|
+
const pruned = result.prunedDependencies?.length
|
|
653
|
+
? [` ${result.prunedDependencies.length} unused root dependenc(ies) left out of package.json`]
|
|
654
|
+
: [];
|
|
655
|
+
return [
|
|
656
|
+
` ${result.outcome.padEnd(8)} ${result.name} ${first}`,
|
|
657
|
+
...rest.map((line) => ` ${line}`),
|
|
658
|
+
...pruned,
|
|
659
|
+
];
|
|
660
|
+
}),
|
|
661
|
+
];
|
|
662
|
+
return sections.join("\n");
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export function formatSubspacePullResult(result: SubspacePullResult) {
|
|
666
|
+
const sections = [
|
|
667
|
+
`Akan Subspace Pull — ${result.name}`,
|
|
668
|
+
"",
|
|
669
|
+
`Customer commits since the last push (${result.incoming.length}):`,
|
|
670
|
+
"",
|
|
671
|
+
...(result.incoming.length
|
|
672
|
+
? result.incoming.map((commit) => ` ${commit.sha} ${commit.author} ${commit.subject}`)
|
|
673
|
+
: [" (none — nothing to pull)"]),
|
|
674
|
+
"",
|
|
675
|
+
`Applied to the working tree, uncommitted (${result.applied.length}):`,
|
|
676
|
+
"",
|
|
677
|
+
...(result.applied.length ? result.applied.map((file) => ` ${file}`) : [" (none)"]),
|
|
678
|
+
...(result.libPatch
|
|
679
|
+
? [
|
|
680
|
+
"",
|
|
681
|
+
`Library edits NOT applied (${result.libPatch.files.length}) — review before adopting:`,
|
|
682
|
+
"",
|
|
683
|
+
...result.libPatch.files.map((file) => ` ${file}`),
|
|
684
|
+
"",
|
|
685
|
+
` patch: ${result.libPatch.path}`,
|
|
686
|
+
" adopt with: akan subspace pull <name> --adopt-libs",
|
|
687
|
+
]
|
|
688
|
+
: []),
|
|
689
|
+
...(result.ignored.length
|
|
690
|
+
? ["", `Ignored (workspace-owned or subspace-owned): ${result.ignored.length} file(s)`]
|
|
691
|
+
: []),
|
|
692
|
+
];
|
|
693
|
+
return sections.join("\n");
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export function formatSubspaceDiff(result: SubspaceDiffResult) {
|
|
697
|
+
const total = result.app.files.length + result.libs.files.length;
|
|
698
|
+
const sections = [
|
|
699
|
+
`Akan Subspace Diff — ${result.name} (${result.branch})`,
|
|
700
|
+
"what `akan subspace push` would change in the subspace",
|
|
701
|
+
"",
|
|
702
|
+
...(total ? [] : [" subspace is identical to the workspace for this slice."]),
|
|
703
|
+
...(result.libs.files.length
|
|
704
|
+
? [
|
|
705
|
+
`LIBRARY changes (${result.libs.files.length}) — the subspace edited shared code:`,
|
|
706
|
+
"",
|
|
707
|
+
...result.libs.files.map((file) => ` ${file}`),
|
|
708
|
+
"",
|
|
709
|
+
result.libs.patch,
|
|
710
|
+
]
|
|
711
|
+
: []),
|
|
712
|
+
...(result.app.files.length
|
|
713
|
+
? [
|
|
714
|
+
`App changes (${result.app.files.length}):`,
|
|
715
|
+
"",
|
|
716
|
+
...result.app.files.map((file) => ` ${file}`),
|
|
717
|
+
"",
|
|
718
|
+
result.app.patch,
|
|
719
|
+
]
|
|
720
|
+
: []),
|
|
721
|
+
];
|
|
722
|
+
return sections.join("\n");
|
|
723
|
+
}
|