Repository: Thinkmill/keystatic
Pull request #220 (2023-04-05 03:20:31)

Fix path reference in local mode
Previously, we used a tree in local mode that only included files within collection/singleton directories, that was fine until `fields.pathReference`, so now we return everything inside that isn't gitignored. The files that can actually be read and updated are still restricted to collection/singleton directories.

changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 63bea4c8464858136ad514bc77f687c072aa2e95

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 6 packages</summary>

  | Name                           | Type  |
| ------------------------------ | ----- |
| @keystatic/core                | Patch |
| @keystatic/test-next-app       | Patch |
| @keystatic/test-astro          | Patch |
| localization                   | Patch |
| @keystatic/test-next-pages-dir | Patch |
| @keystatic/remix-test-app      | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/Thinkmill/keystatic/new/fix-path-reference-in-local-mode?filename=.changeset/blue-lizards-destroy.md&value=---%0A%22%40keystatic%2Ftest-next-app%22%3A%20patch%0A%22%40keystatic%2Fcore%22%3A%20patch%0A---%0A%0AFix%20path%20reference%20in%20local%20mode%0A)


vercel[bot]: [vc]: #oCxKcSOwfbhIrv6QiacRn+ujQKKwTk4QaewyAwmrmH8=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJrZXlzdGF0aWMiLCJyb290RGlyZWN0b3J5Ijoia2V5c3RhdGljIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RoaW5rbWlsbC9rZXlzdGF0aWMvM1ZxbnpGa0hySG5oVUFLOEdGZXNLMUhKdGJSOCIsInByZXZpZXdVcmwiOiJrZXlzdGF0aWMtZ2l0LWZpeC1wYXRoLXJlZmVyZW5jZS1pbi1sb2NhbC1tb2RlLXRoaW5rbWlsbC52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn0seyJuYW1lIjoidm91c3NvaXIiLCJyb290RGlyZWN0b3J5IjoiZGVzaWduLXN5c3RlbS9kb2NzIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RoaW5rbWlsbC92b3Vzc29pci9HS1BSa1J5VFlZVXpuUHJDaXRjRThQRlVoVkxCIiwicHJldmlld1VybCI6InZvdXNzb2lyLWdpdC1maXgtcGF0aC1yZWZlcmVuY2UtaW4tbG9jYWwtbW9kZS10aGlua21pbGwudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCJ9XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **keystatic** | ✅ Ready ([Inspect](https://vercel.com/thinkmill/keystatic/3VqnzFkHrHnhUAK8GFesK1HJtbR8)) | [Visit Preview](https://keystatic-git-fix-path-reference-in-local-mode-thinkmill.vercel.app) | Apr 5, 2023 3:20am |
| **voussoir** | ✅ Ready ([Inspect](https://vercel.com/thinkmill/voussoir/GKPRkRyTYYUznPrCitcE8PFUhVLB)) | [Visit Preview](https://voussoir-git-fix-path-reference-in-local-mode-thinkmill.vercel.app) | Apr 5, 2023 3:20am |




Patch:
diff --git a/.changeset/tiny-fans-vanish.md b/.changeset/tiny-fans-vanish.md
new file mode 100644
index 00000000..56cfc21e
--- /dev/null
+++ b/.changeset/tiny-fans-vanish.md
@@ -0,0 +1,5 @@
+---
+'@keystatic/core': patch
+---
+
+Updated the tree used in local mode to include all non-gitignored files from cwd or `localBaseDirectory` so that `fields.pathReference` works correctly.
diff --git a/packages/keystatic/package.json b/packages/keystatic/package.json
index 4fa36408..c61b821c 100644
--- a/packages/keystatic/package.json
+++ b/packages/keystatic/package.json
@@ -110,6 +110,7 @@
     "fast-deep-equal": "^3.1.3",
     "fp-ts": "^2.11.5",
     "graphql": "^16.6.0",
+    "ignore": "^5.2.4",
     "io-ts": "^2.2.16",
     "io-ts-excess": "^1.0.1",
     "is-hotkey": "^0.2.0",
@@ -142,11 +143,15 @@
     "@types/is-hotkey": "^0.1.7",
     "@types/js-yaml": "^4.0.5",
     "@types/prismjs": "^1.26.0",
+    "@types/signal-exit": "^3.0.1",
     "@voussoir/test-utils": "^2.0.2",
     "eslint": "^8.18.0",
+    "fast-glob": "^3.2.12",
     "jest-diff": "^29.0.1",
+    "outdent": "^0.8.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
+    "signal-exit": "^3.0.7",
     "tsx": "^3.8.0",
     "typescript": "^5.0.2"
   },
diff --git a/packages/keystatic/src/api/generic.ts b/packages/keystatic/src/api/generic.ts
index ba1964a5..47bdacee 100644
--- a/packages/keystatic/src/api/generic.ts
+++ b/packages/keystatic/src/api/generic.ts
@@ -605,7 +605,7 @@ async function tree(
   return {
     status: 200,
     headers: { 'content-type': 'application/json' },
-    body: JSON.stringify(await readToDirEntries(baseDirectory, config)),
+    body: JSON.stringify(await readToDirEntries(baseDirectory)),
   };
 }
 
@@ -695,6 +695,6 @@ async function update(
   return {
     status: 200,
     headers: { 'content-type': 'application/json' },
-    body: JSON.stringify(await readToDirEntries(baseDirectory, config)),
+    body: JSON.stringify(await readToDirEntries(baseDirectory)),
   };
 }
diff --git a/packages/keystatic/src/api/read-local.ts b/packages/keystatic/src/api/read-local.ts
index be272977..03cf52ab 100644
--- a/packages/keystatic/src/api/read-local.ts
+++ b/packages/keystatic/src/api/read-local.ts
@@ -9,6 +9,7 @@ import { updateTreeWithChanges, blobSha } from './trees-server-side';
 import { Config } from '../../config';
 import { getDirectoriesForTreeKey } from '../../app/tree-key';
 import { fields } from '../../DocumentEditor/component-blocks/api';
+import ignore from 'ignore';
 
 async function readDirEntries(dir: string) {
   let entries;
@@ -25,26 +26,62 @@ async function readDirEntries(dir: string) {
 
 async function collectEntriesInDir(
   baseDir: string,
-  currentRelativeDir: string
-): Promise<
-  {
-    path: string;
-    contents: {
-      byteLength: number;
-      sha: string;
-    };
+  ancestors: {
+    gitignoreFilterForDescendents: (relativePath: string) => boolean;
+    segment: string;
   }[]
-> {
+): Promise<{ path: string; contents: { byteLength: number; sha: string } }[]> {
+  const currentRelativeDir = ancestors.map(p => p.segment).join('/');
   const entries = await readDirEntries(path.join(baseDir, currentRelativeDir));
+  const gitignore = entries.find(
+    entry => entry.isFile() && entry.name === '.gitignore'
+  );
+
+  const gitignoreFilterForDescendents = gitignore
+    ? ignore()
+        .add(
+          await fs.readFile(
+            path.join(baseDir, currentRelativeDir, gitignore.name),
+            'utf8'
+          )
+        )
+        .createFilter()
+    : () => true;
+  const pathSegments = ancestors.map(x => x.segment);
   return (
     await Promise.all(
       entries
-        .filter(entry => entry.isDirectory() || entry.isFile())
+        .filter(entry => {
+          if (
+            (!entry.isDirectory() && !entry.isFile()) ||
+            entry.name === '.git'
+          ) {
+            return false;
+          }
+          const innerPath = `${pathSegments.concat(entry.name).join('/')}${
+            entry.isDirectory() ? '/' : ''
+          }`;
+          if (!gitignoreFilterForDescendents(innerPath)) {
+            return false;
+          }
+          let currentPath = entry.name;
+          for (let i = ancestors.length - 1; i >= 0; i--) {
+            const ancestor = ancestors[i];
+            currentPath = `${ancestor.segment}/${currentPath}`;
+            if (!ancestor.gitignoreFilterForDescendents(currentPath)) {
+              return false;
+            }
+          }
+          return true;
+        })
         .map(async entry => {
-          const innerPath = `${currentRelativeDir}/${entry.name}`;
           if (entry.isDirectory()) {
-            return collectEntriesInDir(baseDir, innerPath);
+            return collectEntriesInDir(baseDir, [
+              ...ancestors,
+              { gitignoreFilterForDescendents, segment: entry.name },
+            ]);
           } else {
+            const innerPath = pathSegments.concat(entry.name).join('/');
             const contents = await fs.readFile(path.join(baseDir, innerPath));
             return {
               path: innerPath,
@@ -59,20 +96,8 @@ async function collectEntriesInDir(
   ).flat();
 }
 
-export async function readToDirEntries(baseDir: string, config: Config) {
-  const additions: {
-    path: string;
-    contents: {
-      byteLength: number;
-      sha: string;
-    };
-  }[] = [];
-  const rootDirs = getAllowedDirectories(config);
-  await Promise.all(
-    rootDirs.map(async dir => {
-      additions.push(...(await collectEntriesInDir(baseDir, dir)));
-    })
-  );
+export async function readToDirEntries(baseDir: string) {
+  const additions = await collectEntriesInDir(baseDir, []);
   const { entries } = await updateTreeWithChanges(new Map(), {
     additions: additions,
     deletions: [],
diff --git a/packages/keystatic/src/api/trees-server-side.ts b/packages/keystatic/src/api/trees-server-side.ts
index f5ddd9c8..ead3238f 100644
--- a/packages/keystatic/src/api/trees-server-side.ts
+++ b/packages/keystatic/src/api/trees-server-side.ts
@@ -46,6 +46,7 @@ type TreeChanges = Map<string, NodeChanges>;
 type NodeChanges = { byteLength: number; sha: string } | 'delete' | TreeChanges;
 
 function getNodeAtPath(tree: TreeChanges, path: string): TreeChanges {
+  if (path === '') return tree;
   let node = tree;
   for (const part of path.split('/')) {
     if (!node.has(part)) {
@@ -63,6 +64,7 @@ function getFilename(path: string) {
 }
 
 function getDirname(path: string) {
+  if (!path.includes('/')) return '';
   return path.replace(/\/[^/]+$/, '');
 }
 
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4fcd8c71..7e756de5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2053,6 +2053,7 @@ importers:
       '@types/node': 16.11.13
       '@types/prismjs': ^1.26.0
       '@types/react': ^18.0.25
+      '@types/signal-exit': ^3.0.1
       '@urql/exchange-auth': ^2.0.0
       '@urql/exchange-graphcache': ^5.2.0
       '@urql/exchange-persisted-fetch': ^2.1.0
@@ -2098,8 +2099,10 @@ importers:
       emery: ^1.4.1
       eslint: ^8.18.0
       fast-deep-equal: ^3.1.3
+      fast-glob: ^3.2.12
       fp-ts: ^2.11.5
       graphql: ^16.6.0
+      ignore: ^5.2.4
       io-ts: ^2.2.16
       io-ts-excess: ^1.0.1
       is-hotkey: ^0.2.0
@@ -2115,11 +2118,13 @@ importers:
       micromark-extension-gfm-strikethrough: 0.6.5
       mime: ^3.0.0
       minimatch: ^7.1.0
+      outdent: ^0.8.0
       pretty-format: ^29.0.1
       prismjs: ^1.29.0
       react: ^18.2.0
       react-dom: ^18.2.0
       scroll-into-view-if-needed: ^3.0.3
+      signal-exit: ^3.0.7
       slate: ^0.91.4
       slate-history: ^0.86.0
       slate-hyperscript: ^0.77.0
@@ -2193,6 +2198,7 @@ importers:
       fast-deep-equal: 3.1.3
       fp-ts: 2.13.1
       graphql: 16.6.0
+      ignore: 5.2.4
       io-ts: 2.2.20_fp-ts@2.13.1
       io-ts-excess: 1.0.1_fp-ts@2.13.1
       is-hotkey: 0.2.0
@@ -2224,11 +2230,15 @@ importers:
       '@types/is-hotkey': 0.1.7
       '@types/js-yaml': 4.0.5
       '@types/prismjs': 1.26.0
+      '@types/signal-exit': 3.0.1
       '@voussoir/test-utils': link:../../design-system/packages/test-utils
       eslint: 8.34.0
+      fast-glob: 3.2.12
       jest-diff: 29.4.3
+      outdent: 0.8.0
       react: 18.2.0
       react-dom: 18.2.0_react@18.2.0
+      signal-exit: 3.0.7
       tsx: 3.12.3
       typescript: 5.0.2
 
@@ -2906,7 +2916,7 @@ packages:
       '@babel/core': ^7.0.0-0
     dependencies:
       '@babel/core': 7.12.9
-      '@babel/helper-plugin-utils': 7.10.4
+      '@babel/helper-plugin-utils': 7.20.2
       '@babel/plugin-syntax-object-rest-spread': 7.8.3_@babel+core@7.12.9
       '@babel/plugin-transform-parameters': 7.20.7_@babel+core@7.12.9
     dev: true
@@ -5019,7 +5029,7 @@ packages:
     resolution: {integrity: sha512-W/o/34+wQuXlgqlPYTansOSiBnuxrTv61dEVkA6HNmpcgHLUjfaUbdqt6oVvOzaawwo9IdW9QOtMgQ1ScSZC4A==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
-      '@jest/types': 29.4.3
+      '@jest/types': 29.5.0
       '@types/node': 16.11.13
       chalk: 4.1.2
       jest-message-util: 29.4.3
@@ -5079,6 +5089,16 @@ packages:
       jest-mock: 29.4.3
     dev: false
 
+  /@jest/environment/29.5.0:
+    resolution: {integrity: sha512-5FXw2+wD29YU1d4I2htpRX7jYnAyTRjP2CsXQdo9SAM8g3ifxWPSV0HnClSn71xwctr0U3oZIIH+dtbfmnbXVQ==}
+    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+    dependencies:
+      '@jest/fake-timers': 29.5.0
+      '@jest/types': 29.5.0
+      '@types/node': 16.11.13
+      jest-mock: 29.5.0
+    dev: false
+
   /@jest/expect-utils/29.4.3:
     resolution: {integrity: sha512-/6JWbkxHOP8EoS8jeeTd9dTfc9Uawi+43oLKHfp6zzux3U2hqOOVnV3ai4RpDYHOccL6g+5nrxpoc8DmJxtXVQ==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -5086,12 +5106,19 @@ packages:
       jest-get-type: 29.4.3
     dev: false
 
-  /@jest/expect/29.4.3:
-    resolution: {integrity: sha512-iktRU/YsxEtumI9zsPctYUk7ptpC+AVLLk1Ax3AsA4g1C+8OOnKDkIQBDHtD5hA/+VtgMd5AWI5gNlcAlt2vxQ==}
+  /@jest/expect-utils/29.5.0:
+    resolution: {integrity: sha512-fmKzsidoXQT2KwnrwE0SQq3uj8Z763vzR8LnLBwC2qYWEFpjX8daRsk6rHUM1QvNlEW/UJXNXm59ztmJJWs2Mg==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
-      expect: 29.4.3
-      jest-snapshot: 29.4.3
+      jest-get-type: 29.4.3
+    dev: false
+
+  /@jest/expect/29.5.0:
+    resolution: {integrity: sha512-PueDR2HGihN3ciUNGr4uelropW7rqUfTiOn+8u0leg/42UhblPxHkfoh0Ruu3I9Y1962P3u2DY4+h7GVTSVU6g==}
+    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+    dependencies:
+      expect: 29.5.0
+      jest-snapshot: 29.5.0
     transitivePeerDependencies:
       - supports-color
     dev: false
@@ -5108,14 +5135,26 @@ packages:
       jest-util: 29.4.3
     dev: false
 
-  /@jest/globals/29.4.3:
-    resolution: {integrity: sha512-8BQ/5EzfOLG7AaMcDh7yFCbfRLtsc+09E1RQmRBI4D6QQk4m6NSK/MXo+3bJrBN0yU8A2/VIcqhvsOLFmziioA==}
+  /@jest/fake-timers/29.5.0:
+    resolution: {integrity: sha512-9ARvuAAQcBwDAqOnglWq2zwNIRUDtk/SCkp/ToGEhFv5r86K21l+VEs0qNTaXtyiY0lEePl3kylijSYJQqdbDg==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
-      '@jest/environment': 29.4.3
-      '@jest/expect': 29.4.3
-      '@jest/types': 29.4.3
-      jest-mock: 29.4.3
+      '@jest/types': 29.5.0
+      '@sinonjs/fake-timers': 10.0.2
+      '@types/node': 16.11.13
+      jest-message-util: 29.5.0
+      jest-mock: 29.5.0
+      jest-util: 29.5.0
+    dev: false
+
+  /@jest/globals/29.5.0:
+    resolution: {integrity: sha512-S02y0qMWGihdzNbUiqSAiKSpSozSuHX5UYc7QbnHP+D9Lyw8DgGGCinrN9uSuHPeKgSSzvPom2q1nAtBvUsvPQ==}
+    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+    dependencies:
+      '@jest/environment': 29.5.0
+      '@jest/expect': 29.5.0
+      '@jest/types': 29.5.0
+      jest-mock: 29.5.0
     transitivePeerDependencies:
       - supports-color
     dev: false
@@ -5133,7 +5172,7 @@ packages:
       '@jest/console': 29.4.3
       '@jest/test-result': 29.4.3
       '@jest/transform': 29.4.3
-      '@jest/types': 29.4.3
+      '@jest/types': 29.5.0
       '@jridgewell/trace-mapping': 0.3.17
       '@types/node': 16.11.13
       chalk: 4.1.2
@@ -5177,7 +5216,7 @@ packages:
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
       '@jest/console': 29.4.3
-      '@jest/types': 29.4.3
+      '@jest/types': 29.5.0
       '@types/istanbul-lib-coverage': 2.0.4
       collect-v8-coverage: 1.0.1
     dev: false
@@ -5197,7 +5236,7 @@ packages:
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
       '@babel/core': 7.21.0
-      '@jest/types': 29.4.3
+      '@jest/types': 29.5.0
       '@jridgewell/trace-mapping': 0.3.17
       babel-plugin-istanbul: 6.1.1
       chalk: 4.1.2
@@ -5215,6 +5254,29 @@ packages:
       - supports-color
     dev: false
 
+  /@jest/transform/29.5.0:
+    resolution: {integrity: sha512-8vbeZWqLJOvHaDfeMuoHITGKSz5qWc9u04lnWrQE3VyuSw604PzQM824ZeX9XSjUCeDiE3GuxZe5UKa8J61NQw==}
+    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+    dependencies:
+      '@babel/core': 7.21.0
+      '@jest/types': 29.5.0
+      '@jridgewell/trace-mapping': 0.3.17
+      babel-plugin-istanbul: 6.1.1
+      chalk: 4.1.2
+      convert-source-map: 2.0.0
+      fast-json-stable-stringify: 2.1.0
+      graceful-fs: 4.2.10
+      jest-haste-map: 29.5.0
+      jest-regex-util: 29.4.3
+      jest-util: 29.5.0
+      micromatch: 4.0.5
+      pirates: 4.0.5
+      slash: 3.0.0
+      write-file-atomic: 4.0.2
+    transitivePeerDependencies:
+      - supports-color
+    dev: false
+
   /@jest/types/29.4.3:
     resolution: {integrity: sha512-bPYfw8V65v17m2Od1cv44FH+SiKW7w2Xu7trhcdTLUmSv85rfKsP+qXSjO4KGJr4dtPSzl/gvslZBXctf1qGEA==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -5227,6 +5289,18 @@ packages:
       chalk: 4.1.2
     dev: false
 
+  /@jest/types/29.5.0:
+    resolution: {integrity: sha512-qbu7kN6czmVRc3xWFQcAN03RAUamgppVUdXrvl1Wr3jlNF93o9mJbGcDWrwGB6ht44u7efB1qCFgVQmca24Uog==}
+    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+    dependencies:
+      '@jest/schemas': 29.4.3
+      '@types/istanbul-lib-coverage': 2.0.4
+      '@types/istanbul-reports': 3.0.1
+      '@types/node': 16.11.13
+      '@types/yargs': 17.0.22
+      chalk: 4.1.2
+    dev: false
+
   /@jridgewell/gen-mapping/0.1.1:
     resolution: {integrity: sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==}
     engines: {node: '>=6.0.0'}
@@ -9000,6 +9074,10 @@ packages:
       '@types/node': 16.11.13
     dev: true
 
+  /@types/signal-exit/3.0.1:
+    resolution: {integrity: sha512-OSitN9PP9E/c4tlt1Qdj3CAz5uHD9Da5rhUqlaKyQRCX1T7Zdpbk6YdeZbR2eiE2ce+NMBgVnMxGqpaPSNQDUQ==}
+    dev: true
+
   /@types/sockjs/0.3.33:
     resolution: {integrity: sha512-f0KEEe05NvUnat+boPTZ0dgaLZ4SfSouXUgv5noUiefG2ajgKjmETo9ZJyuqsl7dfl2aHlLJUiki6B4ZYldiiw==}
     dependencies:
@@ -10251,7 +10329,7 @@ packages:
       '@babel/core': ^7.8.0
     dependencies:
       '@babel/core': 7.21.0
-      '@jest/transform': 29.4.3
+      '@jest/transform': 29.5.0
       '@types/babel__core': 7.20.0
       babel-plugin-istanbul: 6.1.1
       babel-preset-jest: 29.4.3_@babel+core@7.21.0
@@ -13639,6 +13717,17 @@ packages:
       jest-util: 29.4.3
     dev: false
 
+  /expect/29.5.0:
+    resolution: {integrity: sha512-yM7xqUrCO2JdpFo4XpM82t+PJBFybdqoQuJLDGeDX2ij8NZzqRHyu3Hp188/JX7SWqud+7t4MUdvcgGBICMHZg==}
+    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+    dependencies:
+      '@jest/expect-utils': 29.5.0
+      jest-get-type: 29.4.3
+      jest-matcher-utils: 29.5.0
+      jest-message-util: 29.5.0
+      jest-util: 29.5.0
+    dev: false
+
   /express/4.18.2:
     resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==}
     engines: {node: '>= 0.10.0'}
@@ -15993,23 +16082,23 @@ packages:
     resolution: {integrity: sha512-Vw/bVvcexmdJ7MLmgdT3ZjkJ3LKu8IlpefYokxiqoZy6OCQ2VAm6Vk3t/qHiAGUXbdbJKJWnc8gH3ypTbB/OBw==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
-      '@jest/environment': 29.4.3
-      '@jest/expect': 29.4.3
+      '@jest/environment': 29.5.0
+      '@jest/expect': 29.5.0
       '@jest/test-result': 29.4.3
-      '@jest/types': 29.4.3
+      '@jest/types': 29.5.0
       '@types/node': 16.11.13
       chalk: 4.1.2
       co: 4.6.0
       dedent: 0.7.0
       is-generator-fn: 2.1.0
       jest-each: 29.4.3
-      jest-matcher-utils: 29.4.3
-      jest-message-util: 29.4.3
+      jest-matcher-utils: 29.5.0
+      jest-message-util: 29.5.0
       jest-runtime: 29.4.3
-      jest-snapshot: 29.4.3
-      jest-util: 29.4.3
+      jest-snapshot: 29.5.0
+      jest-util: 29.5.0
       p-limit: 3.1.0
-      pretty-format: 29.4.3
+      pretty-format: 29.5.0
       slash: 3.0.0
       stack-utils: 2.0.6
     transitivePeerDependencies:
@@ -16058,7 +16147,7 @@ packages:
     dependencies:
       '@babel/core': 7.21.0
       '@jest/test-sequencer': 29.4.3
-      '@jest/types': 29.4.3
+      '@jest/types': 29.5.0
       '@types/node': 16.11.13
       babel-jest: 29.4.3_@babel+core@7.21.0
       chalk: 4.1.2
@@ -16092,6 +16181,16 @@ packages:
       jest-get-type: 29.4.3
       pretty-format: 29.4.3
 
+  /jest-diff/29.5.0:
+    resolution: {integrity: sha512-LtxijLLZBduXnHSniy0WMdaHjmQnt3g5sa16W4p0HqukYTTsyTW3GD1q41TyGl5YFXj/5B2U6dlh5FM1LIMgxw==}
+    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+    dependencies:
+      chalk: 4.1.2
+      diff-sequences: 29.4.3
+      jest-get-type: 29.4.3
+      pretty-format: 29.5.0
+    dev: false
+
   /jest-docblock/29.4.3:
     resolution: {integrity: sha512-fzdTftThczeSD9nZ3fzA/4KkHtnmllawWrXO69vtI+L9WjEIuXWs4AmyME7lN5hU7dB0sHhuPfcKofRsUb/2Fg==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -16103,11 +16202,11 @@ packages:
     resolution: {integrity: sha512-1ElHNAnKcbJb/b+L+7j0/w7bDvljw4gTv1wL9fYOczeJrbTbkMGQ5iQPFJ3eFQH19VPTx1IyfePdqSpePKss7Q==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
-      '@jest/types': 29.4.3
+      '@jest/types': 29.5.0
       chalk: 4.1.2
       jest-get-type: 29.4.3
-      jest-util: 29.4.3
-      pretty-format: 29.4.3
+      jest-util: 29.5.0
+      pretty-format: 29.5.0
     dev: false
 
   /jest-environment-jsdom/29.4.3:
@@ -16137,12 +16236,12 @@ packages:
     resolution: {integrity: sha512-gAiEnSKF104fsGDXNkwk49jD/0N0Bqu2K9+aMQXA6avzsA9H3Fiv1PW2D+gzbOSR705bWd2wJZRFEFpV0tXISg==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
-      '@jest/environment': 29.4.3
-      '@jest/fake-timers': 29.4.3
-      '@jest/types': 29.4.3
+      '@jest/environment': 29.5.0
+      '@jest/fake-timers': 29.5.0
+      '@jest/types': 29.5.0
       '@types/node': 16.11.13
-      jest-mock: 29.4.3
-      jest-util: 29.4.3
+      jest-mock: 29.5.0
+      jest-util: 29.5.0
     dev: false
 
   /jest-get-type/29.4.3:
@@ -16153,7 +16252,7 @@ packages:
     resolution: {integrity: sha512-eZIgAS8tvm5IZMtKlR8Y+feEOMfo2pSQkmNbufdbMzMSn9nitgGxF1waM/+LbryO3OkMcKS98SUb+j/cQxp/vQ==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
-      '@jest/types': 29.4.3
+      '@jest/types': 29.5.0
       '@types/graceful-fs': 4.1.6
       '@types/node': 16.11.13
       anymatch: 3.1.3
@@ -16168,12 +16267,31 @@ packages:
       fsevents: 2.3.2
     dev: false
 
+  /jest-haste-map/29.5.0:
+    resolution: {integrity: sha512-IspOPnnBro8YfVYSw6yDRKh/TiCdRngjxeacCps1cQ9cgVN6+10JUcuJ1EabrgYLOATsIAigxA0rLR9x/YlrSA==}
+    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+    dependencies:
+      '@jest/types': 29.5.0
+      '@types/graceful-fs': 4.1.6
+      '@types/node': 16.11.13
+      anymatch: 3.1.3
+      fb-watchman: 2.0.2
+      graceful-fs: 4.2.10
+      jest-regex-util: 29.4.3
+      jest-util: 29.5.0
+      jest-worker: 29.5.0
+      micromatch: 4.0.5
+      walker: 1.0.8
+    optionalDependencies:
+      fsevents: 2.3.2
+    dev: false
+
   /jest-leak-detector/29.4.3:
     resolution: {integrity: sha512-9yw4VC1v2NspMMeV3daQ1yXPNxMgCzwq9BocCwYrRgXe4uaEJPAN0ZK37nFBhcy3cUwEVstFecFLaTHpF7NiGA==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
       jest-get-type: 29.4.3
-      pretty-format: 29.4.3
+      pretty-format: 29.5.0
     dev: false
 
   /jest-matcher-utils/29.4.3:
@@ -16181,9 +16299,19 @@ packages:
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
       chalk: 4.1.2
-      jest-diff: 29.4.3
+      jest-diff: 29.5.0
       jest-get-type: 29.4.3
-      pretty-format: 29.4.3
+      pretty-format: 29.5.0
+    dev: false
+
+  /jest-matcher-utils/29.5.0:
+    resolution: {integrity: sha512-lecRtgm/rjIK0CQ7LPQwzCs2VwW6WAahA55YBuI+xqmhm7LAaxokSB8C97yJeYyT+HvQkH741StzpU41wohhWw==}
+    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+    dependencies:
+      chalk: 4.1.2
+      jest-diff: 29.5.0
+      jest-get-type: 29.4.3
+      pretty-format: 29.5.0
     dev: false
 
   /jest-message-util/29.4.3:
@@ -16191,7 +16319,7 @@ packages:
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
       '@babel/code-frame': 7.18.6
-      '@jest/types': 29.4.3
+      '@jest/types': 29.5.0
       '@types/stack-utils': 2.0.1
       chalk: 4.1.2
       graceful-fs: 4.2.10
@@ -16201,6 +16329,21 @@ packages:
       stack-utils: 2.0.6
     dev: false
 
+  /jest-message-util/29.5.0:
+    resolution: {integrity: sha512-Kijeg9Dag6CKtIDA7O21zNTACqD5MD/8HfIV8pdD94vFyFuer52SigdC3IQMhab3vACxXMiFk+yMHNdbqtyTGA==}
+    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+    dependencies:
+      '@babel/code-frame': 7.18.6
+      '@jest/types': 29.5.0
+      '@types/stack-utils': 2.0.1
+      chalk: 4.1.2
+      graceful-fs: 4.2.10
+      micromatch: 4.0.5
+      pretty-format: 29.5.0
+      slash: 3.0.0
+      stack-utils: 2.0.6
+    dev: false
+
   /jest-mock/29.4.3:
     resolution: {integrity: sha512-LjFgMg+xed9BdkPMyIJh+r3KeHt1klXPJYBULXVVAkbTaaKjPX1o1uVCAZADMEp/kOxGTwy/Ot8XbvgItOrHEg==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -16210,6 +16353,15 @@ packages:
       jest-util: 29.4.3
     dev: false
 
+  /jest-mock/29.5.0:
+    resolution: {integrity: sha512-GqOzvdWDE4fAV2bWQLQCkujxYWL7RxjCnj71b5VhDAGOevB3qj3Ovg26A5NI84ZpODxyzaozXLOh2NCgkbvyaw==}
+    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+    dependencies:
+      '@jest/types': 29.5.0
+      '@types/node': 16.11.13
+      jest-util: 29.5.0
+    dev: false
+
   /jest-pnp-resolver/1.2.3_jest-resolve@29.4.3:
     resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==}
     engines: {node: '>=6'}
@@ -16232,7 +16384,7 @@ packages:
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
       jest-regex-util: 29.4.3
-      jest-snapshot: 29.4.3
+      jest-snapshot: 29.5.0
     transitivePeerDependencies:
       - supports-color
     dev: false
@@ -16257,10 +16409,10 @@ packages:
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
       '@jest/console': 29.4.3
-      '@jest/environment': 29.4.3
+      '@jest/environment': 29.5.0
       '@jest/test-result': 29.4.3
       '@jest/transform': 29.4.3
-      '@jest/types': 29.4.3
+      '@jest/types': 29.5.0
       '@types/node': 16.11.13
       chalk: 4.1.2
       emittery: 0.13.1
@@ -16285,13 +16437,13 @@ packages:
     resolution: {integrity: sha512-F5bHvxSH+LvLV24vVB3L8K467dt3y3dio6V3W89dUz9nzvTpqd/HcT9zfYKL2aZPvD63vQFgLvaUX/UpUhrP6Q==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
-      '@jest/environment': 29.4.3
+      '@jest/environment': 29.5.0
       '@jest/fake-timers': 29.4.3
-      '@jest/globals': 29.4.3
+      '@jest/globals': 29.5.0
       '@jest/source-map': 29.4.3
       '@jest/test-result': 29.4.3
       '@jest/transform': 29.4.3
-      '@jest/types': 29.4.3
+      '@jest/types': 29.5.0
       '@types/node': 16.11.13
       chalk: 4.1.2
       cjs-module-lexer: 1.2.2
@@ -16300,10 +16452,10 @@ packages:
       graceful-fs: 4.2.10
       jest-haste-map: 29.4.3
       jest-message-util: 29.4.3
-      jest-mock: 29.4.3
+      jest-mock: 29.5.0
       jest-regex-util: 29.4.3
       jest-resolve: 29.4.3
-      jest-snapshot: 29.4.3
+      jest-snapshot: 29.5.0
       jest-util: 29.4.3
       slash: 3.0.0
       strip-bom: 4.0.0
@@ -16323,12 +16475,12 @@ packages:
       '@babel/types': 7.21.0
       '@jest/expect-utils': 29.4.3
       '@jest/transform': 29.4.3
-      '@jest/types': 29.4.3
+      '@jest/types': 29.5.0
       '@types/babel__traverse': 7.18.3
       '@types/prettier': 2.7.2
       babel-preset-current-node-syntax: 1.0.1_@babel+core@7.21.0
       chalk: 4.1.2
-      expect: 29.4.3
+      expect: 29.5.0
       graceful-fs: 4.2.10
       jest-diff: 29.4.3
       jest-get-type: 29.4.3
@@ -16343,6 +16495,37 @@ packages:
       - supports-color
     dev: false
 
+  /jest-snapshot/29.5.0:
+    resolution: {integrity: sha512-x7Wolra5V0tt3wRs3/ts3S6ciSQVypgGQlJpz2rsdQYoUKxMxPNaoHMGJN6qAuPJqS+2iQ1ZUn5kl7HCyls84g==}
+    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+    dependencies:
+      '@babel/core': 7.21.0
+      '@babel/generator': 7.21.1
+      '@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.21.0
+      '@babel/plugin-syntax-typescript': 7.20.0_@babel+core@7.21.0
+      '@babel/traverse': 7.21.0
+      '@babel/types': 7.21.0
+      '@jest/expect-utils': 29.5.0
+      '@jest/transform': 29.5.0
+      '@jest/types': 29.5.0
+      '@types/babel__traverse': 7.18.3
+      '@types/prettier': 2.7.2
+      babel-preset-current-node-syntax: 1.0.1_@babel+core@7.21.0
+      chalk: 4.1.2
+      expect: 29.5.0
+      graceful-fs: 4.2.10
+      jest-diff: 29.5.0
+      jest-get-type: 29.4.3
+      jest-matcher-utils: 29.5.0
+      jest-message-util: 29.5.0
+      jest-util: 29.5.0
+      natural-compare: 1.4.0
+      pretty-format: 29.5.0
+      semver: 7.3.8
+    transitivePeerDependencies:
+      - supports-color
+    dev: false
+
   /jest-util/29.4.3:
     resolution: {integrity: sha512-ToSGORAz4SSSoqxDSylWX8JzkOQR7zoBtNRsA7e+1WUX5F8jrOwaNpuh1YfJHJKDHXLHmObv5eOjejUd+/Ws+Q==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -16355,11 +16538,23 @@ packages:
       picomatch: 2.3.1
     dev: false
 
+  /jest-util/29.5.0:
+    resolution: {integrity: sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ==}
+    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+    dependencies:
+      '@jest/types': 29.5.0
+      '@types/node': 16.11.13
+      chalk: 4.1.2
+      ci-info: 3.8.0
+      graceful-fs: 4.2.10
+      picomatch: 2.3.1
+    dev: false
+
   /jest-validate/29.4.3:
     resolution: {integrity: sha512-J3u5v7aPQoXPzaar6GndAVhdQcZr/3osWSgTeKg5v574I9ybX/dTyH0AJFb5XgXIB7faVhf+rS7t4p3lL9qFaw==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
-      '@jest/types': 29.4.3
+      '@jest/types': 29.5.0
       camelcase: 6.3.0
       chalk: 4.1.2
       jest-get-type: 29.4.3
@@ -16372,7 +16567,7 @@ packages:
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
       '@jest/test-result': 29.4.3
-      '@jest/types': 29.4.3
+      '@jest/types': 29.5.0
       '@types/node': 16.11.13
       ansi-escapes: 4.3.2
       chalk: 4.1.2
@@ -16403,7 +16598,17 @@ packages:
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
     dependencies:
       '@types/node': 16.11.13
-      jest-util: 29.4.3
+      jest-util: 29.5.0
+      merge-stream: 2.0.0
+      supports-color: 8.1.1
+    dev: false
+
+  /jest-worker/29.5.0:
+    resolution: {integrity: sha512-NcrQnevGoSp4b5kg+akIpthoAFHxPBcb5P6mYPY0fUNT+sSvmtu6jlkEle3anczUKIKEbMxFimk9oTP/tpIPgA==}
+    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+    dependencies:
+      '@types/node': 16.11.13
+      jest-util: 29.5.0
       merge-stream: 2.0.0
       supports-color: 8.1.1
     dev: false
@@ -19445,6 +19650,15 @@ packages:
       ansi-styles: 5.2.0
       react-is: 18.2.0
 
+  /pretty-format/29.5.0:
+    resolution: {integrity: sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw==}
+    engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+    dependencies:
+      '@jest/schemas': 29.4.3
+      ansi-styles: 5.2.0
+      react-is: 18.2.0
+    dev: false
+
   /pretty-hrtime/1.0.3:
     resolution: {integrity: sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==}
     engines: {node: '>= 0.8'}


Test patch:
diff --git a/packages/keystatic/test/read-local.test.ts b/packages/keystatic/test/read-local.test.ts
new file mode 100644
index 00000000..27e35df2
--- /dev/null
+++ b/packages/keystatic/test/read-local.test.ts
@@ -0,0 +1,84 @@
+import { TreeEntry } from '../app/trees';
+import { readToDirEntries } from '../src/api/read-local';
+import { testdir } from './test-utils';
+
+function short(output: TreeEntry[]) {
+  const obj = Object.fromEntries(output.map(o => [o.path, o.type]));
+  if (Object.keys(obj).length !== output.length) {
+    throw new Error('duplicate key');
+  }
+  return obj;
+}
+
+test('basic', async () => {
+  const dir = await testdir({
+    '.gitignore': 'dist',
+    'something/dist/other.js': '',
+    'something/something.js': '',
+    'a/b/c.md': '',
+    'b.js': '',
+    'a.js': '',
+  });
+  expect(short(await readToDirEntries(dir))).toMatchInlineSnapshot(`
+    {
+      ".gitignore": "blob",
+      "a": "tree",
+      "a.js": "blob",
+      "a/b": "tree",
+      "a/b/c.md": "blob",
+      "b.js": "blob",
+      "something": "tree",
+      "something/something.js": "blob",
+    }
+  `);
+});
+
+test('with a negation', async () => {
+  const dir = await testdir({
+    '.gitignore': 'dist',
+    'something/.gitignore': '!dist',
+    'something/dist/other.js': '',
+    'something/something.js': '',
+    'a/b/c.md': '',
+    'b.js': '',
+    'a.js': '',
+  });
+  expect(short(await readToDirEntries(dir))).toMatchInlineSnapshot(`
+    {
+      ".gitignore": "blob",
+      "a": "tree",
+      "a.js": "blob",
+      "a/b": "tree",
+      "a/b/c.md": "blob",
+      "b.js": "blob",
+      "something": "tree",
+      "something/.gitignore": "blob",
+      "something/something.js": "blob",
+    }
+  `);
+});
+
+test('symlinks are ignored', async () => {
+  const dir = await testdir({
+    other: { kind: 'symlink', path: 'something/other' },
+    'something/other.js': '',
+  });
+  expect(short(await readToDirEntries(dir))).toMatchInlineSnapshot(`
+    {
+      "something": "tree",
+      "something/other.js": "blob",
+    }
+  `);
+});
+
+test('.git is ignored', async () => {
+  const dir = await testdir({
+    '.git/something': '',
+    another: '',
+  });
+  expect(short(await readToDirEntries(dir))).toMatchInlineSnapshot(`
+    {
+      "another": "blob",
+    }
+  `);
+});
diff --git a/packages/keystatic/test/test-utils.ts b/packages/keystatic/test/test-utils.ts
new file mode 100644
index 00000000..c08d6a02
--- /dev/null
+++ b/packages/keystatic/test/test-utils.ts
@@ -0,0 +1,82 @@
+import path from 'path';
+import fs from 'fs/promises';
+import nonPromiseFs from 'fs';
+import outdent from 'outdent';
+import fastGlob from 'fast-glob';
+import onExit from 'signal-exit';
+import { tmpdir } from 'os';
+import { randomUUID } from 'crypto';
+
+export const js = outdent;
+export const ts = outdent;
+export const tsx = outdent;
+
+type Fixture = {
+  [key: string]: string | { kind: 'symlink'; path: string };
+};
+
+const tempDir = nonPromiseFs.realpathSync(tmpdir());
+
+export async function testdir(dir: Fixture) {
+  const temp = path.join(tempDir, randomUUID());
+  onExit(() => {
+    nonPromiseFs.rmSync(temp, { recursive: true, force: true });
+  });
+  await Promise.all(
+    Object.keys(dir).map(async filename => {
+      const output = dir[filename];
+      const fullPath = path.join(temp, filename);
+      const dirname = path.dirname(fullPath);
+      await fs.mkdir(dirname, { recursive: true });
+      if (typeof output === 'string') {
+        await fs.writeFile(fullPath, output);
+      } else {
+        const targetPath = path.resolve(temp, output.path);
+        await fs.symlink(targetPath, fullPath);
+      }
+    })
+  );
+  return temp;
+}
+
+expect.addSnapshotSerializer({
+  print(_val) {
+    const val = _val as Record<string, string>;
+    const contentsByFilename: Record<string, string[]> = {};
+    Object.entries(val).forEach(([filename, contents]) => {
+      if (contentsByFilename[contents] === undefined) {
+        contentsByFilename[contents] = [];
+      }
+      contentsByFilename[contents].push(filename);
+    });
+    return Object.entries(contentsByFilename)
+      .map(([contents, filenames]) => {
+        return `⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ ${filenames.join(
+          ', '
+        )} ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\n${contents}`;
+      })
+      .join('\n');
+  },
+  test(val) {
+    return val && val[dirPrintingSymbol];
+  },
+});
+
+const dirPrintingSymbol = Symbol('dir printing symbol');
+
+export async function getFiles(dir: string, glob: string[] = ['**']) {
+  const files = await fastGlob(glob, { cwd: dir });
+  const filesObj: Record<string, string> = {
+    [dirPrintingSymbol]: true,
+  };
+  await Promise.all(
+    files.map(async filename => {
+      filesObj[filename] = await fs.readFile(path.join(dir, filename), 'utf-8');
+    })
+  );
+  let newObj: Record<string, string> = { [dirPrintingSymbol]: true };
+  files.sort().forEach(filename => {
+    newObj[filename] = filesObj[filename];
+  });
+  return newObj;
+}