@kubohiroya/turbowarp-plus-editor-git 0.16.1 → 0.18.0
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.ja.md +18 -2
- package/README.md +17 -1
- package/dist/branch-button.d.ts +32 -0
- package/dist/filesystem.d.ts +2 -2
- package/dist/github-pull-requests.d.ts +20 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.js +739 -158
- package/dist/local-operations.d.ts +8 -1
- package/dist/messages.d.ts +16 -2
- package/dist/{project-asset-paths-KV715791.js → project-asset-paths-BzwVf9HE.js} +653 -1
- package/dist/{project-comparison-1rggIJse.js → project-comparison-BXOQcZ73.js} +10 -654
- package/dist/project-source-diff.js +2 -1
- package/dist/pull-request-panel.d.ts +25 -1
- package/dist/remote-operations.d.ts +23 -0
- package/dist/{repository-BYno-5f8.js → repository-ByKRBftJ.js} +370 -14
- package/dist/repository.d.ts +39 -0
- package/dist/repository.js +1 -1
- package/dist/style.css +28 -0
- package/package.json +1 -1
- package/src/branch-button.tsx +216 -0
- package/src/filesystem.ts +2 -1
- package/src/github-pull-requests.ts +7 -0
- package/src/index.tsx +213 -67
- package/src/local-operations.ts +23 -3
- package/src/messages.ts +14 -4
- package/src/pull-request-panel.tsx +125 -14
- package/src/remote-operations.ts +84 -3
- package/src/repository.ts +155 -9
- package/src/style.css +28 -0
- package/test/smart-http-fixture.js +20 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, {useEffect, useMemo, useState} from 'react';
|
|
1
|
+
import React, {useEffect, useMemo, useRef, useState} from 'react';
|
|
2
2
|
import {createGitHubPullRequests, gitHubRepositoryFromOrigin, PullRequestError} from './github-pull-requests.js';
|
|
3
3
|
import type {GitHubPullRequests} from './github-pull-requests.js';
|
|
4
4
|
import {ProjectSourceReview} from './project-source-review.js';
|
|
@@ -7,6 +7,10 @@ import type {Repository} from './repository.js';
|
|
|
7
7
|
import type {ProjectBlockLocator} from './block-diff-location.js';
|
|
8
8
|
import {readProjectSourceDiff} from './project-source-patch.js';
|
|
9
9
|
import type {ProjectSourceTabsFile} from './project-source-tabs.js';
|
|
10
|
+
import {GitPanelError} from './filesystem.js';
|
|
11
|
+
import type {GetRemoteSession} from './remote-operations.js';
|
|
12
|
+
import type {ProjectSourceReviewFile} from './project-source-review.js';
|
|
13
|
+
import {pullRequestBranch} from './local-operations.js';
|
|
10
14
|
|
|
11
15
|
type OpenTabs = (key: string, files: readonly ProjectSourceTabsFile[], rememberAs?: string) => number;
|
|
12
16
|
|
|
@@ -20,7 +24,19 @@ const en = {
|
|
|
20
24
|
rateLimit: 'The public GitHub API rate limit was reached. Try again after the limit resets.',
|
|
21
25
|
failed: 'Could not read GitHub. Try again.', changed: 'The pull request changed while it was being read. Open it again.',
|
|
22
26
|
tooManyFiles: 'GitHub only returns the first 3,000 changed files. This pull request exceeds that limit.',
|
|
23
|
-
openTabs: 'Open as tabs'
|
|
27
|
+
openTabs: 'Open as tabs',
|
|
28
|
+
session: 'Connect to GitHub again (GitHub connection) to read this pull request.',
|
|
29
|
+
forbidden: 'The GitHub connection is not allowed to read this repository.',
|
|
30
|
+
pullRequestNotFound: 'This repository has no pull request with this number.',
|
|
31
|
+
network: 'Could not reach GitHub through the connection. Check the network and try again.',
|
|
32
|
+
remoteFailed: 'Could not read the pull request through the GitHub connection. Try again.',
|
|
33
|
+
baseMissing: 'The base branch is not on GitHub. Choose another base branch.',
|
|
34
|
+
viaConnection: 'Read through the GitHub connection.',
|
|
35
|
+
listPrivate: 'The list uses the public GitHub API. Enter a pull request number to read it through the connection.',
|
|
36
|
+
noMetadata: 'The title and base branch are not available without the public GitHub API. Choose the base branch to compare with.',
|
|
37
|
+
base: 'Base branch', pullRequest: 'Pull request',
|
|
38
|
+
openLocally: 'Open here as {branch}',
|
|
39
|
+
openLocallyHint: 'Creates the local branch {branch} at this version, switches to it and opens the project, so it can be run. Unsaved edits and unrecorded changes stop it. Switch back in Local changes.'
|
|
24
40
|
};
|
|
25
41
|
const ja: typeof en = {
|
|
26
42
|
title: 'プルリクエスト', number: 'プルリクエスト番号', openNumber: 'PR を開く',
|
|
@@ -33,33 +49,76 @@ const ja: typeof en = {
|
|
|
33
49
|
failed: 'GitHub から取得できませんでした。再試行してください。',
|
|
34
50
|
changed: '取得中にプルリクエストが更新されました。もう一度開いてください。',
|
|
35
51
|
tooManyFiles: '変更ファイルが GitHub API の取得上限である 3,000 件を超えています。',
|
|
36
|
-
openTabs: 'タブで開く'
|
|
52
|
+
openTabs: 'タブで開く',
|
|
53
|
+
session: 'この PR を読むには、GitHub 接続でもう一度接続してください。',
|
|
54
|
+
forbidden: 'GitHub 接続に、このリポジトリを読む権限がありません。',
|
|
55
|
+
pullRequestNotFound: 'このリポジトリに、この番号のプルリクエストはありません。',
|
|
56
|
+
network: 'GitHub 接続で GitHub に届きませんでした。ネットワークを確認して、もう一度試してください。',
|
|
57
|
+
remoteFailed: 'GitHub 接続でプルリクエストを読めませんでした。もう一度試してください。',
|
|
58
|
+
baseMissing: 'ベースのブランチが GitHub にありません。別のベースを選んでください。',
|
|
59
|
+
viaConnection: 'GitHub 接続で取得しました。',
|
|
60
|
+
listPrivate: '一覧は公開 GitHub API を使います。番号を入れると、GitHub 接続で読みます。',
|
|
61
|
+
noMetadata: '公開 GitHub API を使えないため、題名とベースのブランチがわかりません。比べるベースのブランチを選んでください。',
|
|
62
|
+
base: 'ベースのブランチ', pullRequest: 'プルリクエスト',
|
|
63
|
+
openLocally: '{branch} として手元で開く',
|
|
64
|
+
openLocallyHint: 'この版にローカルブランチ {branch} を作って切り替え、作品を開き直して実行できるようにします。保存していない編集や記録していない変更があるときは行いません。元に戻すときは「ローカルの変更」で切り替えてください。'
|
|
37
65
|
};
|
|
38
66
|
|
|
67
|
+
const gitErrors = ['session', 'forbidden', 'pullRequestNotFound', 'network', 'remoteFailed', 'baseMissing', 'changed'] as const;
|
|
39
68
|
function errorMessage(error: unknown, labels: typeof en) {
|
|
40
|
-
|
|
69
|
+
if (error instanceof PullRequestError) return labels[error.code];
|
|
70
|
+
if (error instanceof GitPanelError) {
|
|
71
|
+
const code = gitErrors.find(code => code === error.code);
|
|
72
|
+
return code ? labels[code] : labels.failed;
|
|
73
|
+
}
|
|
74
|
+
return labels.failed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Opens a pull request version fetched over the connection in the work tree: the host creates (or reuses) the local
|
|
79
|
+
* branch `pullRequestBranch(number)` at `head` and switches to it. `busy` disables the button.
|
|
80
|
+
*/
|
|
81
|
+
export interface PullRequestOpenLocally {
|
|
82
|
+
open(number: number, head: string): unknown;
|
|
83
|
+
busy: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The person's GitHub connection: while it is connected, pull request contents are read over Git Smart HTTP. */
|
|
87
|
+
export interface PullRequestConnection {
|
|
88
|
+
getSession: GetRemoteSession;
|
|
89
|
+
connected(): boolean;
|
|
41
90
|
}
|
|
42
91
|
|
|
43
92
|
type Result<T> = {data: T; error?: never} | {data?: never; error: string};
|
|
44
93
|
type Listing = Awaited<ReturnType<GitHubPullRequests['list']>>;
|
|
45
|
-
type
|
|
94
|
+
type Metadata = Awaited<ReturnType<GitHubPullRequests['metadata']>>;
|
|
95
|
+
/** A pull request to show: from the public API, or from Git objects fetched over the connection. */
|
|
96
|
+
type Pull = {
|
|
97
|
+
number: number; title: string; description: string; author?: string; state?: Metadata['state'];
|
|
98
|
+
head: {sha: string; ref: string}; base: {ref: string | null; sha: string};
|
|
99
|
+
files: readonly ProjectSourceReviewFile[]; url: string;
|
|
100
|
+
/** Set when the contents came over the connection: the branches to compare with, when the API gave no base. */
|
|
101
|
+
git?: {branches: readonly string[] | null};
|
|
102
|
+
};
|
|
46
103
|
|
|
47
|
-
function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs}: {
|
|
104
|
+
function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs, repository, connection, openLocally}: {
|
|
48
105
|
name: string; labels: typeof en; locale: string; renderBlock: ProjectBlockRenderer;
|
|
49
106
|
locateFor?(path: string, headSha: string): ProjectBlockLocator | undefined;
|
|
50
|
-
openTabs?: OpenTabs;
|
|
107
|
+
openTabs?: OpenTabs; repository: Repository; connection?: PullRequestConnection; openLocally?: PullRequestOpenLocally;
|
|
51
108
|
}) {
|
|
52
109
|
const client = useMemo(() => createGitHubPullRequests(name), [name]);
|
|
53
110
|
const [page, setPage] = useState(1);
|
|
54
111
|
const [listing, setListing] = useState<{page: number; result: Result<Listing>}>();
|
|
55
112
|
const [input, setInput] = useState('');
|
|
56
|
-
const [selection, setSelection] = useState<{number: number}>();
|
|
113
|
+
const [selection, setSelection] = useState<{number: number; base?: string}>();
|
|
57
114
|
const [loaded, setLoaded] = useState<{selection: NonNullable<typeof selection>; result: Result<Pull>}>();
|
|
58
115
|
const list = listing?.page === page ? listing.result : undefined;
|
|
116
|
+
const [listConnected, setListConnected] = useState(false);
|
|
59
117
|
const detail = loaded?.selection === selection ? loaded?.result : undefined;
|
|
60
118
|
useEffect(() => {
|
|
61
119
|
const controller = new AbortController();
|
|
62
120
|
setListing(undefined);
|
|
121
|
+
setListConnected(!!connection?.connected());
|
|
63
122
|
void client.list(page, controller.signal).then(data => {
|
|
64
123
|
if (!controller.signal.aborted) setListing({page, result: {data}});
|
|
65
124
|
}, error => {
|
|
@@ -70,13 +129,44 @@ function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs}:
|
|
|
70
129
|
useEffect(() => {
|
|
71
130
|
if (!selection) return;
|
|
72
131
|
const controller = new AbortController();
|
|
73
|
-
|
|
132
|
+
const load = (): Promise<Pull> => connection?.connected() ?
|
|
133
|
+
loadThroughConnection(selection.number, selection.base, controller.signal) : client.load(selection.number, controller.signal);
|
|
134
|
+
void load().then(data => {
|
|
74
135
|
if (!controller.signal.aborted) setLoaded({selection, result: {data}});
|
|
75
136
|
}, error => {
|
|
76
137
|
if (!controller.signal.aborted) setLoaded({selection, result: {error: errorMessage(error, labels)}});
|
|
77
138
|
});
|
|
78
139
|
return () => controller.abort();
|
|
79
140
|
}, [client, selection, labels]);
|
|
141
|
+
const fetched = useRef<{number: number; head: string; branches: readonly string[]; defaultBranch: string | null}>();
|
|
142
|
+
/**
|
|
143
|
+
* Fetches `refs/pull/<number>/head` and the branches over Git Smart HTTP and compares them from local objects. The
|
|
144
|
+
* public API only adds the title, description and base when it can read the repository; the contents never come
|
|
145
|
+
* from it, so a private repository is read only through the connection.
|
|
146
|
+
*/
|
|
147
|
+
const loadThroughConnection = async (number: number, chosenBase: string | undefined, signal: AbortSignal): Promise<Pull> => {
|
|
148
|
+
const guard = () => {if (signal.aborted) throw new GitPanelError('changed');};
|
|
149
|
+
let head: string, branches: readonly string[], defaultBranch: string | null, metadata: Metadata | null = null;
|
|
150
|
+
if (chosenBase !== undefined && fetched.current?.number === number) {
|
|
151
|
+
({head, branches, defaultBranch} = fetched.current);
|
|
152
|
+
} else {
|
|
153
|
+
metadata = await client.metadata(number, signal).catch(error => {
|
|
154
|
+
if (signal.aborted) throw error;
|
|
155
|
+
return null;
|
|
156
|
+
});
|
|
157
|
+
const result = await repository.fetchPullRequest(number, connection!.getSession, guard, signal);
|
|
158
|
+
if (metadata && metadata.head.sha !== result.head) throw new PullRequestError('changed');
|
|
159
|
+
({head, branches, defaultBranch} = result);
|
|
160
|
+
fetched.current = {number, head, branches, defaultBranch};
|
|
161
|
+
}
|
|
162
|
+
const branch = chosenBase ?? metadata?.base.ref ?? defaultBranch ?? branches[0];
|
|
163
|
+
const read = await repository.readPullRequest(head, {sha: metadata?.base.sha, branch});
|
|
164
|
+
guard();
|
|
165
|
+
return {number, title: metadata?.title ?? `${labels.pullRequest} #${number}`, description: metadata?.description ?? '',
|
|
166
|
+
author: metadata?.author, state: metadata?.state, head: {sha: head, ref: metadata?.head.ref ?? `pull/${number}/head`},
|
|
167
|
+
base: read.base, files: read.files, url: 'https://github.com/' + name + '/pull/' + number,
|
|
168
|
+
git: {branches: metadata ? null : branches}};
|
|
169
|
+
};
|
|
80
170
|
const choose = (number: number) => {
|
|
81
171
|
if (!Number.isSafeInteger(number) || number < 1) return;
|
|
82
172
|
setSelection({number});
|
|
@@ -93,7 +183,8 @@ function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs}:
|
|
|
93
183
|
<button type="submit">{labels.openNumber}</button>
|
|
94
184
|
</form>
|
|
95
185
|
{!list && <p role="status">{labels.loading}</p>}
|
|
96
|
-
{list?.error &&
|
|
186
|
+
{list?.error && (listConnected && list.error === labels.unavailable ?
|
|
187
|
+
<p role="status">{labels.listPrivate}</p> : <p role="alert">{list.error}</p>)}
|
|
97
188
|
{list?.data && <>
|
|
98
189
|
{!list.data.items.length && <p>{labels.empty}</p>}
|
|
99
190
|
<ul>{list.data.items.map(item => <li key={item.number}>
|
|
@@ -112,9 +203,13 @@ function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs}:
|
|
|
112
203
|
{detail?.error && <p role="alert">{detail.error}</p>}
|
|
113
204
|
{pr && <>
|
|
114
205
|
<p className="twgit-pr-revision">
|
|
115
|
-
<strong>#{pr.number} · {labels[pr.state]}</strong>
|
|
206
|
+
<strong>#{pr.number}{pr.state && <> · {labels[pr.state]}</>}</strong>
|
|
116
207
|
{pr.author && <span>{pr.author}</span>}
|
|
117
|
-
<code title={pr.head.sha}>{pr.head.ref} → {pr.base.ref}</code>
|
|
208
|
+
<code title={pr.head.sha}>{pr.head.ref} → {pr.base.ref ?? pr.base.sha.slice(0, 12)}</code>
|
|
209
|
+
{pr.git?.branches && <label>{labels.base} <select data-git-pr-base="" value={pr.base.ref ?? ''}
|
|
210
|
+
onChange={event => setSelection({number: pr.number, base: event.currentTarget.value})}>
|
|
211
|
+
{pr.git.branches.map(branch => <option key={branch} value={branch}>{branch}</option>)}
|
|
212
|
+
</select></label>}
|
|
118
213
|
<a href={pr.url} target="_blank" rel="noreferrer">{labels.onGitHub}</a>
|
|
119
214
|
{openTabs && <button type="button" data-git-open-tabs="" onClick={() => openTabs(`${name}#${pr.number}@${pr.head.sha}`,
|
|
120
215
|
pr.files.flatMap(file => {
|
|
@@ -128,7 +223,12 @@ function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs}:
|
|
|
128
223
|
return projects ? [{path: file.path, before: projects[0], after: projects[1], loadAsset: file.loadAsset,
|
|
129
224
|
locateBlock: locateFor?.(file.path, pr.head.sha)}] : [];
|
|
130
225
|
}), `${name}#${pr.number}`)}>{labels.openTabs}</button>}
|
|
226
|
+
{pr.git && openLocally && <button type="button" data-git-open-pr-locally="" disabled={openLocally.busy}
|
|
227
|
+
title={labels.openLocallyHint.replace('{branch}', pullRequestBranch(pr.number))}
|
|
228
|
+
onClick={() => void openLocally.open(pr.number, pr.head.sha)}>
|
|
229
|
+
{labels.openLocally.replace('{branch}', pullRequestBranch(pr.number))}</button>}
|
|
131
230
|
</p>
|
|
231
|
+
{pr.git && <p className="twgit-context" role="status">{labels.viaConnection}{pr.git.branches && <> {labels.noMetadata}</>}</p>}
|
|
132
232
|
<ProjectSourceReview title={pr.title} description={pr.description} files={locateFor ? pr.files.map(file => 'patch' in file ? {...file, locateBlock: locateFor(file.path, pr.head.sha)} : file) : pr.files}
|
|
133
233
|
renderBlock={renderBlock} locale={locale} rememberAs={`${name}#${pr.number}`}/>
|
|
134
234
|
</>}
|
|
@@ -136,8 +236,18 @@ function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs}:
|
|
|
136
236
|
</div>;
|
|
137
237
|
}
|
|
138
238
|
|
|
139
|
-
export function PullRequestReviewPanel({repository, locale, renderBlock, locateFor, openTabs}: {
|
|
239
|
+
export function PullRequestReviewPanel({repository, locale, renderBlock, locateFor, openTabs, connection, openLocally}: {
|
|
140
240
|
repository: Repository; locale: string; renderBlock: ProjectBlockRenderer;
|
|
241
|
+
/**
|
|
242
|
+
* While connected, pull request contents are fetched over Git Smart HTTP (`refs/pull/<number>/head`) and compared
|
|
243
|
+
* from local Git objects, so private repositories can be reviewed. Without it, the public GitHub API is read.
|
|
244
|
+
*/
|
|
245
|
+
connection?: PullRequestConnection;
|
|
246
|
+
/**
|
|
247
|
+
* Offers to open a pull request read over the connection in the work tree, on the branch `pr/<number>`. Pull
|
|
248
|
+
* requests read from the public API have no local objects, so the button is not shown for them.
|
|
249
|
+
*/
|
|
250
|
+
openLocally?: PullRequestOpenLocally;
|
|
141
251
|
/** Opens a pull request's project changes as tabs of the host's layout. */
|
|
142
252
|
openTabs?: OpenTabs;
|
|
143
253
|
/** Returns a locator for a project file whose PR head matches the open project, or `undefined`. */
|
|
@@ -156,5 +266,6 @@ export function PullRequestReviewPanel({repository, locale, renderBlock, locateF
|
|
|
156
266
|
}, [repository]);
|
|
157
267
|
if (origin?.repository !== repository) return <p role="status" className="twgit-context">{labels.loading}</p>;
|
|
158
268
|
if (!origin.name) return <p className="twgit-context" role="status">{origin.failed ? labels.failed : labels.noOrigin}</p>;
|
|
159
|
-
return <PullRequests key={origin.name} name={origin.name} labels={labels} locale={locale} renderBlock={renderBlock} locateFor={locateFor} openTabs={openTabs}
|
|
269
|
+
return <PullRequests key={origin.name} name={origin.name} labels={labels} locale={locale} renderBlock={renderBlock} locateFor={locateFor} openTabs={openTabs}
|
|
270
|
+
repository={repository} connection={connection} openLocally={openLocally}/>;
|
|
160
271
|
}
|
package/src/remote-operations.ts
CHANGED
|
@@ -8,6 +8,89 @@ export type RemoteOperation = 'fetch' | 'pull' | 'push';
|
|
|
8
8
|
export type RemoteSession = NonNullable<Awaited<ReturnType<GitSmartHttpOptions['getSession']>>>;
|
|
9
9
|
export type GetRemoteSession = (remote: string, write: boolean) => Promise<RemoteSession | null>;
|
|
10
10
|
|
|
11
|
+
type GitOptions = {fs: ReturnType<typeof createMutationFs>['fs']; dir: string; gitdir: string; cache: object};
|
|
12
|
+
const sessionCodes = ['SESSION_REQUIRED', 'SESSION_UNAVAILABLE', 'SESSION_EXPIRED', 'REAUTH_REQUIRED', 'INVALID_SESSION'];
|
|
13
|
+
|
|
14
|
+
// Fetch writes remote-tracking refs through these refspecs; anything else could land a ref outside refs/remotes/origin.
|
|
15
|
+
async function checkRefspecs(options: GitOptions) {
|
|
16
|
+
const refspecs = await git.getConfigAll({...options, path: 'remote.origin.fetch'});
|
|
17
|
+
if (refspecs.some(spec => !/^\+?refs\/heads\/[^:\s]+:refs\/remotes\/origin\/[^:\s]+$/u.test(spec)))
|
|
18
|
+
throw new GitPanelError('unsupported');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where a fetched pull request head is kept: outside `refs/heads`, `refs/remotes` and `refs/tags`, so Git CLI lists it
|
|
23
|
+
* with `git for-each-ref refs/pull` and `git log --all` but not as a branch or tag, `git fetch --prune` leaves it alone,
|
|
24
|
+
* and `git gc` keeps its objects. Each fetch of the same pull request replaces it.
|
|
25
|
+
*/
|
|
26
|
+
export const pullRequestRef = (number: number) => `refs/pull/origin/${number}/head`;
|
|
27
|
+
|
|
28
|
+
export interface PullRequestFetch {
|
|
29
|
+
readonly number: number;
|
|
30
|
+
/** The commit at GitHub's `refs/pull/<number>/head`, kept locally at `ref`. */
|
|
31
|
+
readonly head: string;
|
|
32
|
+
readonly ref: string;
|
|
33
|
+
/** GitHub's default branch, from the advertised `HEAD`, or `null` when it is not advertised. */
|
|
34
|
+
readonly defaultBranch: string | null;
|
|
35
|
+
/** The branches on GitHub, as `refs/remotes/origin/<name>` after this fetch. */
|
|
36
|
+
readonly branches: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fetches GitHub's `refs/pull/<number>/head` together with every branch, over the connection's Git Smart HTTP, like
|
|
41
|
+
* `git fetch origin refs/pull/<number>/head`. The branches update `refs/remotes/origin/*` as fetch does, and the head
|
|
42
|
+
* is kept at `pullRequestRef(number)`. The work tree, the index, HEAD and `.git/config` are never written. The writes
|
|
43
|
+
* are staged first and applied together, under the same lock as fetch, pull and push.
|
|
44
|
+
*/
|
|
45
|
+
export async function runPullRequestFetch(root: FileSystemDirectoryHandle, number: number,
|
|
46
|
+
getSession: GetRemoteSession, guard: () => void, signal?: AbortSignal): Promise<PullRequestFetch> {
|
|
47
|
+
if (!Number.isSafeInteger(number) || number < 1) throw new GitPanelError('pullRequestNotFound');
|
|
48
|
+
const execute = async () => {
|
|
49
|
+
guard();
|
|
50
|
+
const transaction = createMutationFs(root, false);
|
|
51
|
+
const options = {fs: transaction.fs, dir: '/', gitdir: '/.git', cache: {}};
|
|
52
|
+
const remote = await git.getConfig({...options, path: 'remote.origin.url'});
|
|
53
|
+
if (typeof remote !== 'string') throw new GitPanelError('session');
|
|
54
|
+
await checkRefspecs(options);
|
|
55
|
+
const session = await getSession(remote, false);
|
|
56
|
+
if (!session) throw new GitPanelError('session');
|
|
57
|
+
guard();
|
|
58
|
+
const transport = createGitSmartHttp({remote, connection: session.connection,
|
|
59
|
+
getSession: () => getSession(remote, false), signal});
|
|
60
|
+
const source = `refs/pull/${number}/head`;
|
|
61
|
+
let result: Awaited<ReturnType<typeof git.fetch>>;
|
|
62
|
+
try {
|
|
63
|
+
result = await git.fetch({...options, ...transport, remote: 'origin', remoteRef: source, tags: false});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const {code, data} = error as {code?: string; data?: {what?: string}};
|
|
66
|
+
// isomorphic-git resolves the wanted ref against the advertisement before it sends any request for objects.
|
|
67
|
+
if (code === 'NotFoundError' && data?.what === source) throw new GitPanelError('pullRequestNotFound');
|
|
68
|
+
throw remoteError(error);
|
|
69
|
+
}
|
|
70
|
+
const head = result.fetchHead;
|
|
71
|
+
if (!head || !/^[0-9a-f]{40}$/u.test(head)) throw new GitPanelError('remoteFailed');
|
|
72
|
+
const ref = pullRequestRef(number);
|
|
73
|
+
await git.writeRef({...options, ref, value: head, force: true});
|
|
74
|
+
const branches = (await git.listBranches({...options, remote: 'origin'})).filter(name => name !== 'HEAD').sort();
|
|
75
|
+
const advertised = result.defaultBranch?.startsWith('refs/heads/') ? result.defaultBranch.slice(11) : null;
|
|
76
|
+
await transaction.commit(guard);
|
|
77
|
+
return {number, head, ref, defaultBranch: advertised && branches.includes(advertised) ? advertised : null, branches};
|
|
78
|
+
};
|
|
79
|
+
return globalThis.navigator?.locks ? navigator.locks.request('tw-plus-git-mutation', execute) : execute();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Maps a failed read from GitHub to what the person can act on: the connection, access, or the network. */
|
|
83
|
+
function remoteError(error: unknown) {
|
|
84
|
+
if (error instanceof GitPanelError) return error;
|
|
85
|
+
if (error instanceof GitSmartHttpError) {
|
|
86
|
+
if (sessionCodes.includes(error.code)) return new GitPanelError('session');
|
|
87
|
+
if (error.code === 'FORBIDDEN') return new GitPanelError('forbidden');
|
|
88
|
+
if (['NETWORK_ERROR', 'UPSTREAM_FAILURE', 'UPSTREAM_TIMEOUT', 'RATE_LIMITED'].includes(error.code))
|
|
89
|
+
return new GitPanelError('network');
|
|
90
|
+
}
|
|
91
|
+
return new GitPanelError('remoteFailed');
|
|
92
|
+
}
|
|
93
|
+
|
|
11
94
|
export async function runRemoteOperation(root: FileSystemDirectoryHandle, operation: RemoteOperation,
|
|
12
95
|
getSession: GetRemoteSession, guard: () => void, signal?: AbortSignal) {
|
|
13
96
|
const execute = async () => {
|
|
@@ -23,9 +106,7 @@ export async function runRemoteOperation(root: FileSystemDirectoryHandle, operat
|
|
|
23
106
|
const trackingRef = await config('branch.' + ref + '.merge');
|
|
24
107
|
if ((trackingRemote && trackingRemote !== 'origin') || (trackingRef && !trackingRef.startsWith('refs/heads/')))
|
|
25
108
|
throw new GitPanelError('unsupported');
|
|
26
|
-
|
|
27
|
-
if (refspecs.some(spec => !/^\+?refs\/heads\/[^:\s]+:refs\/remotes\/origin\/[^:\s]+$/u.test(spec)))
|
|
28
|
-
throw new GitPanelError('unsupported');
|
|
109
|
+
await checkRefspecs(options);
|
|
29
110
|
if (operation === 'pull') {
|
|
30
111
|
if ((await git.statusMatrix({...options, refresh: false})).some(([, head, worktree, stage]) =>
|
|
31
112
|
head !== worktree || head !== stage)) throw new GitPanelError('pullUnsafe');
|
package/src/repository.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as git from 'isomorphic-git';
|
|
2
2
|
import {createTwoFilesPatch} from 'diff';
|
|
3
3
|
import {createReadOnlyFs, errorCode, GitPanelError, pathParts, readOptional, sameBytes} from './filesystem.js';
|
|
4
|
-
import {runRemoteOperation} from './remote-operations.js';
|
|
4
|
+
import {runPullRequestFetch, runRemoteOperation} from './remote-operations.js';
|
|
5
|
+
import {createProjectSourceDiff} from './project-source-patch.js';
|
|
6
|
+
import type {ProjectSourceReviewFile} from './project-source-review.js';
|
|
5
7
|
import {runLocalOperation} from './local-operations.js';
|
|
6
8
|
import type {LocalOperation} from './local-operations.js';
|
|
7
9
|
import type {GetRemoteSession, RemoteOperation} from './remote-operations.js';
|
|
@@ -9,6 +11,8 @@ import {assetLimit, assetsDirectoryParts, folderSettingsLimit, isAssetName} from
|
|
|
9
11
|
import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
|
|
10
12
|
|
|
11
13
|
export {createReadOnlyFs, GitPanelError} from './filesystem.js';
|
|
14
|
+
export {pullRequestRef} from './remote-operations.js';
|
|
15
|
+
export type {PullRequestFetch} from './remote-operations.js';
|
|
12
16
|
export type ChangeGroup = 'staged' | 'unstaged' | 'untracked' | 'commit';
|
|
13
17
|
export interface FileChange {
|
|
14
18
|
path: string;
|
|
@@ -21,6 +25,22 @@ export interface FileChange {
|
|
|
21
25
|
/** For `commit` changes, the commit compared with its first parent. */
|
|
22
26
|
commit?: string;
|
|
23
27
|
}
|
|
28
|
+
/** A version of the pull request's base to compare with: GitHub's base commit when known, else a branch on origin. */
|
|
29
|
+
export interface PullRequestBase {
|
|
30
|
+
/** The base commit GitHub reported, used when the fetch brought it. */
|
|
31
|
+
sha?: string;
|
|
32
|
+
/** The base branch, read from `refs/remotes/origin/<branch>` when `sha` is missing or not local. */
|
|
33
|
+
branch?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface PullRequestFiles {
|
|
36
|
+
/** The base that was compared: the branch it was named by, if any, and its commit. */
|
|
37
|
+
base: {ref: string | null; sha: string};
|
|
38
|
+
head: string;
|
|
39
|
+
/** The merge base of `base.sha` and `head`, like GitHub's three-dot comparison; `null` for unrelated histories. */
|
|
40
|
+
mergeBase: string | null;
|
|
41
|
+
/** Every changed path from the merge base to the head, with project data as a project-source diff. */
|
|
42
|
+
files: ProjectSourceReviewFile[];
|
|
43
|
+
}
|
|
24
44
|
export interface Commit {
|
|
25
45
|
oid: string;
|
|
26
46
|
message: string;
|
|
@@ -64,6 +84,8 @@ export interface HistoryPage {
|
|
|
64
84
|
export const historyPageSize = 50;
|
|
65
85
|
const projectSourceLimit = 16 * 1024 * 1024;
|
|
66
86
|
type Metadata = Map<string, Uint8Array | null>;
|
|
87
|
+
/** The branch fields of a snapshot. */
|
|
88
|
+
export type BranchRefs = Pick<Snapshot, 'branch' | 'head' | 'branches' | 'remoteBranches'>;
|
|
67
89
|
export interface Snapshot {
|
|
68
90
|
branch: string | undefined;
|
|
69
91
|
head: string | null;
|
|
@@ -105,6 +127,9 @@ export interface FileDiff {
|
|
|
105
127
|
tooLarge: boolean;
|
|
106
128
|
}
|
|
107
129
|
const emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
130
|
+
const isSha = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f]{40}$/u.test(value);
|
|
131
|
+
const validBranch = (name: string) => /^[^\s~^:?*[\\]+$/u.test(name) && !name.startsWith('/') && !name.endsWith('/') &&
|
|
132
|
+
!name.endsWith('.lock') && !name.includes('..') && !name.includes('//') && !name.includes('@{');
|
|
108
133
|
const textLimit = 1024 * 1024;
|
|
109
134
|
const inScope = (path: string, scope: string) => !scope || path === scope || path.startsWith(scope + '/');
|
|
110
135
|
// Above this many work-tree files, probes only watch Git metadata. Edits then need a manual refresh.
|
|
@@ -275,13 +300,7 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
275
300
|
const assertSnapshot = async (snapshot: Snapshot) => {
|
|
276
301
|
if (!sameMetadata(snapshot.metadata, await metadata())) throw new GitPanelError('changed');
|
|
277
302
|
};
|
|
278
|
-
const
|
|
279
|
-
scope = pathParts(scope).join('/');
|
|
280
|
-
fs.clearReads();
|
|
281
|
-
cache = {};
|
|
282
|
-
// Taken first, so a change during the read differs from the stored value and triggers another read.
|
|
283
|
-
const fingerprint = await probe(scope);
|
|
284
|
-
const before = await metadata();
|
|
303
|
+
const readRefs = async (): Promise<BranchRefs> => {
|
|
285
304
|
let head: string | null;
|
|
286
305
|
try {head = await git.resolveRef({...options, ref: 'HEAD'});} catch (error) {
|
|
287
306
|
if (errorCode(error) !== 'NotFoundError') throw error;
|
|
@@ -291,6 +310,16 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
291
310
|
const branches = await git.listBranches(options);
|
|
292
311
|
const remoteBranches = (await git.listBranches({...options, remote: 'origin'}))
|
|
293
312
|
.filter(name => name !== 'HEAD' && !branches.includes(name)).sort();
|
|
313
|
+
return {branch: branch || undefined, head, branches, remoteBranches};
|
|
314
|
+
};
|
|
315
|
+
const read = async (scope = ''): Promise<Snapshot> => {
|
|
316
|
+
scope = pathParts(scope).join('/');
|
|
317
|
+
fs.clearReads();
|
|
318
|
+
cache = {};
|
|
319
|
+
// Taken first, so a change during the read differs from the stored value and triggers another read.
|
|
320
|
+
const fingerprint = await probe(scope);
|
|
321
|
+
const before = await metadata();
|
|
322
|
+
const {branch, head, branches, remoteBranches} = await readRefs();
|
|
294
323
|
const commits = head ? (await git.log({...options, cache, ref: head, depth: 50})).map(({oid, commit}) => ({
|
|
295
324
|
oid, message: commit.message, author: commit.author.name, timestamp: commit.author.timestamp
|
|
296
325
|
})) : [];
|
|
@@ -316,7 +345,7 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
316
345
|
}
|
|
317
346
|
if (!sameMetadata(before, await metadata())) throw new GitPanelError('changed');
|
|
318
347
|
await fs.checkReads();
|
|
319
|
-
return {branch
|
|
348
|
+
return {branch, head, branches, remoteBranches, commits, changes, scope, metadata: before, probe: fingerprint,
|
|
320
349
|
upstream};
|
|
321
350
|
};
|
|
322
351
|
interface HistoryNode {commit: HistoryCommit; time: number; children: number; expanded: boolean}
|
|
@@ -551,6 +580,100 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
551
580
|
await fs.checkReads();
|
|
552
581
|
return {before: before.source, after: after.source, assetsDirectory: {before: before.assetsDirectory, after: after.assetsDirectory}};
|
|
553
582
|
};
|
|
583
|
+
const hasCommit = async (oid: string) => {
|
|
584
|
+
try {
|
|
585
|
+
await git.readCommit({...options, cache, oid});
|
|
586
|
+
return true;
|
|
587
|
+
} catch {
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
type TreeChange = {path: string; before: Entry | null; after: Entry | null};
|
|
592
|
+
/** Lists the paths that differ between two trees, skipping subtrees with the same object. */
|
|
593
|
+
const treeChanges = async (before: string, after: string): Promise<TreeChange[]> => {
|
|
594
|
+
const file = async (entry: git.WalkerEntry | null) => entry && await entry.type() !== 'tree' ?
|
|
595
|
+
{oid: await entry.oid(), mode: await entry.mode()} : null;
|
|
596
|
+
const changes = await git.walk({...options, cache, trees: [git.TREE({ref: before}), git.TREE({ref: after})],
|
|
597
|
+
map: async (path, [a, b]) => {
|
|
598
|
+
if (path === '.') return;
|
|
599
|
+
if (a && b && await a.oid() === await b.oid() && await a.mode() === await b.mode()) return null;
|
|
600
|
+
const change = {path, before: await file(a), after: await file(b)};
|
|
601
|
+
// A tree on either side is walked on; a file on the other side is a change of its own.
|
|
602
|
+
return change.before || change.after ? change : undefined;
|
|
603
|
+
}}) as TreeChange[];
|
|
604
|
+
return changes.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
605
|
+
};
|
|
606
|
+
const readBlobText = async (entry: Entry | null) => {
|
|
607
|
+
if (!entry) return '{"targets":[]}\n';
|
|
608
|
+
if (entry.mode !== 0o100644 && entry.mode !== 0o100755) throw new GitPanelError('unsupported');
|
|
609
|
+
const {blob} = await git.readBlob({...options, cache, oid: entry.oid});
|
|
610
|
+
if (blob.byteLength > projectSourceLimit) throw new GitPanelError('large');
|
|
611
|
+
return new TextDecoder('utf-8', {fatal: true}).decode(blob);
|
|
612
|
+
};
|
|
613
|
+
const readTreeAsset = async (ref: string, folder: string[], md5ext: string, signal: AbortSignal) => {
|
|
614
|
+
if (signal.aborted) return null;
|
|
615
|
+
const source: Source = {kind: 'tree', ref};
|
|
616
|
+
const settings = await readVersion(source, [...folder, 'sb3-source.json'].join('/'), folderSettingsLimit);
|
|
617
|
+
const directory = assetsDirectoryParts(settings && new TextDecoder().decode(settings));
|
|
618
|
+
if (!directory || signal.aborted) return null;
|
|
619
|
+
const bytes = await readVersion(source, [...folder, ...directory, md5ext].join('/'), assetLimit);
|
|
620
|
+
return bytes && !signal.aborted ? new Blob([bytes as Uint8Array<ArrayBuffer>]) : null;
|
|
621
|
+
};
|
|
622
|
+
const pullRequestAssets = (versions: Record<ProjectVersion, {ref: string; path: string} | null>): ProjectAssetLoader =>
|
|
623
|
+
({version, md5ext, signal}) => {
|
|
624
|
+
const location = versions[version];
|
|
625
|
+
if (!location || !isAssetName(md5ext) || signal.aborted) return Promise.resolve(null);
|
|
626
|
+
return serial(() => readTreeAsset(location.ref, pathParts(location.path).slice(0, -1), md5ext, signal)).catch(() => null);
|
|
627
|
+
};
|
|
628
|
+
/** Compares a fetched pull request head with its merge base, from local Git objects only. */
|
|
629
|
+
const readPullRequest = async (head: string, base: PullRequestBase): Promise<PullRequestFiles> => {
|
|
630
|
+
if (!isSha(head) || !await hasCommit(head)) throw new GitPanelError('pullRequestNotFound');
|
|
631
|
+
let sha: string | undefined;
|
|
632
|
+
if (isSha(base.sha) && await hasCommit(base.sha)) sha = base.sha;
|
|
633
|
+
else if (base.branch !== undefined && validBranch(base.branch)) {
|
|
634
|
+
try {
|
|
635
|
+
sha = await git.resolveRef({...options, ref: 'refs/remotes/origin/' + base.branch});
|
|
636
|
+
} catch {
|
|
637
|
+
// Reported below.
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (!sha || !await hasCommit(sha)) throw new GitPanelError('baseMissing');
|
|
641
|
+
const [mergeBase = null] = await git.findMergeBase({...options, cache, oids: [sha, head]}) as string[];
|
|
642
|
+
const changes = await treeChanges(mergeBase ?? emptyTree, head);
|
|
643
|
+
// Pair a removed and an added file with the same content as a rename, like GitHub's exact renames.
|
|
644
|
+
const added = new Map<string, TreeChange[]>();
|
|
645
|
+
for (const change of changes) if (!change.before && change.after) added.set(change.after.oid, [...added.get(change.after.oid) ?? [], change]);
|
|
646
|
+
const renamed = new Map<TreeChange, TreeChange>();
|
|
647
|
+
for (const change of changes) {
|
|
648
|
+
const candidates = change.before && !change.after ? added.get(change.before.oid) : undefined;
|
|
649
|
+
const target = candidates?.find(candidate => !renamed.has(candidate));
|
|
650
|
+
if (target) renamed.set(target, change);
|
|
651
|
+
}
|
|
652
|
+
const removedByRename = new Set(renamed.values());
|
|
653
|
+
const files: ProjectSourceReviewFile[] = [];
|
|
654
|
+
for (const change of changes) {
|
|
655
|
+
if (removedByRename.has(change)) continue;
|
|
656
|
+
const from = renamed.get(change);
|
|
657
|
+
const before = from?.before ?? change.before;
|
|
658
|
+
const previousPath = from?.path;
|
|
659
|
+
const status = !before ? 'added' : !change.after ? 'removed' : 'updated';
|
|
660
|
+
if (change.path.split('/').pop() !== 'project.source.json') {
|
|
661
|
+
files.push({path: change.path, previousPath, status});
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
try {
|
|
665
|
+
const patch = createProjectSourceDiff(await readBlobText(before), await readBlobText(change.after));
|
|
666
|
+
const loadAsset = pullRequestAssets({
|
|
667
|
+
before: before && mergeBase ? {ref: mergeBase, path: previousPath ?? change.path} : null,
|
|
668
|
+
after: change.after ? {ref: head, path: change.path} : null
|
|
669
|
+
});
|
|
670
|
+
files.push({path: change.path, previousPath, patch, loadAsset});
|
|
671
|
+
} catch {
|
|
672
|
+
files.push({path: change.path, previousPath, status, unavailable: true});
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return {base: {ref: base.branch ?? null, sha}, head, mergeBase, files};
|
|
676
|
+
};
|
|
554
677
|
const directoryAt = async (path: string) => {
|
|
555
678
|
let directory = root;
|
|
556
679
|
for (const part of pathParts(path)) directory = await directory.getDirectoryHandle(part);
|
|
@@ -595,6 +718,21 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
595
718
|
throw error;
|
|
596
719
|
}
|
|
597
720
|
}),
|
|
721
|
+
/**
|
|
722
|
+
* Fetches a pull request's head (`refs/pull/<number>/head`, which also covers pull requests from forks) and every
|
|
723
|
+
* branch over the connection; see `runPullRequestFetch`. Only Git objects and refs are written.
|
|
724
|
+
*/
|
|
725
|
+
fetchPullRequest: (number: number, getSession: GetRemoteSession, guard: () => void, signal?: AbortSignal) =>
|
|
726
|
+
serial(async () => {
|
|
727
|
+
const result = await runPullRequestFetch(root, number, getSession, guard, signal);
|
|
728
|
+
cache = {};
|
|
729
|
+
return result;
|
|
730
|
+
}),
|
|
731
|
+
/**
|
|
732
|
+
* Reads the changes of a fetched pull request head against a base, from local Git objects: the changed paths
|
|
733
|
+
* from the merge base to the head, and each project file's diff with assets read from the same commits.
|
|
734
|
+
*/
|
|
735
|
+
readPullRequest: (head: string, base: PullRequestBase) => serial(() => readPullRequest(head, base)),
|
|
598
736
|
/** Stages, unstages or commits; see `runLocalOperation`. `guard` throws to stop before each write. */
|
|
599
737
|
localOperation: (operation: LocalOperation, guard: () => void) =>
|
|
600
738
|
serial(() => runLocalOperation(root, operation, guard)),
|
|
@@ -613,6 +751,14 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
613
751
|
}
|
|
614
752
|
}),
|
|
615
753
|
read: (scope?: string) => serial(() => read(scope)),
|
|
754
|
+
/**
|
|
755
|
+
* The current branch, HEAD and the branch names, read from refs only. Cheap enough to follow branch changes made
|
|
756
|
+
* by other tools while nothing else is read; `read` returns the same fields.
|
|
757
|
+
*/
|
|
758
|
+
refs: () => serial(() => {
|
|
759
|
+
fs.clearReads();
|
|
760
|
+
return readRefs();
|
|
761
|
+
}),
|
|
616
762
|
readDiff: (snapshot: Snapshot, change: FileChange) => serial(() => readDiff(snapshot, change)),
|
|
617
763
|
historyChanges: (snapshot: Snapshot, oid: string) => serial(() => historyChanges(snapshot, oid)),
|
|
618
764
|
/**
|
package/src/style.css
CHANGED
|
@@ -92,3 +92,31 @@
|
|
|
92
92
|
.twgit-graph-status { margin: 4px 0; font-size: 11px; min-height: 1em; }
|
|
93
93
|
.twgit-panel .twgit-graph-popup [role="menuitem"]:focus-visible, .twgit-graph-popup [role="menuitem"]:focus-visible { outline-offset: -2px; }
|
|
94
94
|
.twgit-graph-form-error { margin: 0; color: #a11919; overflow-wrap: anywhere; }
|
|
95
|
+
/* The branch button sits in the host's menu bar and takes its text color; its menu uses the host's menu colors. */
|
|
96
|
+
.twgit-branch-entry { position: relative; flex: 0 1 auto; align-self: center; display: flex; min-width: 0; font: inherit; }
|
|
97
|
+
.twgit-branch-button { display: flex; align-items: center; gap: 4px; min-width: 0; max-width: 16rem; min-height: 2rem; padding: 0 8px;
|
|
98
|
+
color: inherit; background: transparent; border: 0; border-radius: 4px; font: inherit; white-space: nowrap; cursor: pointer; }
|
|
99
|
+
.twgit-branch-button:hover, .twgit-branch-button[aria-expanded="true"] { background: color-mix(in srgb, currentColor 15%, transparent); }
|
|
100
|
+
.twgit-branch-button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
|
|
101
|
+
.twgit-branch-icon { flex: none; }
|
|
102
|
+
.twgit-branch-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
103
|
+
.twgit-branch-unborn { flex: none; font-size: 11px; opacity: .85; }
|
|
104
|
+
.twgit-branch-caret { flex: none; font-size: 10px; }
|
|
105
|
+
.twgit-branch-popup { position: absolute; z-index: 510; top: calc(100% + 4px); right: 0; display: grid; gap: 2px; min-width: 220px;
|
|
106
|
+
max-width: min(360px, 90vw); max-height: 70vh; overflow: auto; box-sizing: border-box; padding: 4px;
|
|
107
|
+
color: var(--text-primary, CanvasText); background: var(--ui-white, Canvas); border: 1px solid var(--ui-black-transparent, GrayText);
|
|
108
|
+
border-radius: 6px; box-shadow: 0 8px 24px #0004; font: 14px/1.5 system-ui, sans-serif; white-space: normal; }
|
|
109
|
+
.twgit-branch-popup [role^="menuitem"] { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 10px; color: inherit;
|
|
110
|
+
background: transparent; border: 0; border-radius: 4px; font: inherit; text-align: start; overflow-wrap: anywhere; cursor: pointer; }
|
|
111
|
+
.twgit-branch-popup [role^="menuitem"]:hover, .twgit-branch-popup [role^="menuitem"]:focus-visible {
|
|
112
|
+
background: color-mix(in srgb, currentColor 12%, transparent); }
|
|
113
|
+
.twgit-branch-popup [role^="menuitem"]:focus-visible { outline: 2px solid var(--looks-secondary, Highlight); outline-offset: -2px; }
|
|
114
|
+
.twgit-branch-popup [aria-disabled="true"]:not([aria-checked="true"]) { opacity: .55; cursor: default; }
|
|
115
|
+
.twgit-branch-popup [aria-checked="true"] { font-weight: 600; }
|
|
116
|
+
.twgit-branch-popup [data-separator-before="true"] { margin-top: 3px; box-shadow: 0 -3px 0 -2px color-mix(in srgb, currentColor 25%, transparent); }
|
|
117
|
+
.twgit-branch-check { flex: none; width: 1em; text-align: center; }
|
|
118
|
+
.twgit-branch-popup form { display: grid; gap: 6px; padding: 6px; font-size: 12px; }
|
|
119
|
+
.twgit-branch-popup form label { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
|
120
|
+
.twgit-branch-popup form input:not([type]) { flex: 1; min-width: 10em; font: inherit; }
|
|
121
|
+
.twgit-branch-popup form button { color: inherit; background: transparent; border: 1px solid currentColor; border-radius: 4px; padding: 3px 8px; font: inherit; cursor: pointer; }
|
|
122
|
+
.twgit-branch-popup form button:disabled { opacity: .5; cursor: default; }
|
|
@@ -176,3 +176,23 @@ export function adapterOptions(fixture, bearer = 'fixture-write') {
|
|
|
176
176
|
fetch: (url, init) => fetch(url, {...init, headers: {...Object.fromEntries(init.headers), Origin: 'https://editor.example.invalid'}})
|
|
177
177
|
};
|
|
178
178
|
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Publishes a pull request the way GitHub exposes it on the base repository: `refs/pull/<number>/head` points at the
|
|
182
|
+
* head commit. With `fork`, the head is committed in a separate clone and pushed only to that ref, so no branch of the
|
|
183
|
+
* base repository contains it.
|
|
184
|
+
*/
|
|
185
|
+
export async function publishPullRequest(fixture, number, {fork = false, branch = 'feature', from, edit}) {
|
|
186
|
+
const dir = from ?? join(fixture.root, fork ? `fork-${number}` : `branch-${number}`);
|
|
187
|
+
if (!from) {
|
|
188
|
+
git(fixture.root, 'clone', '-q', fixture.bare, dir);
|
|
189
|
+
if (!fork) git(dir, 'switch', '-q', '-c', branch);
|
|
190
|
+
}
|
|
191
|
+
await edit(dir);
|
|
192
|
+
git(dir, 'add', '-A');
|
|
193
|
+
git(dir, '-c', 'user.name=Contributor', '-c', 'user.email=contributor@example.invalid', 'commit', '-qm', `pull request ${number}`);
|
|
194
|
+
const head = git(dir, 'rev-parse', 'HEAD');
|
|
195
|
+
if (!fork) git(dir, 'push', '-q', '--force', fixture.bare, branch);
|
|
196
|
+
git(dir, 'push', '-q', fixture.bare, `+HEAD:refs/pull/${number}/head`);
|
|
197
|
+
return {dir, head};
|
|
198
|
+
}
|