@crombolo/server 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +432 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundles.ts +508 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +548 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +109 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +62 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +297 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1579 -0
- package/src/status.ts +246 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- package/README.md +0 -3
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub, over its REST API.
|
|
3
|
+
*
|
|
4
|
+
* A commit is built the long way — blobs, a tree, a commit, then moving a ref —
|
|
5
|
+
* rather than with the contents API, because that writes one file per request and
|
|
6
|
+
* a schema change is usually several. Built this way the whole change is one
|
|
7
|
+
* commit, which is what a reviewer wants to see, and a partly-applied change is
|
|
8
|
+
* not a state that can happen.
|
|
9
|
+
*
|
|
10
|
+
* Needs a token with `contents: write`, and `pull_requests: write` to raise a PR.
|
|
11
|
+
* A fine-grained personal access token or a GitHub App installation token both
|
|
12
|
+
* work; the token is the CMS's own identity, so its commits are attributable to
|
|
13
|
+
* the CMS rather than to whoever was signed in.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
diffFiles,
|
|
17
|
+
type GitDiff,
|
|
18
|
+
type GitProvider,
|
|
19
|
+
type PushResult,
|
|
20
|
+
type RepositoryFile,
|
|
21
|
+
} from './git.ts'
|
|
22
|
+
|
|
23
|
+
export interface GitHubOptions {
|
|
24
|
+
/** `owner/repo`. */
|
|
25
|
+
repository: string
|
|
26
|
+
token: string
|
|
27
|
+
/** The branch to compare against and commit to. Default `main`. */
|
|
28
|
+
branch?: string
|
|
29
|
+
/** For GitHub Enterprise; default is github.com's API. */
|
|
30
|
+
api?: string
|
|
31
|
+
/** The commit author; defaults to a bot identity naming the site. */
|
|
32
|
+
author?: { name: string; email: string }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface TreeEntry {
|
|
36
|
+
path: string
|
|
37
|
+
mode: '100644'
|
|
38
|
+
type: 'blob'
|
|
39
|
+
sha: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function gitHub(options: GitHubOptions): GitProvider {
|
|
43
|
+
const api = (options.api ?? 'https://api.github.com').replace(/\/$/, '')
|
|
44
|
+
const branch = options.branch ?? 'main'
|
|
45
|
+
const base = `${api}/repos/${options.repository}`
|
|
46
|
+
const author = options.author ?? { name: 'crombolo', email: 'crombolo@users.noreply.github.com' }
|
|
47
|
+
|
|
48
|
+
const call = async <T>(path: string, init: RequestInit = {}): Promise<T> => {
|
|
49
|
+
const response = await fetch(`${base}${path}`, {
|
|
50
|
+
...init,
|
|
51
|
+
headers: {
|
|
52
|
+
accept: 'application/vnd.github+json',
|
|
53
|
+
authorization: `Bearer ${options.token}`,
|
|
54
|
+
'x-github-api-version': '2022-11-28',
|
|
55
|
+
...(init.body ? { 'content-type': 'application/json' } : {}),
|
|
56
|
+
...init.headers,
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
const detail = (await response.text()).slice(0, 300)
|
|
61
|
+
throw new Error(
|
|
62
|
+
`GitHub ${init.method ?? 'GET'} ${path} failed (${response.status}): ${detail}`,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
return (await response.json()) as T
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
description: `github:${options.repository}`,
|
|
70
|
+
branch,
|
|
71
|
+
|
|
72
|
+
async read(prefix) {
|
|
73
|
+
const ref = await call<{ object: { sha: string } }>(`/git/ref/heads/${branch}`)
|
|
74
|
+
const head = ref.object.sha
|
|
75
|
+
// One recursive call for the whole tree beats walking it a directory at a time.
|
|
76
|
+
const tree = await call<{ tree: { path: string; type: string; sha: string }[] }>(
|
|
77
|
+
`/git/trees/${head}?recursive=1`,
|
|
78
|
+
)
|
|
79
|
+
const wanted = tree.tree.filter(
|
|
80
|
+
(entry) => entry.type === 'blob' && entry.path.startsWith(`${prefix}/`),
|
|
81
|
+
)
|
|
82
|
+
const files: RepositoryFile[] = []
|
|
83
|
+
for (const entry of wanted) {
|
|
84
|
+
const blob = await call<{ content: string; encoding: string }>(`/git/blobs/${entry.sha}`)
|
|
85
|
+
const contents =
|
|
86
|
+
blob.encoding === 'base64'
|
|
87
|
+
? new TextDecoder().decode(
|
|
88
|
+
Uint8Array.from(atob(blob.content.replace(/\n/g, '')), (c) => c.charCodeAt(0)),
|
|
89
|
+
)
|
|
90
|
+
: blob.content
|
|
91
|
+
files.push({ path: entry.path, contents })
|
|
92
|
+
}
|
|
93
|
+
return { head, files }
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async commit({ prefix, files, message, pullRequest }) {
|
|
97
|
+
const ref = await call<{ object: { sha: string } }>(`/git/ref/heads/${branch}`)
|
|
98
|
+
const head = ref.object.sha
|
|
99
|
+
const headCommit = await call<{ tree: { sha: string } }>(`/git/commits/${head}`)
|
|
100
|
+
|
|
101
|
+
const entries: TreeEntry[] = []
|
|
102
|
+
for (const file of files) {
|
|
103
|
+
const blob = await call<{ sha: string }>('/git/blobs', {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
body: JSON.stringify({ content: file.contents, encoding: 'utf-8' }),
|
|
106
|
+
})
|
|
107
|
+
entries.push({ path: file.path, mode: '100644', type: 'blob', sha: blob.sha })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Files the site no longer has are deleted by giving them a null sha, which
|
|
111
|
+
// is how the tree API spells "remove this path".
|
|
112
|
+
const existing = await call<{ tree: { path: string; type: string }[] }>(
|
|
113
|
+
`/git/trees/${head}?recursive=1`,
|
|
114
|
+
)
|
|
115
|
+
const keeping = new Set(files.map((file) => file.path))
|
|
116
|
+
const removals = existing.tree
|
|
117
|
+
.filter((entry) => entry.type === 'blob' && entry.path.startsWith(`${prefix}/`))
|
|
118
|
+
.filter((entry) => !keeping.has(entry.path))
|
|
119
|
+
.map((entry) => ({ path: entry.path, mode: '100644', type: 'blob', sha: null }))
|
|
120
|
+
|
|
121
|
+
const tree = await call<{ sha: string }>('/git/trees', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
body: JSON.stringify({ base_tree: headCommit.tree.sha, tree: [...entries, ...removals] }),
|
|
124
|
+
})
|
|
125
|
+
const commit = await call<{ sha: string }>('/git/commits', {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
body: JSON.stringify({ message, tree: tree.sha, parents: [head], author }),
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
if (!pullRequest) {
|
|
131
|
+
await call(`/git/refs/heads/${branch}`, {
|
|
132
|
+
method: 'PATCH',
|
|
133
|
+
body: JSON.stringify({ sha: commit.sha }),
|
|
134
|
+
})
|
|
135
|
+
return { branch, commit: commit.sha }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const topic = `crombolo/schema-${commit.sha.slice(0, 8)}`
|
|
139
|
+
await call('/git/refs', {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
body: JSON.stringify({ ref: `refs/heads/${topic}`, sha: commit.sha }),
|
|
142
|
+
})
|
|
143
|
+
const raised = await call<{ number: number; html_url: string }>('/pulls', {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
title: pullRequest.title,
|
|
147
|
+
body: pullRequest.body,
|
|
148
|
+
head: topic,
|
|
149
|
+
base: branch,
|
|
150
|
+
}),
|
|
151
|
+
})
|
|
152
|
+
return {
|
|
153
|
+
branch: topic,
|
|
154
|
+
commit: commit.sha,
|
|
155
|
+
pullRequest: { number: raised.number, url: raised.html_url },
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The live files against the repository, for the backoffice to show before sending. */
|
|
162
|
+
export async function diffAgainst(
|
|
163
|
+
provider: GitProvider,
|
|
164
|
+
prefix: string,
|
|
165
|
+
local: readonly RepositoryFile[],
|
|
166
|
+
): Promise<GitDiff> {
|
|
167
|
+
const { head, files } = await provider.read(prefix)
|
|
168
|
+
return { branch: provider.branch, head, changes: diffFiles(files, local) }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type { PushResult }
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The backoffice's view of source control: what the live schema has that the
|
|
3
|
+
* repository does not, and a way to send it.
|
|
4
|
+
*
|
|
5
|
+
* Mounted only when `git` is configured. Every route needs a signed-in user, and
|
|
6
|
+
* sending is a deliberate act — nothing here happens on a timer, because a commit
|
|
7
|
+
* nobody asked for is a commit nobody reviewed.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from 'node:fs'
|
|
10
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
11
|
+
import { join, relative, sep } from 'node:path'
|
|
12
|
+
import type { Principal } from '@crombolo/api-management'
|
|
13
|
+
import { diffFiles, type GitProvider, type RepositoryFile } from './git.ts'
|
|
14
|
+
import { logger } from './logging.ts'
|
|
15
|
+
|
|
16
|
+
const log = logger('Git')
|
|
17
|
+
|
|
18
|
+
export interface GitConfig {
|
|
19
|
+
provider: GitProvider
|
|
20
|
+
/**
|
|
21
|
+
* Where the schema lives in the repository. The live directory is a cache when a
|
|
22
|
+
* schema store is configured, so its path is not the repository's.
|
|
23
|
+
*/
|
|
24
|
+
schemaPath?: string
|
|
25
|
+
/**
|
|
26
|
+
* Raise a pull request rather than committing to the branch. On by default: a
|
|
27
|
+
* schema change arriving as a PR is reviewable, and one arriving as a commit on
|
|
28
|
+
* main is not.
|
|
29
|
+
*/
|
|
30
|
+
pullRequest?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface GitHost {
|
|
34
|
+
handle(request: Request, pathname: string, principal: Principal): Promise<Response | undefined>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GitHostOptions {
|
|
38
|
+
config: GitConfig
|
|
39
|
+
/** The directory the live schema is loaded from. */
|
|
40
|
+
schemaDir: string
|
|
41
|
+
siteName: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Every `.toml` under `dir`, as repository paths. */
|
|
45
|
+
async function localSchema(dir: string, prefix: string): Promise<RepositoryFile[]> {
|
|
46
|
+
if (!existsSync(dir)) return []
|
|
47
|
+
const files: RepositoryFile[] = []
|
|
48
|
+
const walk = async (at: string): Promise<void> => {
|
|
49
|
+
for (const entry of await readdir(at, { withFileTypes: true })) {
|
|
50
|
+
const path = join(at, entry.name)
|
|
51
|
+
if (entry.isDirectory()) await walk(path)
|
|
52
|
+
else if (entry.name.toLowerCase().endsWith('.toml')) {
|
|
53
|
+
files.push({
|
|
54
|
+
path: `${prefix}/${relative(dir, path).split(sep).join('/')}`,
|
|
55
|
+
contents: await readFile(path, 'utf8'),
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
await walk(dir)
|
|
61
|
+
return files.sort((a, b) => a.path.localeCompare(b.path))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createGitHost(options: GitHostOptions): GitHost {
|
|
65
|
+
const prefix = (options.config.schemaPath ?? 'schema').replace(/^\/+|\/+$/g, '')
|
|
66
|
+
const provider = options.config.provider
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
async handle(request, pathname, principal) {
|
|
70
|
+
if (pathname === '/status' && request.method === 'GET') {
|
|
71
|
+
return Response.json({
|
|
72
|
+
provider: provider.description,
|
|
73
|
+
branch: provider.branch,
|
|
74
|
+
schemaPath: prefix,
|
|
75
|
+
raisesPullRequest: options.config.pullRequest !== false,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (pathname === '/diff' && request.method === 'GET') {
|
|
80
|
+
const local = await localSchema(options.schemaDir, prefix)
|
|
81
|
+
const { head, files } = await provider.read(prefix)
|
|
82
|
+
return Response.json({
|
|
83
|
+
branch: provider.branch,
|
|
84
|
+
head,
|
|
85
|
+
changes: diffFiles(files, local),
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (pathname === '/commit' && request.method === 'POST') {
|
|
90
|
+
const body = (await request.json().catch(() => ({}))) as {
|
|
91
|
+
message?: unknown
|
|
92
|
+
description?: unknown
|
|
93
|
+
}
|
|
94
|
+
const local = await localSchema(options.schemaDir, prefix)
|
|
95
|
+
const { files } = await provider.read(prefix)
|
|
96
|
+
const changes = diffFiles(files, local)
|
|
97
|
+
if (changes.length === 0) {
|
|
98
|
+
return Response.json(
|
|
99
|
+
{ error: 'The repository already matches this site’s schema.' },
|
|
100
|
+
{ status: 409 },
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const message =
|
|
105
|
+
typeof body.message === 'string' && body.message.trim() !== ''
|
|
106
|
+
? body.message.trim()
|
|
107
|
+
: `Schema changes from ${options.siteName}`
|
|
108
|
+
const description =
|
|
109
|
+
typeof body.description === 'string' ? body.description : summarise(changes)
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const result = await provider.commit({
|
|
113
|
+
prefix,
|
|
114
|
+
files: local,
|
|
115
|
+
message,
|
|
116
|
+
pullRequest:
|
|
117
|
+
options.config.pullRequest === false
|
|
118
|
+
? undefined
|
|
119
|
+
: { title: message, body: description },
|
|
120
|
+
})
|
|
121
|
+
log.info('{user} sent {count} schema change(s) to {provider} as {what}', {
|
|
122
|
+
user: principal.userName,
|
|
123
|
+
count: changes.length,
|
|
124
|
+
provider: provider.description,
|
|
125
|
+
what: result.pullRequest
|
|
126
|
+
? `PR #${result.pullRequest.number}`
|
|
127
|
+
: `commit ${result.commit.slice(0, 8)}`,
|
|
128
|
+
})
|
|
129
|
+
return Response.json(result)
|
|
130
|
+
} catch (error) {
|
|
131
|
+
log.error('Could not send schema to {provider}: {message}', {
|
|
132
|
+
provider: provider.description,
|
|
133
|
+
message: (error as Error).message,
|
|
134
|
+
})
|
|
135
|
+
return Response.json({ error: (error as Error).message }, { status: 502 })
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return undefined
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A plain description of the change, which becomes the pull request's body. */
|
|
145
|
+
function summarise(changes: readonly { path: string; status: string }[]): string {
|
|
146
|
+
const lines = changes.map((change) => `- ${change.status}: ${change.path}`)
|
|
147
|
+
return [
|
|
148
|
+
'Schema changed in the backoffice and sent from there.',
|
|
149
|
+
'',
|
|
150
|
+
...lines,
|
|
151
|
+
'',
|
|
152
|
+
'Review as you would any schema change: these files are what the next deploy syncs.',
|
|
153
|
+
].join('\n')
|
|
154
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Getting schema changes made on a running site back into source control,
|
|
3
|
+
* without an editor ever meeting git.
|
|
4
|
+
*
|
|
5
|
+
* Optional, like everything else here: absent from the configuration there is no
|
|
6
|
+
* route and no credentials anywhere. Configured, the backoffice can show what the
|
|
7
|
+
* live schema has that the repository does not, and send it — as a commit on a
|
|
8
|
+
* branch, or as a pull request for somebody to review.
|
|
9
|
+
*
|
|
10
|
+
* Over the provider's HTTP API rather than a `git` binary, because the CMS image
|
|
11
|
+
* has no git and should not grow one, and because a shallow clone per diff to
|
|
12
|
+
* compare a handful of TOML files would be absurd. `GitProvider` is the seam;
|
|
13
|
+
* GitHub is the implementation that ships.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface RepositoryFile {
|
|
17
|
+
/** Repository-relative, e.g. `schema/content-types/home-page.toml`. */
|
|
18
|
+
path: string
|
|
19
|
+
contents: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type FileChange =
|
|
23
|
+
| { path: string; status: 'added'; local: string; remote: undefined }
|
|
24
|
+
| { path: string; status: 'modified'; local: string; remote: string }
|
|
25
|
+
| { path: string; status: 'removed'; local: undefined; remote: string }
|
|
26
|
+
|
|
27
|
+
export interface GitDiff {
|
|
28
|
+
branch: string
|
|
29
|
+
/** The commit the comparison was made against. */
|
|
30
|
+
head: string
|
|
31
|
+
changes: FileChange[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PushResult {
|
|
35
|
+
/** The branch the commit landed on. */
|
|
36
|
+
branch: string
|
|
37
|
+
commit: string
|
|
38
|
+
/** Present when a pull request was opened. */
|
|
39
|
+
pullRequest?: { number: number; url: string }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface GitProvider {
|
|
43
|
+
/** Named in logs and in the backoffice, e.g. `github:kineco-au/site`. */
|
|
44
|
+
readonly description: string
|
|
45
|
+
/** The branch changes are compared against and sent to. */
|
|
46
|
+
readonly branch: string
|
|
47
|
+
/** Every file under `prefix`, at the head of `branch`. */
|
|
48
|
+
read(prefix: string): Promise<{ head: string; files: RepositoryFile[] }>
|
|
49
|
+
/** Commits `files`, replacing what is under `prefix`, and optionally raises a PR. */
|
|
50
|
+
commit(input: {
|
|
51
|
+
prefix: string
|
|
52
|
+
files: readonly RepositoryFile[]
|
|
53
|
+
message: string
|
|
54
|
+
/** Raise a pull request instead of committing straight to `branch`. */
|
|
55
|
+
pullRequest?: { title: string; body: string }
|
|
56
|
+
}): Promise<PushResult>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What `git status` would say, comparing the repository against what is on disk now. */
|
|
60
|
+
export function diffFiles(
|
|
61
|
+
remote: readonly RepositoryFile[],
|
|
62
|
+
local: readonly RepositoryFile[],
|
|
63
|
+
): FileChange[] {
|
|
64
|
+
const byPath = new Map(remote.map((file) => [file.path, file.contents]))
|
|
65
|
+
const changes: FileChange[] = []
|
|
66
|
+
|
|
67
|
+
for (const file of local) {
|
|
68
|
+
const previous = byPath.get(file.path)
|
|
69
|
+
if (previous === undefined) {
|
|
70
|
+
changes.push({ path: file.path, status: 'added', local: file.contents, remote: undefined })
|
|
71
|
+
} else if (previous !== file.contents) {
|
|
72
|
+
changes.push({
|
|
73
|
+
path: file.path,
|
|
74
|
+
status: 'modified',
|
|
75
|
+
local: file.contents,
|
|
76
|
+
remote: previous,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const here = new Set(local.map((file) => file.path))
|
|
82
|
+
for (const file of remote) {
|
|
83
|
+
if (here.has(file.path)) continue
|
|
84
|
+
changes.push({ path: file.path, status: 'removed', local: undefined, remote: file.contents })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return changes.sort((a, b) => a.path.localeCompare(b.path))
|
|
88
|
+
}
|