@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.
@@ -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
- return error instanceof PullRequestError ? labels[error.code] : labels.failed;
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 Pull = Awaited<ReturnType<GitHubPullRequests['load']>>;
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
- void client.load(selection.number, controller.signal).then(data => {
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 && <p role="alert">{list.error}</p>}
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
  }
@@ -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
- const refspecs = await git.getConfigAll({...options, path: 'remote.origin.fetch'});
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 read = async (scope = ''): Promise<Snapshot> => {
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: branch || undefined, head, branches, remoteBranches, commits, changes, scope, metadata: before, probe: fingerprint,
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
+ }