presubmit 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ presubmit /absolute/path/to/your/repository
18
18
  For a locally built release, install its tarball instead:
19
19
 
20
20
  ```sh
21
- npm install -g ./presubmit-0.1.0.tgz
21
+ npm install -g ./presubmit-0.1.1.tgz
22
22
  presubmit /absolute/path/to/your/repository
23
23
  ```
24
24
 
@@ -37,6 +37,10 @@ Sessions, uploads, worktrees, and credentials live under `~/.local/share/presubm
37
37
  Set `PRESUBMIT_HOME` to use another private data directory. Keep it outside your
38
38
  repository. These runtime files are not part of the npm package.
39
39
 
40
+ At startup, file watching excludes existing Git-ignored paths, including datasets
41
+ and downloaded models. Restart Presubmit after changing ignore rules or adding a
42
+ large ignored directory so the watcher refreshes its exclusions.
43
+
40
44
  Use `presubmit --help` for CLI options. To follow an existing session in a terminal:
41
45
 
42
46
  ```sh
@@ -1708,21 +1708,24 @@ async function startLockedServer(options, cwd, lock) {
1708
1708
  return refresh;
1709
1709
  }
1710
1710
  let watcher;
1711
+ const watcherError = (error) => {
1712
+ if (!closing)
1713
+ activity({
1714
+ type: 'error',
1715
+ text: 'File watcher unavailable; use Refresh. ' + errorText(error),
1716
+ });
1717
+ };
1711
1718
  try {
1712
- watcher = watchWorkspace(cwd, {
1719
+ watcher = await watchWorkspace(cwd, {
1713
1720
  changed: () => {
1714
1721
  void refreshWorkspace();
1715
1722
  },
1716
- error: (error) => {
1717
- if (!closing)
1718
- activity({
1719
- type: 'error',
1720
- text: 'File watcher unavailable; use Refresh. ' + errorText(error),
1721
- });
1722
- },
1723
+ error: watcherError,
1723
1724
  });
1724
1725
  }
1725
- catch { }
1726
+ catch (error) {
1727
+ watcherError(error);
1728
+ }
1726
1729
  let commandQueue = Promise.resolve();
1727
1730
  function acceptChildReport(id, text) {
1728
1731
  const operation = commandQueue.then(() => {
@@ -1,46 +1,101 @@
1
+ import { execFile } from 'node:child_process';
1
2
  import { realpathSync } from 'node:fs';
3
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
4
+ import { promisify } from 'node:util';
2
5
  import { watch } from 'chokidar';
3
6
  import { diagnosticsEnabled, performanceMetrics, diagnosticGauges } from './diagnostics.js';
4
- export function createWorkspaceWatchers(create = watch, debounceMs = 500) {
7
+ const execute = promisify(execFile);
8
+ /** Snapshot Git's exclusions before opening recursive filesystem subscriptions. */
9
+ export async function workspaceWatchFilter(cwd) {
10
+ const ignoredFiles = new Set();
11
+ const ignoredDirectories = new Set();
12
+ try {
13
+ const { stdout } = await execute('git', ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], {
14
+ cwd,
15
+ encoding: 'utf8',
16
+ maxBuffer: 16 * 1024 * 1024,
17
+ timeout: 30_000,
18
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' },
19
+ });
20
+ for (const name of stdout.split('\0').filter(Boolean)) {
21
+ // Git keeps tracked files out of this list, even when an ignore rule matches.
22
+ if (name.endsWith('/'))
23
+ ignoredDirectories.add(resolve(cwd, name));
24
+ else
25
+ ignoredFiles.add(resolve(cwd, name));
26
+ }
27
+ }
28
+ catch (error) {
29
+ const failure = error;
30
+ if (failure.code !== 'ENOENT' && !failure.stderr?.includes('not a git repository'))
31
+ throw new Error('Could not load Git exclusions; automatic file watching is unavailable.', {
32
+ cause: error,
33
+ });
34
+ }
35
+ return (file) => {
36
+ const name = resolve(file);
37
+ const local = relative(cwd, name);
38
+ if (isAbsolute(local) || local === '..' || local.startsWith('..' + sep))
39
+ return true;
40
+ if (local.split(sep).some((part) => ['node_modules', '.git', 'dist', '.cache'].includes(part)))
41
+ return true;
42
+ if (ignoredFiles.has(name))
43
+ return true;
44
+ for (let parent = name; parent !== cwd; parent = dirname(parent)) {
45
+ if (ignoredDirectories.has(parent))
46
+ return true;
47
+ }
48
+ return false;
49
+ };
50
+ }
51
+ export function createWorkspaceWatchers(create = watch, debounceMs = 500, filter = workspaceWatchFilter) {
5
52
  const entries = new Map();
53
+ let subscribers = 0;
6
54
  const updateGauges = () => {
7
55
  diagnosticGauges.set('workspace.watchers', entries.size);
8
- diagnosticGauges.set('workspace.subscribers', [...entries.values()].reduce((sum, entry) => sum + entry.subscribers.size, 0));
56
+ diagnosticGauges.set('workspace.subscribers', subscribers);
9
57
  };
10
- return (cwd, subscriber) => {
58
+ const subscribe = async (cwd, subscriber) => {
11
59
  const key = realpathSync(cwd);
12
- let entry = entries.get(key);
13
- if (!entry) {
14
- const watcher = create(key, {
15
- ignoreInitial: true,
16
- ignored: (path) => path
17
- .split(/[\\/]/)
18
- .some((segment) => ['node_modules', '.git', 'dist', '.cache'].includes(segment)),
19
- });
20
- entry = { watcher, subscribers: new Set() };
21
- entries.set(key, entry);
22
- const shared = entry;
23
- watcher.on('all', () => {
24
- if (diagnosticsEnabled)
25
- performanceMetrics.count('workspace.fileEvents');
26
- // Coalesce a sustained stream as well as a short burst. Do not allocate
27
- // a fresh timer for every event or postpone refresh indefinitely.
28
- if (shared.timer)
29
- return;
30
- shared.timer = setTimeout(() => {
31
- shared.timer = undefined;
60
+ let pending = entries.get(key);
61
+ if (!pending) {
62
+ pending = filter(key).then((ignored) => {
63
+ const watcher = create(key, { ignoreInitial: true, followSymlinks: false, ignored });
64
+ const entry = { watcher, subscribers: new Set() };
65
+ const shared = entry;
66
+ watcher.on('all', () => {
32
67
  if (diagnosticsEnabled)
33
- performanceMetrics.count('workspace.changeBatches');
68
+ performanceMetrics.count('workspace.fileEvents');
69
+ // Coalesce a sustained stream as well as a short burst. Do not allocate
70
+ // a fresh timer for every event or postpone refresh indefinitely.
71
+ if (shared.timer)
72
+ return;
73
+ shared.timer = setTimeout(() => {
74
+ shared.timer = undefined;
75
+ if (diagnosticsEnabled)
76
+ performanceMetrics.count('workspace.changeBatches');
77
+ for (const listener of shared.subscribers)
78
+ listener.changed();
79
+ }, debounceMs);
80
+ });
81
+ watcher.on('error', (error) => {
34
82
  for (const listener of shared.subscribers)
35
- listener.changed();
36
- }, debounceMs);
83
+ listener.error(error);
84
+ });
85
+ return entry;
37
86
  });
38
- watcher.on('error', (error) => {
39
- for (const listener of shared.subscribers)
40
- listener.error(error);
87
+ entries.set(key, pending);
88
+ void pending.catch(() => {
89
+ if (entries.get(key) === pending)
90
+ entries.delete(key);
41
91
  });
42
92
  }
93
+ const entry = await pending;
94
+ // A last unsubscribe may have closed this entry while initialization was awaited.
95
+ if (entries.get(key) !== pending)
96
+ return subscribe(cwd, subscriber);
43
97
  entry.subscribers.add(subscriber);
98
+ subscribers++;
44
99
  updateGauges();
45
100
  const shared = entry;
46
101
  let closed;
@@ -48,6 +103,7 @@ export function createWorkspaceWatchers(create = watch, debounceMs = 500) {
48
103
  close() {
49
104
  if (closed)
50
105
  return closed;
106
+ subscribers--;
51
107
  shared.subscribers.delete(subscriber);
52
108
  if (!shared.subscribers.size) {
53
109
  entries.delete(key);
@@ -61,5 +117,6 @@ export function createWorkspaceWatchers(create = watch, debounceMs = 500) {
61
117
  },
62
118
  };
63
119
  };
120
+ return subscribe;
64
121
  }
65
122
  export const watchWorkspace = createWorkspaceWatchers();
@@ -24,6 +24,8 @@ async function gitRoot(cwd) {
24
24
  const failure = error;
25
25
  if (failure.code === 'ENOENT' || failure.stderr?.includes('not a git repository'))
26
26
  return null;
27
+ if (failure.code === 'EMFILE' || failure.code === 'ENFILE')
28
+ throw new Error('Too many open files to inspect Git. Stop and restart Presubmit to release file watchers.', { cause: error });
27
29
  throw new Error('Could not inspect the folder’s Git repository.', { cause: error });
28
30
  }
29
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "presubmit",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A local coding agent and human review workspace",
5
5
  "type": "module",
6
6
  "bin": {