@bevel-software/platform-core-backend 0.21.0 → 0.22.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/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +32 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +13 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +51 -5
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-requests.contract.d.ts +75 -0
- package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
- package/dist/modules/access/access-requests.contract.js +20 -0
- package/dist/modules/access/access-requests.contract.js.map +1 -0
- package/dist/modules/access/access-requests.routes.d.ts +35 -0
- package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
- package/dist/modules/access/access-requests.routes.js +237 -0
- package/dist/modules/access/access-requests.routes.js.map +1 -0
- package/dist/modules/access/access-requests.service.d.ts +123 -0
- package/dist/modules/access/access-requests.service.d.ts.map +1 -0
- package/dist/modules/access/access-requests.service.js +337 -0
- package/dist/modules/access/access-requests.service.js.map +1 -0
- package/dist/modules/access-model/access-grammar.d.ts +12 -0
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +24 -0
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +26 -1
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +11 -2
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/github-app/github-app.client.d.ts +101 -0
- package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.client.js +222 -0
- package/dist/modules/github-app/github-app.client.js.map +1 -0
- package/dist/modules/github-app/github-app.connection.d.ts +102 -0
- package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.connection.js +185 -0
- package/dist/modules/github-app/github-app.connection.js.map +1 -0
- package/dist/modules/github-app/github-app.routes.d.ts +59 -0
- package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.routes.js +323 -0
- package/dist/modules/github-app/github-app.routes.js.map +1 -0
- package/dist/modules/github-app/index.d.ts +4 -0
- package/dist/modules/github-app/index.d.ts.map +1 -0
- package/dist/modules/github-app/index.js +4 -0
- package/dist/modules/github-app/index.js.map +1 -0
- package/dist/modules/kb-fs/remote-url.d.ts +22 -0
- package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
- package/dist/modules/kb-fs/remote-url.js +35 -0
- package/dist/modules/kb-fs/remote-url.js.map +1 -0
- package/dist/modules/plugins/join-proposals.d.ts +17 -5
- package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
- package/dist/modules/plugins/join-proposals.js +76 -22
- package/dist/modules/plugins/join-proposals.js.map +1 -1
- package/dist/modules/plugins/join-requests.service.d.ts +111 -18
- package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-requests.service.js +173 -29
- package/dist/modules/plugins/join-requests.service.js.map +1 -1
- package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
- package/dist/modules/plugins/plugins.routes.js +3 -2
- package/dist/modules/plugins/plugins.routes.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +93 -4
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/settings/managed-repository.d.ts +43 -0
- package/dist/modules/settings/managed-repository.d.ts.map +1 -0
- package/dist/modules/settings/managed-repository.js +60 -0
- package/dist/modules/settings/managed-repository.js.map +1 -0
- package/dist/modules/settings/repository-source.d.ts +128 -0
- package/dist/modules/settings/repository-source.d.ts.map +1 -0
- package/dist/modules/settings/repository-source.js +150 -0
- package/dist/modules/settings/repository-source.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +27 -4
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +187 -13
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
- package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.js +24 -63
- package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +15 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +38 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +3 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +17 -4
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
- package/dist/modules/workflow/git/node-git-runner.js +5 -0
- package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +99 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +3 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +34 -7
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +148 -48
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +66 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +148 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +17 -1
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +28 -2
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/domain-errors.d.ts +39 -0
- package/dist/shared/domain-errors.d.ts.map +1 -1
- package/dist/shared/domain-errors.js +55 -0
- package/dist/shared/domain-errors.js.map +1 -1
- package/dist/shared/git.contract.d.ts +10 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js.map +1 -1
- package/package.json +3 -3
- package/src/core/create-core-server.ts +37 -1
- package/src/core/create-core-services.ts +65 -8
- package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
- package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
- package/src/modules/access/access-requests.contract.ts +93 -0
- package/src/modules/access/access-requests.routes.ts +286 -0
- package/src/modules/access/access-requests.service.ts +420 -0
- package/src/modules/access-model/access-grammar.ts +22 -0
- package/src/modules/auth/__tests__/auth.service.test.ts +38 -0
- package/src/modules/auth/auth.service.ts +28 -1
- package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
- package/src/modules/github-app/github-app.client.ts +268 -0
- package/src/modules/github-app/github-app.connection.ts +204 -0
- package/src/modules/github-app/github-app.routes.ts +359 -0
- package/src/modules/github-app/index.ts +19 -0
- package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
- package/src/modules/kb-fs/remote-url.ts +35 -0
- package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
- package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
- package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
- package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
- package/src/modules/plugins/join-proposals.ts +87 -19
- package/src/modules/plugins/join-requests.service.ts +199 -39
- package/src/modules/plugins/plugins.routes.ts +3 -2
- package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
- package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +353 -0
- package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +282 -0
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +233 -0
- package/src/modules/settings/deployment-settings.service.ts +109 -3
- package/src/modules/settings/managed-repository.ts +68 -0
- package/src/modules/settings/repository-source.ts +197 -0
- package/src/modules/settings/setup.routes.ts +214 -10
- package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
- package/src/modules/skills/skill-access-requests.routes.ts +25 -75
- package/src/modules/tool-helpers/tool-context.ts +15 -0
- package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
- package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +141 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +49 -3
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
- package/src/modules/workflow/git/git.service.ts +25 -4
- package/src/modules/workflow/git/node-git-runner.ts +6 -0
- package/src/modules/workflow/git/pull-request.service.ts +119 -0
- package/src/modules/workflow/workflow.routes.ts +3 -1
- package/src/modules/workflow/workflow.service.ts +170 -52
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +233 -7
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +35 -3
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +211 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +159 -0
- package/src/modules/workspace/workspace.service.ts +17 -1
- package/src/modules/workspace/workspace.tools.ts +35 -0
- package/src/shared/domain-errors.ts +58 -0
- package/src/shared/git.contract.ts +10 -0
|
@@ -5,6 +5,7 @@ import { toolDef, withBranchInput } from '../../tool-helpers/tool-def.js';
|
|
|
5
5
|
import type { ToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
6
6
|
import { requireInternalSource } from '../../tool-auth/tool-auth.middleware.js';
|
|
7
7
|
import type { KbContext } from '../../../shared/kb-context.js';
|
|
8
|
+
import { assertBranchProvided } from '../../../shared/domain-errors.js';
|
|
8
9
|
import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
9
10
|
import { assertInsideRepo, normalizePathArgs } from '../../kb-fs/repo-path.js';
|
|
10
11
|
import { RETIRED_TOOL_MESSAGES } from '@bevel-software/platform-mcp-core';
|
|
@@ -144,16 +145,30 @@ export function registerWorkflowTools(
|
|
|
144
145
|
write: boolean;
|
|
145
146
|
/** Register on the internal catalog only (e.g. agent-control tools that make no sense to a remote caller). */
|
|
146
147
|
internalOnly?: boolean;
|
|
147
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* Skip the auto-injected `branch` input — for a tool that declares its own
|
|
150
|
+
* `branch` with its own meaning (`create_branch`'s fork base,
|
|
151
|
+
* `switch_branch`'s target draft) or none at all (`merge_branch`,
|
|
152
|
+
* `open_change_request`, the repo-global reads). It says NOTHING about
|
|
153
|
+
* whether the tool requires a branch: that is read off the declared schema
|
|
154
|
+
* below, because reading it off this flag is what let `create_branch` opt
|
|
155
|
+
* out of the check while still demanding the input.
|
|
156
|
+
*/
|
|
148
157
|
skipBranch?: boolean;
|
|
149
158
|
handler: ToolHandler;
|
|
150
159
|
}): void => {
|
|
151
160
|
const path = `/api/agent/tools/${spec.name}`;
|
|
161
|
+
const inputs = spec.skipBranch ? spec.inputs : withBranchInput(spec.inputs);
|
|
162
|
+
// The tool's OWN declaration is the only honest answer to "must this call
|
|
163
|
+
// name a branch": the injected input and a hand-declared one (the fork
|
|
164
|
+
// base, the draft to switch to) are equally required, and a tool that adds
|
|
165
|
+
// or drops the input later moves this with it.
|
|
166
|
+
const requiresBranch = ((inputs as { required?: string[] }).required ?? []).includes('branch');
|
|
152
167
|
const def = toolDef({
|
|
153
168
|
name: spec.name,
|
|
154
169
|
description: spec.description,
|
|
155
170
|
path,
|
|
156
|
-
inputs
|
|
171
|
+
inputs,
|
|
157
172
|
outputs: spec.outputs,
|
|
158
173
|
tags: spec.write ? ['workflow', 'write'] : ['workflow'],
|
|
159
174
|
});
|
|
@@ -171,7 +186,27 @@ export function registerWorkflowTools(
|
|
|
171
186
|
// normaliser: the root-anchored `/<kbDirName>/…` form names the same
|
|
172
187
|
// workspace path, and a path with no prefix is placed under
|
|
173
188
|
// `<kbDirName>/` rather than refused.
|
|
174
|
-
toolHandler(
|
|
189
|
+
toolHandler(
|
|
190
|
+
(args, ctx) => {
|
|
191
|
+
// These tools hand `branch` STRAIGHT to `workspaceIdForBranch` —
|
|
192
|
+
// they address a workspace by id rather than going through
|
|
193
|
+
// `getFilesystem`, so the choke point that guards the file tools
|
|
194
|
+
// never sees them. Without this, a branch-less `commit_change`
|
|
195
|
+
// commits to a workspace id literally named "undefined", and a
|
|
196
|
+
// branch-less `create_branch` forks from one: it clones a branch of
|
|
197
|
+
// that name, fails, and answers `There is no branch named undefined.`
|
|
198
|
+
//
|
|
199
|
+
// Keyed on whether the SCHEMA requires `branch`, not on whether the
|
|
200
|
+
// input was injected. `create_branch` and `switch_branch` declare
|
|
201
|
+
// their own required `branch`; a check keyed on the injection skipped
|
|
202
|
+
// exactly those two, which is how the reported bug survived on
|
|
203
|
+
// `create_branch` — the one tool whose `branch` is a branch the
|
|
204
|
+
// caller must already have.
|
|
205
|
+
if (requiresBranch) assertBranchProvided(args.branch);
|
|
206
|
+
return spec.handler(normalizePathArgs(args, kbDirName), ctx);
|
|
207
|
+
},
|
|
208
|
+
{ write: spec.write },
|
|
209
|
+
),
|
|
175
210
|
);
|
|
176
211
|
};
|
|
177
212
|
|
|
@@ -330,6 +365,17 @@ export function registerWorkflowTools(
|
|
|
330
365
|
handler: async (args, ctx: ToolContext) => {
|
|
331
366
|
const name = args.name as string;
|
|
332
367
|
const base = args.branch as string;
|
|
368
|
+
// `name` is declared required too, and the same-name refusal below quotes
|
|
369
|
+
// it. With nothing enforcing it, a call that named neither input compared
|
|
370
|
+
// undefined to undefined, matched, and answered "not 'undefined', the
|
|
371
|
+
// draft being created" — an absent value presented as a draft name, the
|
|
372
|
+
// very sentence this ticket exists to stop. Refused by name instead,
|
|
373
|
+
// without echoing what was not sent.
|
|
374
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
375
|
+
throw new ToolError('`name` is required: pass the name of the draft to create.', 400, {
|
|
376
|
+
kind: 'name-required',
|
|
377
|
+
});
|
|
378
|
+
}
|
|
333
379
|
// The git op runs in the base's workspace, whose clone has the base
|
|
334
380
|
// checked out — the new draft forks from its HEAD. Resolving a workspace
|
|
335
381
|
// lazily clones its branch from origin, so a `branch` naming the
|
|
@@ -353,3 +353,124 @@ describe('GitService.pull', () => {
|
|
|
353
353
|
await expect(svc.hasUnpushedCommits(workspaceId)).resolves.toBe(false);
|
|
354
354
|
});
|
|
355
355
|
});
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* `preserveMerges` — why an unpushed MERGE must survive the pull's rebase.
|
|
359
|
+
*
|
|
360
|
+
* The change-request Update merges the target into the proposal branch and
|
|
361
|
+
* pushes. When that push fails, the merge sits in the clone, and the NEXT
|
|
362
|
+
* update pulls first. A plain `git rebase` replays `--no-merges
|
|
363
|
+
* upstream..HEAD`, which turns that merge commit into cherry-picks of the
|
|
364
|
+
* commits it merged: the target stops being a parent, so the branch never
|
|
365
|
+
* contains the target's head however many times the update runs, and the
|
|
366
|
+
* request stays behind for good.
|
|
367
|
+
*/
|
|
368
|
+
describe('GitService.pull — an unpushed merge commit', () => {
|
|
369
|
+
let root: string;
|
|
370
|
+
const workspaceId = 'alice%2Fdeal';
|
|
371
|
+
|
|
372
|
+
beforeEach(async () => {
|
|
373
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-git-pull-merges-'));
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
afterEach(async () => {
|
|
377
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* A clone of `alice/deal` holding a merge of `target-company-state` that
|
|
382
|
+
* origin has not seen, with origin's own `alice/deal` one commit further on
|
|
383
|
+
* so the pull has something to rebase ONTO.
|
|
384
|
+
*/
|
|
385
|
+
async function seedUnpushedMerge(): Promise<{ repo: string; targetTip: string }> {
|
|
386
|
+
const upstream = path.join(root, 'upstream.git');
|
|
387
|
+
await runGit(root, ['init', '--bare', '-b', 'target-company-state', upstream]);
|
|
388
|
+
|
|
389
|
+
const seed = path.join(root, '.seed');
|
|
390
|
+
await fs.mkdir(seed);
|
|
391
|
+
await runGit(seed, ['init', '-b', 'target-company-state']);
|
|
392
|
+
await runGit(seed, ['remote', 'add', 'origin', upstream]);
|
|
393
|
+
await fs.writeFile(path.join(seed, 'shared.txt'), 'one\n');
|
|
394
|
+
await runGit(seed, ['add', '.']);
|
|
395
|
+
await runGit(seed, ['commit', '-m', 'init']);
|
|
396
|
+
await runGit(seed, ['push', 'origin', 'target-company-state']);
|
|
397
|
+
// The proposal branch, forked here.
|
|
398
|
+
await runGit(seed, ['checkout', '-b', 'alice/deal']);
|
|
399
|
+
await fs.writeFile(path.join(seed, 'proposal.txt'), 'alice proposes\n');
|
|
400
|
+
await runGit(seed, ['add', '.']);
|
|
401
|
+
await runGit(seed, ['commit', '-m', 'propose']);
|
|
402
|
+
await runGit(seed, ['push', 'origin', 'alice/deal']);
|
|
403
|
+
// The target moves on, in a file the proposal does not touch.
|
|
404
|
+
await runGit(seed, ['checkout', 'target-company-state']);
|
|
405
|
+
await fs.writeFile(path.join(seed, 'elsewhere.txt'), 'someone else\n');
|
|
406
|
+
await runGit(seed, ['add', '.']);
|
|
407
|
+
await runGit(seed, ['commit', '-m', 'target moves']);
|
|
408
|
+
await runGit(seed, ['push', 'origin', 'target-company-state']);
|
|
409
|
+
const targetTip = await gitOut(seed, ['rev-parse', 'HEAD']);
|
|
410
|
+
|
|
411
|
+
const workspaceDir = path.join(root, workspaceId);
|
|
412
|
+
const repo = path.join(workspaceDir, 'knowledge-base');
|
|
413
|
+
await fs.mkdir(workspaceDir, { recursive: true });
|
|
414
|
+
await runGit(root, ['clone', '-b', 'alice/deal', upstream, repo]);
|
|
415
|
+
await runGit(repo, ['config', 'user.email', 'workspace@bevel.test']);
|
|
416
|
+
await runGit(repo, ['config', 'user.name', 'bevel Workspace']);
|
|
417
|
+
await runGit(repo, ['config', 'core.autocrlf', 'false']);
|
|
418
|
+
|
|
419
|
+
// The Update that merged and could not push: a real merge commit in the
|
|
420
|
+
// clone, with the target's tip as its second parent.
|
|
421
|
+
await runGit(repo, ['fetch', 'origin']);
|
|
422
|
+
await runGit(repo, [
|
|
423
|
+
'merge', '--no-ff', '-m', 'Refresh alice/deal', 'origin/target-company-state',
|
|
424
|
+
]);
|
|
425
|
+
expect(await gitOut(repo, ['rev-list', '--count', '--merges', 'HEAD'])).toBe('1');
|
|
426
|
+
|
|
427
|
+
// Origin's alice/deal moves on too (the author saved from another client),
|
|
428
|
+
// so the pull has a real rebase to do rather than nothing.
|
|
429
|
+
await runGit(seed, ['checkout', 'alice/deal']);
|
|
430
|
+
await fs.writeFile(path.join(seed, 'proposal.txt'), 'alice proposes\nand adds\n');
|
|
431
|
+
await runGit(seed, ['add', '.']);
|
|
432
|
+
await runGit(seed, ['commit', '-m', 'another save']);
|
|
433
|
+
await runGit(seed, ['push', 'origin', 'alice/deal']);
|
|
434
|
+
|
|
435
|
+
return { repo, targetTip };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function svcFor(): GitService {
|
|
439
|
+
return new GitService(
|
|
440
|
+
stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }),
|
|
441
|
+
stubWorkflowHooks(),
|
|
442
|
+
testKbContext(),
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
it('is flattened by a plain pull — the branch stops containing the target', async () => {
|
|
447
|
+
// The bug, stated as the behaviour that causes it. Kept as a test so the
|
|
448
|
+
// option below is never "simplified" away as redundant.
|
|
449
|
+
const { repo, targetTip } = await seedUnpushedMerge();
|
|
450
|
+
await svcFor().pull(workspaceId);
|
|
451
|
+
|
|
452
|
+
expect(await gitOut(repo, ['rev-list', '--count', '--merges', 'HEAD'])).toBe('0');
|
|
453
|
+
const contains = await gitOut(repo, [
|
|
454
|
+
'merge-base', '--is-ancestor', targetTip, 'HEAD',
|
|
455
|
+
]).then(() => true, () => false);
|
|
456
|
+
expect(contains).toBe(false);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('survives `preserveMerges`, and the branch still contains the target', async () => {
|
|
460
|
+
const { repo, targetTip } = await seedUnpushedMerge();
|
|
461
|
+
await svcFor().pull(workspaceId, { preserveMerges: true });
|
|
462
|
+
|
|
463
|
+
// The merge is still a merge...
|
|
464
|
+
expect(await gitOut(repo, ['rev-list', '--count', '--merges', 'HEAD'])).toBe('1');
|
|
465
|
+
// ...so the target's head is an ancestor of the branch, which is exactly
|
|
466
|
+
// what "this request is no longer behind" means.
|
|
467
|
+
const contains = await gitOut(repo, [
|
|
468
|
+
'merge-base', '--is-ancestor', targetTip, 'HEAD',
|
|
469
|
+
]).then(() => true, () => false);
|
|
470
|
+
expect(contains).toBe(true);
|
|
471
|
+
// And the save origin had that the clone did not is replayed on top.
|
|
472
|
+
expect(await fs.readFile(path.join(repo, 'proposal.txt'), 'utf8')).toBe(
|
|
473
|
+
'alice proposes\nand adds\n',
|
|
474
|
+
);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
@@ -49,7 +49,13 @@ describe('PullRequestService.getPrDetail — git work per read', () => {
|
|
|
49
49
|
const resolvePrShas = vi.fn(async () => ({ baseSha: BASE, headSha: HEAD }));
|
|
50
50
|
const changedFilesForPr = vi.fn(async () => []);
|
|
51
51
|
const forkPointForPr = vi.fn(async () => ({ mergeBaseSha: FORK, behind: true }));
|
|
52
|
-
const
|
|
52
|
+
const pathsChangedBetween = vi.fn(async () => []);
|
|
53
|
+
const git = {
|
|
54
|
+
resolvePrShas,
|
|
55
|
+
changedFilesForPr,
|
|
56
|
+
forkPointForPr,
|
|
57
|
+
pathsChangedBetween,
|
|
58
|
+
} as unknown as GitService;
|
|
53
59
|
const workspace = {
|
|
54
60
|
findAnyWorkspaceId: async () => 'ws-main',
|
|
55
61
|
ensureRemotesFetched: async () => undefined,
|
|
@@ -63,7 +69,14 @@ describe('PullRequestService.getPrDetail — git work per read', () => {
|
|
|
63
69
|
getApprovalStates,
|
|
64
70
|
evaluateMergeGate: () => ({ mergeable: false, reasons: [], warnings: [] }),
|
|
65
71
|
});
|
|
66
|
-
return {
|
|
72
|
+
return {
|
|
73
|
+
svc,
|
|
74
|
+
resolvePrShas,
|
|
75
|
+
changedFilesForPr,
|
|
76
|
+
forkPointForPr,
|
|
77
|
+
pathsChangedBetween,
|
|
78
|
+
getApprovalStates,
|
|
79
|
+
};
|
|
67
80
|
}
|
|
68
81
|
|
|
69
82
|
it('pins the file list to the SHAs it just resolved, and keeps patches for a client read', async () => {
|
|
@@ -179,3 +192,133 @@ describe('PullRequestService — change request link', () => {
|
|
|
179
192
|
expect(detail?.urlNote).toBe('Set PUBLIC_FRONTEND_URL to get absolute links.');
|
|
180
193
|
});
|
|
181
194
|
});
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* `needsUpdate` — the answer the dialog acts on, as distinct from `behind`.
|
|
198
|
+
*
|
|
199
|
+
* `behind` is the honest git fact that the two branches have diverged. On a
|
|
200
|
+
* knowledge base where every shared save commits to the default branch it is
|
|
201
|
+
* true again minutes after every update, and acting on it made opening a
|
|
202
|
+
* request pay for a merge on almost every open — for changes to files the
|
|
203
|
+
* request does not contain. `needsUpdate` adds the half that matters: the
|
|
204
|
+
* target changed a file THIS request also changes.
|
|
205
|
+
*/
|
|
206
|
+
describe('PullRequestService.getPrDetail — needsUpdate', () => {
|
|
207
|
+
const FILE = 'Sales/Deal.md';
|
|
208
|
+
const OTHER = 'Ops/Runbook.md';
|
|
209
|
+
|
|
210
|
+
function harness(opts: {
|
|
211
|
+
behind?: boolean;
|
|
212
|
+
mergeBaseSha?: string | null;
|
|
213
|
+
files?: { path: string; previousPath?: string }[];
|
|
214
|
+
changedOnTarget?: string[];
|
|
215
|
+
changedOnTargetError?: Error;
|
|
216
|
+
state?: string;
|
|
217
|
+
} = {}) {
|
|
218
|
+
const resolvePrShas = vi.fn(async () => ({ baseSha: BASE, headSha: HEAD }));
|
|
219
|
+
const changedFilesForPr = vi.fn(async () => (opts.files ?? [{ path: FILE }]) as never);
|
|
220
|
+
const forkPointForPr = vi.fn(async () => ({
|
|
221
|
+
mergeBaseSha: opts.mergeBaseSha === undefined ? FORK : opts.mergeBaseSha,
|
|
222
|
+
behind: opts.behind ?? true,
|
|
223
|
+
}));
|
|
224
|
+
const pathsChangedBetween = opts.changedOnTargetError
|
|
225
|
+
? vi.fn().mockRejectedValue(opts.changedOnTargetError)
|
|
226
|
+
: vi.fn(async () => opts.changedOnTarget ?? []);
|
|
227
|
+
const git = {
|
|
228
|
+
resolvePrShas,
|
|
229
|
+
changedFilesForPr,
|
|
230
|
+
forkPointForPr,
|
|
231
|
+
pathsChangedBetween,
|
|
232
|
+
} as unknown as GitService;
|
|
233
|
+
const workspace = {
|
|
234
|
+
findAnyWorkspaceId: async () => 'ws-main',
|
|
235
|
+
ensureRemotesFetched: async () => undefined,
|
|
236
|
+
} as unknown as WorkspaceService;
|
|
237
|
+
const access = { canWriteAtRef: async () => false } as unknown as IAccessControl;
|
|
238
|
+
const db = {
|
|
239
|
+
select: () => ({
|
|
240
|
+
from: () => ({
|
|
241
|
+
where: () => ({ limit: async () => [{ ...ROW, state: opts.state ?? 'open' }] }),
|
|
242
|
+
}),
|
|
243
|
+
}),
|
|
244
|
+
} as unknown as Database;
|
|
245
|
+
const svc = new PullRequestService(db, workspace, access, git);
|
|
246
|
+
svc.setDetailEnricher({
|
|
247
|
+
listComments: async () => [],
|
|
248
|
+
getApprovalStates: async () => [],
|
|
249
|
+
evaluateMergeGate: () => ({ mergeable: false, reasons: [], warnings: [] }),
|
|
250
|
+
});
|
|
251
|
+
return { svc, pathsChangedBetween };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
it('is false when the target moved in files this request does not change', async () => {
|
|
255
|
+
// The case that made the message appear on essentially every open: a
|
|
256
|
+
// shared save somewhere else in the knowledge base.
|
|
257
|
+
const h = harness({ files: [{ path: FILE }], changedOnTarget: [OTHER] });
|
|
258
|
+
const detail = await h.svc.getPrDetail(7, { fresh: true });
|
|
259
|
+
expect(detail?.behind).toBe(true);
|
|
260
|
+
expect(detail?.needsUpdate).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('is true when the target changed a file this request also changes', async () => {
|
|
264
|
+
const h = harness({ files: [{ path: FILE }], changedOnTarget: [OTHER, FILE] });
|
|
265
|
+
const detail = await h.svc.getPrDetail(7, { fresh: true });
|
|
266
|
+
expect(detail?.needsUpdate).toBe(true);
|
|
267
|
+
// Intersected over the fork point → the target's tip: the same two
|
|
268
|
+
// commits the rest of the detail is pinned to.
|
|
269
|
+
expect(h.pathsChangedBetween).toHaveBeenCalledWith('ws-main', FORK, BASE);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('asks git nothing at all when the request is not behind', async () => {
|
|
273
|
+
// The common case, and the one that has to stay free.
|
|
274
|
+
const h = harness({ behind: false });
|
|
275
|
+
const detail = await h.svc.getPrDetail(7, { fresh: true });
|
|
276
|
+
expect(detail?.needsUpdate).toBe(false);
|
|
277
|
+
expect(h.pathsChangedBetween).not.toHaveBeenCalled();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('asks git nothing when the request changes no files', async () => {
|
|
281
|
+
const h = harness({ files: [], changedOnTarget: [FILE] });
|
|
282
|
+
const detail = await h.svc.getPrDetail(7, { fresh: true });
|
|
283
|
+
expect(detail?.needsUpdate).toBe(false);
|
|
284
|
+
expect(h.pathsChangedBetween).not.toHaveBeenCalled();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('counts a file the target RENAMED, under either of its names', async () => {
|
|
288
|
+
// `pathsChangedBetween` runs without rename detection, so the target's
|
|
289
|
+
// rename reports the path it left as well as the one it arrived at — and
|
|
290
|
+
// a request still editing the old name is affected.
|
|
291
|
+
const h = harness({
|
|
292
|
+
files: [{ path: FILE }],
|
|
293
|
+
changedOnTarget: [FILE, 'Sales/Deal-2026.md'],
|
|
294
|
+
});
|
|
295
|
+
expect((await h.svc.getPrDetail(7, { fresh: true }))?.needsUpdate).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('counts a file THIS request renamed, under the name it came from', async () => {
|
|
299
|
+
const h = harness({
|
|
300
|
+
files: [{ path: 'Sales/Deal-final.md', previousPath: FILE }],
|
|
301
|
+
changedOnTarget: [FILE],
|
|
302
|
+
});
|
|
303
|
+
expect((await h.svc.getPrDetail(7, { fresh: true }))?.needsUpdate).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('is true when the branches share no history — there is nothing to intersect', async () => {
|
|
307
|
+
const h = harness({ mergeBaseSha: null });
|
|
308
|
+
const detail = await h.svc.getPrDetail(7, { fresh: true });
|
|
309
|
+
expect(detail?.needsUpdate).toBe(true);
|
|
310
|
+
expect(h.pathsChangedBetween).not.toHaveBeenCalled();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('is true when the comparison itself fails — an infra error is not "this is fine"', async () => {
|
|
314
|
+
const h = harness({ changedOnTargetError: new Error('git exploded') });
|
|
315
|
+
expect((await h.svc.getPrDetail(7, { fresh: true }))?.needsUpdate).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('is false for a request that is not open, whatever git says', async () => {
|
|
319
|
+
const h = harness({ state: 'merged', changedOnTarget: [FILE] });
|
|
320
|
+
const detail = await h.svc.getPrDetail(7, { fresh: true });
|
|
321
|
+
expect(detail?.needsUpdate).toBe(false);
|
|
322
|
+
expect(detail?.behind).toBe(false);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { computeViewerCanDelete } from '../pull-request.service.js';
|
|
3
|
+
import { hashEmail } from '../../../../shared/email-identity.js';
|
|
4
|
+
|
|
5
|
+
const EMAIL = 'juan@bevel.software';
|
|
6
|
+
const AUTHOR_HASH = hashEmail(EMAIL);
|
|
7
|
+
const OTHER_AUTHOR_HASH = hashEmail('someone-else@bevel.software');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The truth table for who may delete a request outright — close it AND retire
|
|
11
|
+
* its branch. It must mirror `deleteChangeRequest`'s own check, which is why
|
|
12
|
+
* the interesting rows here are the ones where it DIFFERS from its siblings:
|
|
13
|
+
* an owner of every changed file is absent (they decline, they do not
|
|
14
|
+
* destroy), and a closed request still says yes (its leftover branch is still
|
|
15
|
+
* the server's to retire).
|
|
16
|
+
*/
|
|
17
|
+
describe('computeViewerCanDelete', () => {
|
|
18
|
+
it('lets the author delete their own open request', () => {
|
|
19
|
+
expect(
|
|
20
|
+
computeViewerCanDelete({
|
|
21
|
+
state: 'open',
|
|
22
|
+
authorId: AUTHOR_HASH,
|
|
23
|
+
viewerEmail: EMAIL,
|
|
24
|
+
viewerCanBypassMerge: false,
|
|
25
|
+
}),
|
|
26
|
+
).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('lets an admin delete someone else’s open request', () => {
|
|
30
|
+
expect(
|
|
31
|
+
computeViewerCanDelete({
|
|
32
|
+
state: 'open',
|
|
33
|
+
authorId: OTHER_AUTHOR_HASH,
|
|
34
|
+
viewerEmail: EMAIL,
|
|
35
|
+
viewerCanBypassMerge: true,
|
|
36
|
+
}),
|
|
37
|
+
).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('says yes once for a viewer who is both author and admin', () => {
|
|
41
|
+
expect(
|
|
42
|
+
computeViewerCanDelete({
|
|
43
|
+
state: 'open',
|
|
44
|
+
authorId: AUTHOR_HASH,
|
|
45
|
+
viewerEmail: EMAIL,
|
|
46
|
+
viewerCanBypassMerge: true,
|
|
47
|
+
}),
|
|
48
|
+
).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('refuses a signed-in user who is neither author nor admin', () => {
|
|
52
|
+
expect(
|
|
53
|
+
computeViewerCanDelete({
|
|
54
|
+
state: 'open',
|
|
55
|
+
authorId: OTHER_AUTHOR_HASH,
|
|
56
|
+
viewerEmail: EMAIL,
|
|
57
|
+
viewerCanBypassMerge: false,
|
|
58
|
+
}),
|
|
59
|
+
).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('fails closed with no viewer, even on one’s own-looking request', () => {
|
|
63
|
+
expect(
|
|
64
|
+
computeViewerCanDelete({
|
|
65
|
+
state: 'open',
|
|
66
|
+
authorId: AUTHOR_HASH,
|
|
67
|
+
viewerEmail: undefined,
|
|
68
|
+
viewerCanBypassMerge: false,
|
|
69
|
+
}),
|
|
70
|
+
).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('fails closed for an anonymous admin-flagged read', () => {
|
|
74
|
+
expect(
|
|
75
|
+
computeViewerCanDelete({
|
|
76
|
+
state: 'open',
|
|
77
|
+
authorId: OTHER_AUTHOR_HASH,
|
|
78
|
+
viewerEmail: undefined,
|
|
79
|
+
viewerCanBypassMerge: true,
|
|
80
|
+
}),
|
|
81
|
+
).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('refuses an APPLIED request to its author — applied history is nobody’s to delete', () => {
|
|
85
|
+
expect(
|
|
86
|
+
computeViewerCanDelete({
|
|
87
|
+
state: 'merged',
|
|
88
|
+
authorId: AUTHOR_HASH,
|
|
89
|
+
viewerEmail: EMAIL,
|
|
90
|
+
viewerCanBypassMerge: false,
|
|
91
|
+
}),
|
|
92
|
+
).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('refuses an APPLIED request to an admin too', () => {
|
|
96
|
+
expect(
|
|
97
|
+
computeViewerCanDelete({
|
|
98
|
+
state: 'merged',
|
|
99
|
+
authorId: OTHER_AUTHOR_HASH,
|
|
100
|
+
viewerEmail: EMAIL,
|
|
101
|
+
viewerCanBypassMerge: true,
|
|
102
|
+
}),
|
|
103
|
+
).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('still says yes on a CLOSED request: the leftover branch is the delete’s job', () => {
|
|
107
|
+
// Withdrawn in another tab. The server's delete still succeeds there and
|
|
108
|
+
// removes the branch the withdraw left behind, so the hint must not go
|
|
109
|
+
// quiet the moment the row flips — unlike `viewerCanCancel`, which is
|
|
110
|
+
// open-only because there is nothing left to cancel.
|
|
111
|
+
expect(
|
|
112
|
+
computeViewerCanDelete({
|
|
113
|
+
state: 'closed',
|
|
114
|
+
authorId: AUTHOR_HASH,
|
|
115
|
+
viewerEmail: EMAIL,
|
|
116
|
+
viewerCanBypassMerge: false,
|
|
117
|
+
}),
|
|
118
|
+
).toBe(true);
|
|
119
|
+
expect(
|
|
120
|
+
computeViewerCanDelete({
|
|
121
|
+
state: 'closed',
|
|
122
|
+
authorId: OTHER_AUTHOR_HASH,
|
|
123
|
+
viewerEmail: EMAIL,
|
|
124
|
+
viewerCanBypassMerge: true,
|
|
125
|
+
}),
|
|
126
|
+
).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('refuses a request with no stored author to a non-admin (opened outside this backend)', () => {
|
|
130
|
+
expect(
|
|
131
|
+
computeViewerCanDelete({
|
|
132
|
+
state: 'open',
|
|
133
|
+
authorId: undefined,
|
|
134
|
+
viewerEmail: EMAIL,
|
|
135
|
+
viewerCanBypassMerge: false,
|
|
136
|
+
}),
|
|
137
|
+
).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('leaves normalization to hashEmail, exactly as its siblings do', () => {
|
|
141
|
+
expect(
|
|
142
|
+
computeViewerCanDelete({
|
|
143
|
+
state: 'open',
|
|
144
|
+
authorId: AUTHOR_HASH,
|
|
145
|
+
viewerEmail: ' Juan@Bevel.Software ',
|
|
146
|
+
viewerCanBypassMerge: false,
|
|
147
|
+
}),
|
|
148
|
+
).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('does NOT grant the owner of every changed file — it takes no such input', () => {
|
|
152
|
+
// Decision 4. The owner of every changed file is exactly the person
|
|
153
|
+
// `computeViewerCanCancel` says yes to and this predicate must not: they
|
|
154
|
+
// may decline the request, which leaves the author's branch to rework.
|
|
155
|
+
// There is no input here to express that grant, and the row a
|
|
156
|
+
// file-owning non-admin produces reads false.
|
|
157
|
+
expect(
|
|
158
|
+
computeViewerCanDelete({
|
|
159
|
+
state: 'open',
|
|
160
|
+
authorId: OTHER_AUTHOR_HASH,
|
|
161
|
+
viewerEmail: EMAIL,
|
|
162
|
+
viewerCanBypassMerge: false,
|
|
163
|
+
}),
|
|
164
|
+
).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -1760,7 +1760,12 @@ export class GitService implements IGitService {
|
|
|
1760
1760
|
* conflict into the typed error the workflow layer queues recovery for.
|
|
1761
1761
|
* Shared by `pull` and `syncFromRemote` so the two cannot drift.
|
|
1762
1762
|
*/
|
|
1763
|
-
private async rebaseOntoRemote(
|
|
1763
|
+
private async rebaseOntoRemote(
|
|
1764
|
+
cwd: string,
|
|
1765
|
+
branch: string,
|
|
1766
|
+
remoteRef: string,
|
|
1767
|
+
opts: { preserveMerges?: boolean } = {},
|
|
1768
|
+
): Promise<void> {
|
|
1764
1769
|
// An UNBORN HEAD (a clone of an upstream that was empty when cloned) has
|
|
1765
1770
|
// no commit to replay and `git rebase` refuses to start from it, so the
|
|
1766
1771
|
// branch is moved to the remote commit instead. That is only safe while
|
|
@@ -1798,7 +1803,20 @@ export class GitService implements IGitService {
|
|
|
1798
1803
|
// the local commits onto origin, then reapplies them, so the retry
|
|
1799
1804
|
// push can fast-forward. Untracked files don't block rebase and are
|
|
1800
1805
|
// left untouched.
|
|
1801
|
-
|
|
1806
|
+
// `--rebase-merges` is what keeps an unpushed MERGE a merge. Plain
|
|
1807
|
+
// rebase replays `--no-merges upstream..HEAD`, which turns a merge
|
|
1808
|
+
// commit the clone holds but origin has not seen into cherry-picks of
|
|
1809
|
+
// the commits it merged — the merged branch stops being a parent, so
|
|
1810
|
+
// "does the published head contain the target's head" answers no
|
|
1811
|
+
// however many times the update runs. Only the change-request update
|
|
1812
|
+
// asks for it; every other pull replays plain saves, where the two
|
|
1813
|
+
// spellings produce the same history.
|
|
1814
|
+
await this.git(cwd, [
|
|
1815
|
+
'rebase',
|
|
1816
|
+
'--autostash',
|
|
1817
|
+
...(opts.preserveMerges ? ['--rebase-merges'] : []),
|
|
1818
|
+
remoteRef,
|
|
1819
|
+
]);
|
|
1802
1820
|
} catch (err) {
|
|
1803
1821
|
// Capture the contested paths BEFORE the abort wipes the rebase
|
|
1804
1822
|
// state — `--diff-filter=U` lists exactly the files whose replay
|
|
@@ -1922,7 +1940,10 @@ export class GitService implements IGitService {
|
|
|
1922
1940
|
});
|
|
1923
1941
|
}
|
|
1924
1942
|
|
|
1925
|
-
async pull(
|
|
1943
|
+
async pull(
|
|
1944
|
+
workspaceId: string,
|
|
1945
|
+
opts: { preserveMerges?: boolean } = {},
|
|
1946
|
+
): Promise<{ treeChanged: boolean }> {
|
|
1926
1947
|
return this.mutex.run(workspaceId, async () => {
|
|
1927
1948
|
const cwd = await this.repoDir(workspaceId);
|
|
1928
1949
|
const branch = await this.checkedOutBranch(cwd);
|
|
@@ -1935,7 +1956,7 @@ export class GitService implements IGitService {
|
|
|
1935
1956
|
// Refresh WITHOUT `git pull`, and without touching `FETCH_HEAD` — see
|
|
1936
1957
|
// `refreshRemoteBranchRef` for why both halves are load-bearing.
|
|
1937
1958
|
const remoteRef = await this.refreshRemoteBranchRef(cwd, branch);
|
|
1938
|
-
await this.rebaseOntoRemote(cwd, branch, remoteRef);
|
|
1959
|
+
await this.rebaseOntoRemote(cwd, branch, remoteRef, opts);
|
|
1939
1960
|
this.accessControl?.invalidate(workspaceId);
|
|
1940
1961
|
const treeAfter = (await this.revParseOrNull(cwd, 'HEAD^{tree}')) ?? '';
|
|
1941
1962
|
return { treeChanged: treeAfter !== treeBefore };
|
|
@@ -250,6 +250,12 @@ export class NodeGitRunner implements IGitRunner {
|
|
|
250
250
|
const timers: NodeJS.Timeout[] = [];
|
|
251
251
|
let timedOut = false;
|
|
252
252
|
|
|
253
|
+
// A credential that expires is renewed before it is read. Renewing is
|
|
254
|
+
// the credential's own business and its failure is not this call's:
|
|
255
|
+
// plenty of git never leaves the disk, and a call that does is told by
|
|
256
|
+
// the host what the token in hand is worth.
|
|
257
|
+
await this.credentials.prepare?.().catch(() => undefined);
|
|
258
|
+
|
|
253
259
|
try {
|
|
254
260
|
const { promise: pending, child } = spawnGit(args, {
|
|
255
261
|
cwd,
|