@bevel-software/platform-core-backend 0.9.1 → 0.11.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/THIRD-PARTY-NOTICES.md +2 -2
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +16 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +25 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +45 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts +19 -1
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +44 -4
- package/dist/core-config.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +54 -28
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +129 -14
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +435 -66
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts.map +1 -1
- package/dist/modules/access/access-declarations.js +5 -3
- package/dist/modules/access/access-declarations.js.map +1 -1
- package/dist/modules/access/access-mutation.service.d.ts +39 -6
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +78 -18
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.d.ts +31 -4
- package/dist/modules/access/access-splice.d.ts.map +1 -1
- package/dist/modules/access/access-splice.js +40 -16
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +204 -82
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts +134 -0
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
- package/dist/modules/access/admin-locked-commit.js +277 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -0
- package/dist/modules/access/admin-route-helpers.d.ts +32 -0
- package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
- package/dist/modules/access/admin-route-helpers.js +44 -0
- package/dist/modules/access/admin-route-helpers.js.map +1 -0
- package/dist/modules/access/capability-registry.d.ts +41 -0
- package/dist/modules/access/capability-registry.d.ts.map +1 -0
- package/dist/modules/access/capability-registry.js +46 -0
- package/dist/modules/access/capability-registry.js.map +1 -0
- package/dist/modules/access/directory-sync-bot.d.ts +13 -0
- package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
- package/dist/modules/access/directory-sync-bot.js +64 -0
- package/dist/modules/access/directory-sync-bot.js.map +1 -0
- package/dist/modules/access/group-files.d.ts +83 -0
- package/dist/modules/access/group-files.d.ts.map +1 -0
- package/dist/modules/access/group-files.js +167 -0
- package/dist/modules/access/group-files.js.map +1 -0
- package/dist/modules/access/groups-admin.routes.d.ts +19 -0
- package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.routes.js +98 -0
- package/dist/modules/access/groups-admin.routes.js.map +1 -0
- package/dist/modules/access/groups-admin.service.d.ts +166 -0
- package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.service.js +442 -0
- package/dist/modules/access/groups-admin.service.js.map +1 -0
- package/dist/modules/access/groups-edit.d.ts +58 -0
- package/dist/modules/access/groups-edit.d.ts.map +1 -0
- package/dist/modules/access/groups-edit.js +162 -0
- package/dist/modules/access/groups-edit.js.map +1 -0
- package/dist/modules/access/reference-scan.d.ts +141 -0
- package/dist/modules/access/reference-scan.d.ts.map +1 -0
- package/dist/modules/access/reference-scan.js +440 -0
- package/dist/modules/access/reference-scan.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +88 -119
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +230 -384
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/access/roles-edit.d.ts +51 -25
- package/dist/modules/access/roles-edit.d.ts.map +1 -1
- package/dist/modules/access/roles-edit.js +133 -59
- package/dist/modules/access/roles-edit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.d.ts +28 -0
- package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-committer.js +139 -0
- package/dist/modules/access/synced-groups-committer.js.map +1 -0
- package/dist/modules/access/synced-groups-writer.d.ts +78 -0
- package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-writer.js +219 -0
- package/dist/modules/access/synced-groups-writer.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +17 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +9 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +9 -3
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +126 -2
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +14 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +6 -1
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/update-check/update-check.routes.d.ts +13 -0
- package/dist/modules/update-check/update-check.routes.d.ts.map +1 -0
- package/dist/modules/update-check/update-check.routes.js +21 -0
- package/dist/modules/update-check/update-check.routes.js.map +1 -0
- package/dist/modules/update-check/update-check.service.d.ts +57 -0
- package/dist/modules/update-check/update-check.service.d.ts.map +1 -0
- package/dist/modules/update-check/update-check.service.js +109 -0
- package/dist/modules/update-check/update-check.service.js.map +1 -0
- package/dist/modules/workflow/file-lock.service.d.ts +11 -1
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +15 -1
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +34 -11
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +173 -37
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
- package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/workflow/locking-filesystem.js +181 -28
- package/dist/modules/workflow/locking-filesystem.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +19 -1
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +17 -2
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +113 -19
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/version.d.ts +14 -0
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +25 -0
- package/dist/version.js.map +1 -1
- package/kb-template/AGENTS.md +4 -1
- package/migrations/0004_file_lock_mode.sql +2 -0
- package/migrations/meta/0004_snapshot.json +1564 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +4 -4
- package/src/__tests__/core-config.domain.test.ts +92 -0
- package/src/core/create-core-server.ts +21 -0
- package/src/core/create-core-services.ts +82 -0
- package/src/core-config.ts +48 -4
- package/src/index.ts +10 -0
- package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
- package/src/modules/access/__tests__/access-groups.test.ts +427 -0
- package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
- package/src/modules/access/__tests__/access-splice.test.ts +65 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
- package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
- package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
- package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
- package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
- package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
- package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
- package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
- package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
- package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
- package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
- package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
- package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
- package/src/modules/access/access-control.interface.ts +66 -32
- package/src/modules/access/access-control.service.ts +536 -73
- package/src/modules/access/access-declarations.ts +5 -2
- package/src/modules/access/access-mutation.service.ts +88 -14
- package/src/modules/access/access-splice.ts +55 -17
- package/src/modules/access/access.routes.ts +227 -93
- package/src/modules/access/admin-locked-commit.ts +331 -0
- package/src/modules/access/admin-route-helpers.ts +55 -0
- package/src/modules/access/capability-registry.ts +74 -0
- package/src/modules/access/directory-sync-bot.ts +76 -0
- package/src/modules/access/group-files.ts +212 -0
- package/src/modules/access/groups-admin.routes.ts +113 -0
- package/src/modules/access/groups-admin.service.ts +551 -0
- package/src/modules/access/groups-edit.ts +187 -0
- package/src/modules/access/reference-scan.ts +513 -0
- package/src/modules/access/roles-admin.service.ts +290 -418
- package/src/modules/access/roles-edit.ts +134 -61
- package/src/modules/access/synced-groups-committer.ts +177 -0
- package/src/modules/access/synced-groups-writer.ts +303 -0
- package/src/modules/database/core-schema.ts +9 -0
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
- package/src/modules/mcp/mcp.routes.ts +137 -2
- package/src/modules/mcp/mcp.service.ts +6 -1
- package/src/modules/update-check/__tests__/update-check.routes.test.ts +69 -0
- package/src/modules/update-check/__tests__/update-check.service.test.ts +170 -0
- package/src/modules/update-check/update-check.routes.ts +28 -0
- package/src/modules/update-check/update-check.service.ts +130 -0
- package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
- package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
- package/src/modules/workflow/file-lock.service.ts +15 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
- package/src/modules/workflow/git/git.service.ts +182 -35
- package/src/modules/workflow/locking-filesystem.ts +188 -26
- package/src/modules/workflow/pending-commits.service.ts +27 -1
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
- package/src/modules/workflow/workflow.service.ts +140 -20
- package/src/version.ts +28 -0
|
@@ -126,7 +126,6 @@ function recordingAccessControl(opts: {
|
|
|
126
126
|
findEmailByHash: async () => null,
|
|
127
127
|
kbPrincipals: async () => ({ plugins: [], people: [] }),
|
|
128
128
|
validateRolesYaml: () => ({ ok: true }),
|
|
129
|
-
referencesToRole: async () => [],
|
|
130
129
|
};
|
|
131
130
|
return { ac, calls };
|
|
132
131
|
}
|
|
@@ -102,4 +102,136 @@ describe('GitService.commitChanges', () => {
|
|
|
102
102
|
const change = await svc.commitChanges(workspaceId, USER, 'no-op');
|
|
103
103
|
expect(change).toBeNull();
|
|
104
104
|
});
|
|
105
|
+
|
|
106
|
+
it('onlyPaths scopes the commit — an unrelated dirty file is NOT swept in', async () => {
|
|
107
|
+
const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
|
|
108
|
+
const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), makeValidator(), PROCESS_MAP_DIR);
|
|
109
|
+
|
|
110
|
+
// The batch's own file, plus a CONCURRENT save's bytes whose commit is
|
|
111
|
+
// still queued — the shared per-branch workspace makes this ordinary.
|
|
112
|
+
await fs.writeFile(path.join(repo, 'synced-groups.yaml'), 'groups: {}\n');
|
|
113
|
+
await fs.writeFile(path.join(repo, 'Old.md'), 'someone else mid-save\n');
|
|
114
|
+
|
|
115
|
+
const change = await svc.commitChanges(workspaceId, USER, 'Sync directory groups', [
|
|
116
|
+
`${PROCESS_MAP_DIR}/synced-groups.yaml`,
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
expect(change?.sha).toBeTruthy();
|
|
120
|
+
const { stdout: nameStatus } = await execFileAsync('git', ['-C', repo, 'show', '--name-status', '--pretty=format:', 'HEAD']);
|
|
121
|
+
expect(nameStatus).toMatch(/A\s+synced-groups\.yaml/);
|
|
122
|
+
expect(nameStatus).not.toMatch(/Old\.md/);
|
|
123
|
+
// The unrelated file stays dirty for its OWN queued commit.
|
|
124
|
+
const { stdout: status } = await execFileAsync('git', ['-C', repo, 'status', '--porcelain']);
|
|
125
|
+
expect(status).toContain('Old.md');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('onlyPaths keeps a staged RENAME whole — old path deletion rides the scoped commit', async () => {
|
|
129
|
+
const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
|
|
130
|
+
const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), makeValidator(), PROCESS_MAP_DIR);
|
|
131
|
+
|
|
132
|
+
// A staged rename shows as one `R` porcelain record (new-path + old-path).
|
|
133
|
+
// The scope names only the NEW path; the old path must ride along or the
|
|
134
|
+
// rename decays into an add that leaves Old.md alive in HEAD.
|
|
135
|
+
await runGit(repo, ['mv', 'Old.md', 'New.md']);
|
|
136
|
+
const change = await svc.commitChanges(workspaceId, USER, 'Rename via batch', [
|
|
137
|
+
`${PROCESS_MAP_DIR}/New.md`,
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
expect(change?.sha).toBeTruthy();
|
|
141
|
+
const { stdout: nameStatus } = await execFileAsync('git', ['-C', repo, 'show', '--name-status', '-M', '--pretty=format:', 'HEAD']);
|
|
142
|
+
expect(nameStatus).toMatch(/R\d*\s+Old\.md\s+New\.md/);
|
|
143
|
+
// Old.md is gone from HEAD — not left behind as a live file.
|
|
144
|
+
const { stdout: lsTree } = await execFileAsync('git', ['-C', repo, 'ls-tree', '-r', '--name-only', 'HEAD']);
|
|
145
|
+
expect(lsTree).not.toContain('Old.md');
|
|
146
|
+
expect(lsTree).toContain('New.md');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('a REAL `git add` failure aborts the scoped commit — only the pathspec miss is tolerated', async () => {
|
|
150
|
+
const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
|
|
151
|
+
const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), makeValidator(), PROCESS_MAP_DIR);
|
|
152
|
+
|
|
153
|
+
await fs.writeFile(path.join(repo, 'Old.md'), 'new bytes\n');
|
|
154
|
+
// Hold git's index lock: `git add` now fails with a real error (index
|
|
155
|
+
// contention), which must PROPAGATE — swallowing it would let the commit
|
|
156
|
+
// record stale index content as this caller's change.
|
|
157
|
+
await fs.writeFile(path.join(repo, '.git', 'index.lock'), '');
|
|
158
|
+
try {
|
|
159
|
+
await expect(
|
|
160
|
+
svc.commitChanges(workspaceId, USER, 'must not land', [`${PROCESS_MAP_DIR}/Old.md`]),
|
|
161
|
+
).rejects.toThrow(/git (add|commit) failed/);
|
|
162
|
+
} finally {
|
|
163
|
+
await fs.rm(path.join(repo, '.git', 'index.lock'), { force: true });
|
|
164
|
+
}
|
|
165
|
+
// Nothing was committed — still just the seed commit.
|
|
166
|
+
const { stdout } = await execFileAsync('git', ['-C', repo, 'log', '--oneline']);
|
|
167
|
+
expect(stdout.trim().split('\n')).toHaveLength(1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('a several-hundred-file scoped batch commits (pathspecs ride stdin, not argv)', async () => {
|
|
171
|
+
// ~300 long paths would blow Windows' ~32K argv limit if the add/commit
|
|
172
|
+
// pathspecs were passed on the command line — the scoped path feeds them
|
|
173
|
+
// via `--pathspec-from-file=-` on stdin instead.
|
|
174
|
+
const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
|
|
175
|
+
const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), makeValidator(), PROCESS_MAP_DIR);
|
|
176
|
+
|
|
177
|
+
const dir = 'Product/Knowledge/A Rather Long Folder Name To Inflate The Pathspec Bytes';
|
|
178
|
+
await fs.mkdir(path.join(repo, dir), { recursive: true });
|
|
179
|
+
const batch: string[] = [];
|
|
180
|
+
for (let i = 0; i < 300; i++) {
|
|
181
|
+
const rel = `${dir}/A quite long knowledge node file name number ${String(i).padStart(3, '0')}.md`;
|
|
182
|
+
await fs.writeFile(path.join(repo, rel), `# node ${i}\n`);
|
|
183
|
+
batch.push(`${PROCESS_MAP_DIR}/${rel}`);
|
|
184
|
+
}
|
|
185
|
+
// An unrelated dirty file proves the scope still holds at this size.
|
|
186
|
+
await fs.writeFile(path.join(repo, 'Old.md'), 'someone else mid-save\n');
|
|
187
|
+
|
|
188
|
+
const change = await svc.commitChanges(workspaceId, USER, 'Bulk import (300 nodes)', batch);
|
|
189
|
+
|
|
190
|
+
expect(change?.sha).toBeTruthy();
|
|
191
|
+
const { stdout: names } = await execFileAsync(
|
|
192
|
+
'git',
|
|
193
|
+
['-C', repo, 'show', '--name-only', '--pretty=format:', 'HEAD'],
|
|
194
|
+
{ maxBuffer: 16 * 1024 * 1024 },
|
|
195
|
+
);
|
|
196
|
+
const committed = names.split('\n').filter(Boolean);
|
|
197
|
+
expect(committed).toHaveLength(300);
|
|
198
|
+
expect(committed).not.toContain('Old.md');
|
|
199
|
+
const { stdout: status } = await execFileAsync('git', ['-C', repo, 'status', '--porcelain']);
|
|
200
|
+
expect(status).toContain('Old.md'); // untouched, still dirty
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('a big batch containing a fully-staged deletion still commits (per-path fallback)', async () => {
|
|
204
|
+
const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
|
|
205
|
+
const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), makeValidator(), PROCESS_MAP_DIR);
|
|
206
|
+
|
|
207
|
+
// Stage Old.md's deletion fully — the batched `git add` then has nothing
|
|
208
|
+
// in the worktree to match for it and fails with the ONE expected miss,
|
|
209
|
+
// which must fall back to tolerant per-path adds, not abort the batch.
|
|
210
|
+
await runGit(repo, ['rm', 'Old.md']);
|
|
211
|
+
await fs.writeFile(path.join(repo, 'New-A.md'), 'a\n');
|
|
212
|
+
await fs.writeFile(path.join(repo, 'New-B.md'), 'b\n');
|
|
213
|
+
|
|
214
|
+
const change = await svc.commitChanges(workspaceId, USER, 'Delete + add batch', [
|
|
215
|
+
`${PROCESS_MAP_DIR}/Old.md`,
|
|
216
|
+
`${PROCESS_MAP_DIR}/New-A.md`,
|
|
217
|
+
`${PROCESS_MAP_DIR}/New-B.md`,
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
expect(change?.sha).toBeTruthy();
|
|
221
|
+
const { stdout: nameStatus } = await execFileAsync('git', ['-C', repo, 'show', '--name-status', '--pretty=format:', 'HEAD']);
|
|
222
|
+
expect(nameStatus).toMatch(/D\s+Old\.md/);
|
|
223
|
+
expect(nameStatus).toMatch(/A\s+New-A\.md/);
|
|
224
|
+
expect(nameStatus).toMatch(/A\s+New-B\.md/);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('onlyPaths with nothing of its own dirty is a no-op even when other files are dirty', async () => {
|
|
228
|
+
const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
|
|
229
|
+
const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), makeValidator(), PROCESS_MAP_DIR);
|
|
230
|
+
|
|
231
|
+
await fs.writeFile(path.join(repo, 'Old.md'), 'someone else mid-save\n');
|
|
232
|
+
const change = await svc.commitChanges(workspaceId, USER, 'Sync directory groups', [
|
|
233
|
+
`${PROCESS_MAP_DIR}/synced-groups.yaml`,
|
|
234
|
+
]);
|
|
235
|
+
expect(change).toBeNull();
|
|
236
|
+
});
|
|
105
237
|
});
|
|
@@ -136,7 +136,6 @@ function fakeAccessControl(adminEmails: string[]): IAccessControl {
|
|
|
136
136
|
findEmailByHash: async () => null,
|
|
137
137
|
kbPrincipals: async () => ({ plugins: [], people: [] }),
|
|
138
138
|
validateRolesYaml: () => ({ ok: true }),
|
|
139
|
-
referencesToRole: async () => [],
|
|
140
139
|
canWriteAtRef: async () => null,
|
|
141
140
|
canWriteBatchAtRef: async () => null,
|
|
142
141
|
eligibleWritersAtRef: async () => null,
|
|
@@ -66,7 +66,6 @@ function makeAccessControl(byRef: WritersByRefAndPath): IAccessControl {
|
|
|
66
66
|
findEmailByHash: async () => null,
|
|
67
67
|
kbPrincipals: async () => ({ plugins: [], people: [] }),
|
|
68
68
|
validateRolesYaml: () => ({ ok: true }),
|
|
69
|
-
referencesToRole: async () => [],
|
|
70
69
|
};
|
|
71
70
|
}
|
|
72
71
|
|
|
@@ -68,8 +68,23 @@ const BOT_EMAIL = 'bevel-workflow@bevel.software';
|
|
|
68
68
|
* we keep the new path and discard the old one.
|
|
69
69
|
*/
|
|
70
70
|
export function parsePorcelainZ(stdout: string): string[] {
|
|
71
|
+
return parsePorcelainZDetailed(stdout).paths;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Like {@link parsePorcelainZ}, but also reports each rename/copy entry's
|
|
76
|
+
* OLD path (keyed by the new path). A pathspec-scoped commit needs the old
|
|
77
|
+
* path too: committing only the new path of a staged rename records an ADD
|
|
78
|
+
* and leaves the deletion of the old path behind — the rename becomes two
|
|
79
|
+
* files.
|
|
80
|
+
*/
|
|
81
|
+
export function parsePorcelainZDetailed(stdout: string): {
|
|
82
|
+
paths: string[];
|
|
83
|
+
renameOldByNew: Map<string, string>;
|
|
84
|
+
} {
|
|
71
85
|
const tokens = stdout.split('\0').filter((t) => t.length > 0);
|
|
72
86
|
const paths = new Set<string>();
|
|
87
|
+
const renameOldByNew = new Map<string, string>();
|
|
73
88
|
let i = 0;
|
|
74
89
|
while (i < tokens.length) {
|
|
75
90
|
const token = tokens[i];
|
|
@@ -77,12 +92,19 @@ export function parsePorcelainZ(stdout: string): string[] {
|
|
|
77
92
|
if (token.length < 4) { i++; continue; }
|
|
78
93
|
const x = token[0];
|
|
79
94
|
const y = token[1];
|
|
80
|
-
|
|
95
|
+
const newPath = token.slice(3);
|
|
96
|
+
paths.add(newPath);
|
|
81
97
|
// Rename or copy in either the index or worktree position carries an
|
|
82
|
-
// extra NUL-separated old-path field
|
|
83
|
-
|
|
98
|
+
// extra NUL-separated old-path field.
|
|
99
|
+
if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
|
|
100
|
+
const oldPath = tokens[i + 1];
|
|
101
|
+
if (oldPath) renameOldByNew.set(newPath, oldPath);
|
|
102
|
+
i += 2;
|
|
103
|
+
} else {
|
|
104
|
+
i += 1;
|
|
105
|
+
}
|
|
84
106
|
}
|
|
85
|
-
return Array.from(paths);
|
|
107
|
+
return { paths: Array.from(paths), renameOldByNew };
|
|
86
108
|
}
|
|
87
109
|
|
|
88
110
|
/**
|
|
@@ -970,23 +992,30 @@ export class GitService implements IGitService {
|
|
|
970
992
|
}
|
|
971
993
|
|
|
972
994
|
/**
|
|
973
|
-
* Atomic multi-file commit — commits
|
|
974
|
-
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
*
|
|
978
|
-
*
|
|
995
|
+
* Atomic multi-file commit — commits the caller's batch as ONE commit
|
|
996
|
+
* attributed to `user`, then returns its attribution (or null when nothing
|
|
997
|
+
* in scope is dirty — idempotent re-apply). The multi-file sibling of
|
|
998
|
+
* `commit()`, WITHOUT the one-file-per-change guard — the caller has
|
|
999
|
+
* assembled + written + validated a batch (bulk node upload; the lock-aware
|
|
1000
|
+
* saves and admin roster writes via `LockingFilesystem` /
|
|
1001
|
+
* `AdminLockedCommits`).
|
|
1002
|
+
*
|
|
1003
|
+
* `onlyPaths` (workspace-relative) scopes staging + commit to the caller's
|
|
1004
|
+
* own files via git pathspecs: on the shared per-branch workspace other
|
|
1005
|
+
* paths may be dirty from a concurrent save whose commit is still queued,
|
|
1006
|
+
* and sweeping them in would land them under this caller's author/summary.
|
|
1007
|
+
* Omitted → the whole dirty set (`git add -A`), for flows that own the
|
|
1008
|
+
* workspace.
|
|
979
1009
|
*
|
|
980
1010
|
* Deliberately does NOT write file content itself — disk writes are the
|
|
981
|
-
* caller's job. This is the git layer: it only stages + commits what is
|
|
982
|
-
* on disk
|
|
983
|
-
* `git add -A` stages exactly the caller's batch). Caller pushes separately
|
|
984
|
-
* (mirrors `commitFile` + `push`).
|
|
1011
|
+
* caller's job. This is the git layer: it only stages + commits what is
|
|
1012
|
+
* already on disk. Caller pushes separately (mirrors `commitFile` + `push`).
|
|
985
1013
|
*/
|
|
986
1014
|
async commitChanges(
|
|
987
1015
|
workspaceId: string,
|
|
988
1016
|
user: AuthUser,
|
|
989
1017
|
summary: string,
|
|
1018
|
+
onlyPaths?: string[],
|
|
990
1019
|
): Promise<CommitAttribution | null> {
|
|
991
1020
|
assertValidAuthor(user);
|
|
992
1021
|
const subject = summary?.trim();
|
|
@@ -1000,8 +1029,27 @@ export class GitService implements IGitService {
|
|
|
1000
1029
|
|
|
1001
1030
|
// Nothing dirty → no-op (idempotent). Compute touched BEFORE `git add` so
|
|
1002
1031
|
// the protected-branch gate sees the same path set `commit()` would.
|
|
1003
|
-
|
|
1004
|
-
|
|
1032
|
+
// `--untracked-files=all` lists every untracked FILE individually —
|
|
1033
|
+
// without it a brand-new directory shows as one `?? dir/` entry, so a
|
|
1034
|
+
// scoped batch creating files under a new folder would match nothing
|
|
1035
|
+
// against `onlyPaths` and silently no-op.
|
|
1036
|
+
const { stdout: porcelain } = await this.git(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
|
|
1037
|
+
const detailed = parsePorcelainZDetailed(porcelain);
|
|
1038
|
+
let touched = detailed.paths;
|
|
1039
|
+
// Scope to the caller's own paths (see the interface doc): other files
|
|
1040
|
+
// may be dirty from a concurrent same-branch save whose commit is still
|
|
1041
|
+
// queued — sweeping them in would land them under this caller's
|
|
1042
|
+
// author/summary, and gate the commit on paths the caller never touched.
|
|
1043
|
+
// An in-scope RENAME keeps its old path too: a pathspec naming only the
|
|
1044
|
+
// new path records an add and strands the deletion of the old one.
|
|
1045
|
+
if (onlyPaths) {
|
|
1046
|
+
const scope = new Set(onlyPaths.map((p) => this.stripRepoPrefix(p)));
|
|
1047
|
+
touched = touched.filter((p) => scope.has(p));
|
|
1048
|
+
for (const p of touched.slice()) {
|
|
1049
|
+
const oldPath = detailed.renameOldByNew.get(p);
|
|
1050
|
+
if (oldPath && !touched.includes(oldPath)) touched.push(oldPath);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1005
1053
|
if (touched.length === 0) return null;
|
|
1006
1054
|
|
|
1007
1055
|
// Protected-branch access gate (defence in depth — callers already gate at
|
|
@@ -1011,20 +1059,83 @@ export class GitService implements IGitService {
|
|
|
1011
1059
|
await this.assertCanWriteAtRef(workspaceId, 'HEAD', user.email, touched);
|
|
1012
1060
|
}
|
|
1013
1061
|
|
|
1014
|
-
|
|
1015
|
-
|
|
1062
|
+
// Scoped → stage the batch (a brand-new file must be `add`ed before a
|
|
1063
|
+
// pathspec commit will know it), then commit WITH the pathspec: it
|
|
1064
|
+
// records exactly those paths, so neither other dirty files nor an
|
|
1065
|
+
// unrelated entry someone left staged can ride along.
|
|
1066
|
+
//
|
|
1067
|
+
// Both stages feed the pathspecs over STDIN (`--pathspec-from-file=-`,
|
|
1068
|
+
// NUL-separated) instead of argv: a several-hundred-file batch of long
|
|
1069
|
+
// KB paths would otherwise blow Windows' ~32K command-line limit. The
|
|
1070
|
+
// add is ONE spawn for the whole batch; only when git reports the ONE
|
|
1071
|
+
// expected miss — a fully-staged deletion (or the old half of a rename)
|
|
1072
|
+
// has nothing left in the worktree to match, which fails the entire
|
|
1073
|
+
// batched add — do we fall back to per-path adds with the same
|
|
1074
|
+
// tolerance, in argv-safe chunks of one path per spawn.
|
|
1075
|
+
if (onlyPaths) {
|
|
1076
|
+
const pathspecInput = touched.join('\0');
|
|
1077
|
+
const pathspecArgs = ['--pathspec-from-file=-', '--pathspec-file-nul'];
|
|
1078
|
+
const isExpectedMiss = (err: unknown): boolean => {
|
|
1079
|
+
// (LC_ALL=C pins the message to English.)
|
|
1080
|
+
const e = err as Error & { stderr?: string };
|
|
1081
|
+
return /did not match any files/.test(`${e.stderr ?? ''}\n${e.message}`);
|
|
1082
|
+
};
|
|
1083
|
+
try {
|
|
1084
|
+
await this.git(cwd, ['add', '-A', ...pathspecArgs], { input: pathspecInput });
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
// Any other add failure — index.lock contention, I/O — must abort:
|
|
1087
|
+
// committing without staging would record stale index content as
|
|
1088
|
+
// this caller's change.
|
|
1089
|
+
if (!isExpectedMiss(err)) throw err;
|
|
1090
|
+
for (const p of touched) {
|
|
1091
|
+
try {
|
|
1092
|
+
await this.git(cwd, ['add', '-A', '--', p]);
|
|
1093
|
+
} catch (perPathErr) {
|
|
1094
|
+
if (!isExpectedMiss(perPathErr)) throw perPathErr;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
await this.git(
|
|
1099
|
+
cwd,
|
|
1100
|
+
['commit', `--author=${user.name} <${user.email}>`, '-m', subject, ...pathspecArgs],
|
|
1101
|
+
{ input: pathspecInput },
|
|
1102
|
+
);
|
|
1103
|
+
} else {
|
|
1104
|
+
await this.git(cwd, ['add', '-A']);
|
|
1105
|
+
await this.git(cwd, ['commit', `--author=${user.name} <${user.email}>`, '-m', subject]);
|
|
1106
|
+
}
|
|
1016
1107
|
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1108
|
+
// CONTRACT: past this point the commit EXISTS, so this method must not
|
|
1109
|
+
// throw — callers (writeAndCommitLocked) treat a throw as "nothing was
|
|
1110
|
+
// committed" and restore their pre-edit bytes, which here would publish
|
|
1111
|
+
// a compensating revert of a commit that DID land. The attribution read
|
|
1112
|
+
// is best-effort; on failure fall back to what we already know.
|
|
1113
|
+
let sha = '';
|
|
1114
|
+
let authorName = user.name;
|
|
1115
|
+
let authorEmail = user.email;
|
|
1116
|
+
let subj = subject;
|
|
1117
|
+
let committedAt = '';
|
|
1118
|
+
try {
|
|
1119
|
+
const { stdout } = await this.git(cwd, [
|
|
1120
|
+
'log', '-1', '--pretty=format:%H%x00%an%x00%ae%x00%s%x00%aI',
|
|
1121
|
+
]);
|
|
1122
|
+
[sha, authorName, authorEmail, subj, committedAt] = stdout.split('\x00');
|
|
1123
|
+
} catch {
|
|
1124
|
+
// The commit landed; only its attribution read failed. Give the sha
|
|
1125
|
+
// one simpler second chance — callers use it as the commit id, and an
|
|
1126
|
+
// empty sha would make a landed change unidentifiable downstream.
|
|
1127
|
+
try {
|
|
1128
|
+
const { stdout } = await this.git(cwd, ['rev-parse', 'HEAD']);
|
|
1129
|
+
sha = stdout;
|
|
1130
|
+
} catch { /* keep the fallback attribution */ }
|
|
1131
|
+
}
|
|
1021
1132
|
this.accessControl?.invalidate(workspaceId);
|
|
1022
1133
|
return {
|
|
1023
1134
|
sha: sha?.trim() ?? '',
|
|
1024
1135
|
authorName: authorName ?? '',
|
|
1025
1136
|
authorEmail: authorEmail ?? '',
|
|
1026
1137
|
subject: subj ?? subject,
|
|
1027
|
-
committedAt: committedAt?.trim()
|
|
1138
|
+
committedAt: committedAt?.trim() || new Date().toISOString(),
|
|
1028
1139
|
};
|
|
1029
1140
|
});
|
|
1030
1141
|
}
|
|
@@ -1467,18 +1578,26 @@ export class GitService implements IGitService {
|
|
|
1467
1578
|
* Used by the pending-commits worker's no-op arm: a clean tree does NOT
|
|
1468
1579
|
* mean "nothing to share" when a prior best-effort push (the autosave
|
|
1469
1580
|
* path) failed and left the committed change stranded locally.
|
|
1581
|
+
*
|
|
1582
|
+
* Serialized under the workspace mutex: a pull-rebase in flight moves HEAD
|
|
1583
|
+
* through states where the local commits are momentarily unreachable, and
|
|
1584
|
+
* an unserialized read there answers "nothing unpushed" about work that is
|
|
1585
|
+
* about to be replayed — callers use this as a publication PROOF, so it may
|
|
1586
|
+
* only ever see settled states.
|
|
1470
1587
|
*/
|
|
1471
1588
|
async hasUnpushedCommits(workspaceId: string): Promise<boolean> {
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1589
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1590
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1591
|
+
const branch = await this.currentBranch(cwd);
|
|
1592
|
+
try {
|
|
1593
|
+
const { stdout } = await this.git(cwd, [
|
|
1594
|
+
'rev-list', '--count', `refs/remotes/origin/${branch}..HEAD`,
|
|
1595
|
+
]);
|
|
1596
|
+
return Number(stdout.trim()) > 0;
|
|
1597
|
+
} catch {
|
|
1598
|
+
return true;
|
|
1599
|
+
}
|
|
1600
|
+
});
|
|
1482
1601
|
}
|
|
1483
1602
|
|
|
1484
1603
|
/**
|
|
@@ -1680,6 +1799,14 @@ export class GitService implements IGitService {
|
|
|
1680
1799
|
try {
|
|
1681
1800
|
const { stdout } = await this.git(cwd, [
|
|
1682
1801
|
'show',
|
|
1802
|
+
// Patch only, no commit header — and not for looks: whether `show`
|
|
1803
|
+
// prints the header for a commit the pathspec does not touch is
|
|
1804
|
+
// git-version-dependent (older gits simplified the commit away,
|
|
1805
|
+
// newer ones print the header over an empty diff). Suppressing the
|
|
1806
|
+
// format makes "commit didn't touch the file" mean an EMPTY string
|
|
1807
|
+
// on every git. The metadata the header carried already reaches the
|
|
1808
|
+
// history view via `logForFile`.
|
|
1809
|
+
'--format=',
|
|
1683
1810
|
sha,
|
|
1684
1811
|
'--',
|
|
1685
1812
|
repoRelativePath,
|
|
@@ -2271,9 +2398,21 @@ export class GitService implements IGitService {
|
|
|
2271
2398
|
return stdout.trim().length > 0;
|
|
2272
2399
|
}
|
|
2273
2400
|
|
|
2274
|
-
private async git(
|
|
2401
|
+
private async git(
|
|
2402
|
+
cwd: string,
|
|
2403
|
+
args: string[],
|
|
2404
|
+
opts?: {
|
|
2405
|
+
/**
|
|
2406
|
+
* Bytes to feed the subprocess on stdin — used by the
|
|
2407
|
+
* `--pathspec-from-file=-` commit/add paths so a several-hundred-file
|
|
2408
|
+
* batch never has to ride the argv (Windows caps a command line at
|
|
2409
|
+
* ~32K chars).
|
|
2410
|
+
*/
|
|
2411
|
+
input?: string;
|
|
2412
|
+
},
|
|
2413
|
+
): Promise<GitRunResult> {
|
|
2275
2414
|
try {
|
|
2276
|
-
const
|
|
2415
|
+
const pending = execFileAsync('git', args, {
|
|
2277
2416
|
cwd,
|
|
2278
2417
|
// `GIT_LITERAL_PATHSPECS=1` makes git treat every pathspec literally
|
|
2279
2418
|
// instead of interpreting `[`, `]`, `*`, `?`, `!`, or `:(magic)` as
|
|
@@ -2294,6 +2433,14 @@ export class GitService implements IGitService {
|
|
|
2294
2433
|
env: { ...process.env, GIT_LITERAL_PATHSPECS: '1', LC_ALL: 'C', LANG: 'C' },
|
|
2295
2434
|
maxBuffer: 32 * 1024 * 1024,
|
|
2296
2435
|
});
|
|
2436
|
+
if (opts?.input !== undefined && pending.child.stdin) {
|
|
2437
|
+
// A dying git can close stdin mid-write; the promise below still
|
|
2438
|
+
// rejects with the exit code, which is the error we want to surface.
|
|
2439
|
+
pending.child.stdin.on('error', () => undefined);
|
|
2440
|
+
pending.child.stdin.write(opts.input);
|
|
2441
|
+
pending.child.stdin.end();
|
|
2442
|
+
}
|
|
2443
|
+
const { stdout, stderr } = await pending;
|
|
2297
2444
|
return { stdout: stdout.toString(), stderr: stderr.toString() };
|
|
2298
2445
|
} catch (err) {
|
|
2299
2446
|
const msg = err instanceof Error ? err.message : String(err);
|