@kubohiroya/turbowarp-plus-editor-git 0.16.1 → 0.17.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/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;
@@ -105,6 +125,9 @@ export interface FileDiff {
105
125
  tooLarge: boolean;
106
126
  }
107
127
  const emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
128
+ const isSha = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f]{40}$/u.test(value);
129
+ const validBranch = (name: string) => /^[^\s~^:?*[\\]+$/u.test(name) && !name.startsWith('/') && !name.endsWith('/') &&
130
+ !name.endsWith('.lock') && !name.includes('..') && !name.includes('//') && !name.includes('@{');
108
131
  const textLimit = 1024 * 1024;
109
132
  const inScope = (path: string, scope: string) => !scope || path === scope || path.startsWith(scope + '/');
110
133
  // Above this many work-tree files, probes only watch Git metadata. Edits then need a manual refresh.
@@ -551,6 +574,100 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
551
574
  await fs.checkReads();
552
575
  return {before: before.source, after: after.source, assetsDirectory: {before: before.assetsDirectory, after: after.assetsDirectory}};
553
576
  };
577
+ const hasCommit = async (oid: string) => {
578
+ try {
579
+ await git.readCommit({...options, cache, oid});
580
+ return true;
581
+ } catch {
582
+ return false;
583
+ }
584
+ };
585
+ type TreeChange = {path: string; before: Entry | null; after: Entry | null};
586
+ /** Lists the paths that differ between two trees, skipping subtrees with the same object. */
587
+ const treeChanges = async (before: string, after: string): Promise<TreeChange[]> => {
588
+ const file = async (entry: git.WalkerEntry | null) => entry && await entry.type() !== 'tree' ?
589
+ {oid: await entry.oid(), mode: await entry.mode()} : null;
590
+ const changes = await git.walk({...options, cache, trees: [git.TREE({ref: before}), git.TREE({ref: after})],
591
+ map: async (path, [a, b]) => {
592
+ if (path === '.') return;
593
+ if (a && b && await a.oid() === await b.oid() && await a.mode() === await b.mode()) return null;
594
+ const change = {path, before: await file(a), after: await file(b)};
595
+ // A tree on either side is walked on; a file on the other side is a change of its own.
596
+ return change.before || change.after ? change : undefined;
597
+ }}) as TreeChange[];
598
+ return changes.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
599
+ };
600
+ const readBlobText = async (entry: Entry | null) => {
601
+ if (!entry) return '{"targets":[]}\n';
602
+ if (entry.mode !== 0o100644 && entry.mode !== 0o100755) throw new GitPanelError('unsupported');
603
+ const {blob} = await git.readBlob({...options, cache, oid: entry.oid});
604
+ if (blob.byteLength > projectSourceLimit) throw new GitPanelError('large');
605
+ return new TextDecoder('utf-8', {fatal: true}).decode(blob);
606
+ };
607
+ const readTreeAsset = async (ref: string, folder: string[], md5ext: string, signal: AbortSignal) => {
608
+ if (signal.aborted) return null;
609
+ const source: Source = {kind: 'tree', ref};
610
+ const settings = await readVersion(source, [...folder, 'sb3-source.json'].join('/'), folderSettingsLimit);
611
+ const directory = assetsDirectoryParts(settings && new TextDecoder().decode(settings));
612
+ if (!directory || signal.aborted) return null;
613
+ const bytes = await readVersion(source, [...folder, ...directory, md5ext].join('/'), assetLimit);
614
+ return bytes && !signal.aborted ? new Blob([bytes as Uint8Array<ArrayBuffer>]) : null;
615
+ };
616
+ const pullRequestAssets = (versions: Record<ProjectVersion, {ref: string; path: string} | null>): ProjectAssetLoader =>
617
+ ({version, md5ext, signal}) => {
618
+ const location = versions[version];
619
+ if (!location || !isAssetName(md5ext) || signal.aborted) return Promise.resolve(null);
620
+ return serial(() => readTreeAsset(location.ref, pathParts(location.path).slice(0, -1), md5ext, signal)).catch(() => null);
621
+ };
622
+ /** Compares a fetched pull request head with its merge base, from local Git objects only. */
623
+ const readPullRequest = async (head: string, base: PullRequestBase): Promise<PullRequestFiles> => {
624
+ if (!isSha(head) || !await hasCommit(head)) throw new GitPanelError('pullRequestNotFound');
625
+ let sha: string | undefined;
626
+ if (isSha(base.sha) && await hasCommit(base.sha)) sha = base.sha;
627
+ else if (base.branch !== undefined && validBranch(base.branch)) {
628
+ try {
629
+ sha = await git.resolveRef({...options, ref: 'refs/remotes/origin/' + base.branch});
630
+ } catch {
631
+ // Reported below.
632
+ }
633
+ }
634
+ if (!sha || !await hasCommit(sha)) throw new GitPanelError('baseMissing');
635
+ const [mergeBase = null] = await git.findMergeBase({...options, cache, oids: [sha, head]}) as string[];
636
+ const changes = await treeChanges(mergeBase ?? emptyTree, head);
637
+ // Pair a removed and an added file with the same content as a rename, like GitHub's exact renames.
638
+ const added = new Map<string, TreeChange[]>();
639
+ for (const change of changes) if (!change.before && change.after) added.set(change.after.oid, [...added.get(change.after.oid) ?? [], change]);
640
+ const renamed = new Map<TreeChange, TreeChange>();
641
+ for (const change of changes) {
642
+ const candidates = change.before && !change.after ? added.get(change.before.oid) : undefined;
643
+ const target = candidates?.find(candidate => !renamed.has(candidate));
644
+ if (target) renamed.set(target, change);
645
+ }
646
+ const removedByRename = new Set(renamed.values());
647
+ const files: ProjectSourceReviewFile[] = [];
648
+ for (const change of changes) {
649
+ if (removedByRename.has(change)) continue;
650
+ const from = renamed.get(change);
651
+ const before = from?.before ?? change.before;
652
+ const previousPath = from?.path;
653
+ const status = !before ? 'added' : !change.after ? 'removed' : 'updated';
654
+ if (change.path.split('/').pop() !== 'project.source.json') {
655
+ files.push({path: change.path, previousPath, status});
656
+ continue;
657
+ }
658
+ try {
659
+ const patch = createProjectSourceDiff(await readBlobText(before), await readBlobText(change.after));
660
+ const loadAsset = pullRequestAssets({
661
+ before: before && mergeBase ? {ref: mergeBase, path: previousPath ?? change.path} : null,
662
+ after: change.after ? {ref: head, path: change.path} : null
663
+ });
664
+ files.push({path: change.path, previousPath, patch, loadAsset});
665
+ } catch {
666
+ files.push({path: change.path, previousPath, status, unavailable: true});
667
+ }
668
+ }
669
+ return {base: {ref: base.branch ?? null, sha}, head, mergeBase, files};
670
+ };
554
671
  const directoryAt = async (path: string) => {
555
672
  let directory = root;
556
673
  for (const part of pathParts(path)) directory = await directory.getDirectoryHandle(part);
@@ -595,6 +712,21 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
595
712
  throw error;
596
713
  }
597
714
  }),
715
+ /**
716
+ * Fetches a pull request's head (`refs/pull/<number>/head`, which also covers pull requests from forks) and every
717
+ * branch over the connection; see `runPullRequestFetch`. Only Git objects and refs are written.
718
+ */
719
+ fetchPullRequest: (number: number, getSession: GetRemoteSession, guard: () => void, signal?: AbortSignal) =>
720
+ serial(async () => {
721
+ const result = await runPullRequestFetch(root, number, getSession, guard, signal);
722
+ cache = {};
723
+ return result;
724
+ }),
725
+ /**
726
+ * Reads the changes of a fetched pull request head against a base, from local Git objects: the changed paths
727
+ * from the merge base to the head, and each project file's diff with assets read from the same commits.
728
+ */
729
+ readPullRequest: (head: string, base: PullRequestBase) => serial(() => readPullRequest(head, base)),
598
730
  /** Stages, unstages or commits; see `runLocalOperation`. `guard` throws to stop before each write. */
599
731
  localOperation: (operation: LocalOperation, guard: () => void) =>
600
732
  serial(() => runLocalOperation(root, operation, guard)),
@@ -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
+ }