@bevel-software/platform-core-backend 0.30.0 → 0.32.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/agent-guide/access-control.md +49 -4
- package/agent-guide/html-views.md +76 -2
- package/dist/core/catalog-revision.js +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +2 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +7 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +16 -3
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +1 -5
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access-model/index.d.ts +14 -0
- package/dist/modules/access-model/index.d.ts.map +1 -0
- package/dist/modules/access-model/index.js +13 -0
- package/dist/modules/access-model/index.js.map +1 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +66 -6
- package/dist/modules/audit/agent-audit.service.js.map +1 -1
- package/dist/modules/audit/audit.contract.d.ts +52 -8
- package/dist/modules/audit/audit.contract.d.ts.map +1 -1
- package/dist/modules/audit/audit.contract.js +37 -1
- package/dist/modules/audit/audit.contract.js.map +1 -1
- package/dist/modules/audit/audit.routes.d.ts +1 -1
- package/dist/modules/audit/audit.routes.d.ts.map +1 -1
- package/dist/modules/audit/audit.routes.js +15 -13
- package/dist/modules/audit/audit.routes.js.map +1 -1
- package/dist/modules/audit/request-audit.d.ts +21 -10
- package/dist/modules/audit/request-audit.d.ts.map +1 -1
- package/dist/modules/audit/request-audit.js +26 -10
- package/dist/modules/audit/request-audit.js.map +1 -1
- package/dist/modules/auth/account-hooks.d.ts +4 -6
- package/dist/modules/auth/account-hooks.d.ts.map +1 -1
- package/dist/modules/auth/account-hooks.js +7 -17
- package/dist/modules/auth/account-hooks.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +9 -8
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +19 -18
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +43 -4
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +31 -7
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/kb-fs/index.d.ts +1 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +1 -0
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +3 -12
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +11 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +14 -0
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +5 -0
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +67 -0
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +107 -27
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +257 -50
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +28 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +166 -39
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/mcp/request-context.d.ts +25 -0
- package/dist/modules/mcp/request-context.d.ts.map +1 -0
- package/dist/modules/mcp/request-context.js +11 -0
- package/dist/modules/mcp/request-context.js.map +1 -0
- package/dist/modules/mcp/shared-definitions.d.ts +56 -0
- package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
- package/dist/modules/mcp/shared-definitions.js +117 -0
- package/dist/modules/mcp/shared-definitions.js.map +1 -0
- package/dist/modules/mcp/surface-cache.d.ts +70 -0
- package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
- package/dist/modules/mcp/surface-cache.js +104 -0
- package/dist/modules/mcp/surface-cache.js.map +1 -0
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +33 -12
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +10 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.js +2 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +458 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/change-request-hooks.d.ts +78 -0
- package/dist/modules/workflow/change-request-hooks.d.ts.map +1 -0
- package/dist/modules/workflow/change-request-hooks.js +15 -0
- package/dist/modules/workflow/change-request-hooks.js.map +1 -0
- package/dist/modules/workflow/git/git.service.d.ts +113 -4
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +470 -106
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +10 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +9 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/read-on-branch.d.ts +35 -0
- package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
- package/dist/modules/workflow/read-on-branch.js +58 -0
- package/dist/modules/workflow/read-on-branch.js.map +1 -0
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +22 -41
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +124 -24
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +326 -48
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/index.d.ts +2 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +4 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/shared/git.contract.d.ts +7 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +9 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/shared/listener-seam.d.ts +34 -0
- package/dist/shared/listener-seam.d.ts.map +1 -0
- package/dist/shared/listener-seam.js +42 -0
- package/dist/shared/listener-seam.js.map +1 -0
- package/dist/shared/no-link-below.d.ts +10 -0
- package/dist/shared/no-link-below.d.ts.map +1 -0
- package/dist/shared/no-link-below.js +30 -0
- package/dist/shared/no-link-below.js.map +1 -0
- package/migrations/0018_agent_events_page.sql +11 -0
- package/migrations/0019_oauth_token_rotated_at.sql +7 -0
- package/migrations/meta/0018_snapshot.json +2429 -0
- package/migrations/meta/0019_snapshot.json +2435 -0
- package/migrations/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/core/catalog-revision.ts +3 -3
- package/src/core/create-core-server.ts +2 -1
- package/src/core/create-core-services.ts +21 -2
- package/src/index.ts +7 -0
- package/src/modules/access/access-control.service.ts +1 -6
- package/src/modules/access-model/index.ts +13 -0
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
- package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
- package/src/modules/audit/agent-audit.service.ts +66 -6
- package/src/modules/audit/audit.contract.ts +62 -10
- package/src/modules/audit/audit.routes.ts +14 -12
- package/src/modules/audit/request-audit.ts +28 -13
- package/src/modules/auth/__tests__/account-deactivation.test.ts +38 -2
- package/src/modules/auth/account-hooks.ts +13 -21
- package/src/modules/auth/auth.service.ts +19 -18
- package/src/modules/database/core-schema.ts +31 -7
- package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
- package/src/modules/kb-fs/index.ts +1 -0
- package/src/modules/kb-fs/locking-filesystem.ts +3 -12
- package/src/modules/kb-fs/mutex.ts +16 -0
- package/src/modules/mcp/__tests__/bevel-oauth-provider.pg.test.ts +279 -0
- package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +190 -15
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
- package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
- package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
- package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
- package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
- package/src/modules/mcp/mcp.routes.ts +68 -0
- package/src/modules/mcp/mcp.service.ts +315 -51
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +214 -48
- package/src/modules/mcp/request-context.ts +34 -0
- package/src/modules/mcp/shared-definitions.ts +121 -0
- package/src/modules/mcp/surface-cache.ts +154 -0
- package/src/modules/tool-helpers/tool-context.ts +37 -18
- package/src/modules/tool-helpers/tool.contract.ts +8 -0
- package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
- package/src/modules/workflow/__tests__/change-request-hooks.test.ts +62 -0
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
- package/src/modules/workflow/__tests__/workflow.routes.open-change-request.test.ts +79 -0
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +9 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +508 -5
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +474 -4
- package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
- package/src/modules/workflow/agent-tools/change-request-summary.ts +10 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +468 -1
- package/src/modules/workflow/change-request-hooks.ts +96 -0
- package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
- package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
- package/src/modules/workflow/git/git.service.ts +537 -106
- package/src/modules/workflow/git/pull-request.service.ts +10 -0
- package/src/modules/workflow/read-on-branch.ts +79 -0
- package/src/modules/workflow/workflow.routes.ts +21 -39
- package/src/modules/workflow/workflow.service.ts +390 -55
- package/src/modules/workspace/index.ts +11 -0
- package/src/shared/__tests__/no-link-below.test.ts +45 -0
- package/src/shared/git.contract.ts +10 -0
- package/src/shared/listener-seam.ts +52 -0
- package/src/shared/no-link-below.ts +29 -0
|
@@ -8,7 +8,11 @@ import type {
|
|
|
8
8
|
BranchInfo,
|
|
9
9
|
ChangedPathPair,
|
|
10
10
|
CommitAttribution,
|
|
11
|
+
ConflictedFile,
|
|
11
12
|
IGitService,
|
|
13
|
+
MergeIntoBranchOutcome,
|
|
14
|
+
MergeResolution,
|
|
15
|
+
ReservedPushOps,
|
|
12
16
|
RemoteSyncPullResult,
|
|
13
17
|
PrFileStatus,
|
|
14
18
|
PullRequestFile,
|
|
@@ -21,11 +25,14 @@ import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
|
21
25
|
import type { WorkflowHooks, CommitValidationContext } from '../workflow-hooks.js';
|
|
22
26
|
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
23
27
|
import { AccessDeniedError } from '../../access-model/access-errors.js';
|
|
24
|
-
import { WorkspaceMutex } from '../../kb-fs/
|
|
28
|
+
import { WorkspaceIdMutex, type WorkspaceMutex } from '../../kb-fs/index.js';
|
|
25
29
|
import { isAbsence } from '../../../shared/fs.contract.js';
|
|
26
30
|
import { assertInsideRepo } from '../../kb-fs/repo-path.js';
|
|
27
31
|
import { cloneTrackingConfigArgs, SAFE_IMPLICIT_FETCH_ARGS } from '../../kb-fs/clone-config.js';
|
|
28
32
|
import { readRefSha } from '../../kb-fs/index.js';
|
|
33
|
+
import { utf8Text } from '../../../shared/utf8-text.js';
|
|
34
|
+
import { lstatOrNull } from '../../../shared/rename-no-replace.js';
|
|
35
|
+
import { linkedAncestor } from '../../../shared/no-link-below.js';
|
|
29
36
|
import {
|
|
30
37
|
assertValidBranchName,
|
|
31
38
|
assertValidRelativePath,
|
|
@@ -48,7 +55,6 @@ import {
|
|
|
48
55
|
import {
|
|
49
56
|
GitRunError,
|
|
50
57
|
assertOneSpecPerLine,
|
|
51
|
-
isGitTimeout,
|
|
52
58
|
redactGitToken,
|
|
53
59
|
type GitCredentials,
|
|
54
60
|
type GitRunOptions,
|
|
@@ -325,7 +331,10 @@ export class GitService implements IGitService {
|
|
|
325
331
|
*/
|
|
326
332
|
private readonly hooks: WorkflowHooks,
|
|
327
333
|
private readonly kb: KbContext,
|
|
328
|
-
|
|
334
|
+
// Keyed by the CANONICAL workspace id: a route's Express-decoded `:id`
|
|
335
|
+
// (`alice/feature`) and a tool's `alice%2Ffeature` name one clone, and
|
|
336
|
+
// must take one reservation.
|
|
337
|
+
private readonly mutex: WorkspaceMutex = new WorkspaceIdMutex(),
|
|
329
338
|
private readonly accessControl: IAccessControl | null = null,
|
|
330
339
|
/**
|
|
331
340
|
* How git is actually run — see `shared/git.contract.ts`. The composition
|
|
@@ -963,16 +972,18 @@ export class GitService implements IGitService {
|
|
|
963
972
|
async isAncestor(workspaceId: string, ancestor: string, descendant: string): Promise<boolean> {
|
|
964
973
|
const sha = /^[0-9a-f]{4,64}$/i;
|
|
965
974
|
if (!sha.test(ancestor) || !sha.test(descendant)) throw new Error('isAncestor takes commit ids');
|
|
966
|
-
return this.mutex.run(workspaceId, async () =>
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
975
|
+
return this.mutex.run(workspaceId, async () => this.isAncestorUnlocked(await this.repoDir(workspaceId), ancestor, descendant));
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** `isAncestor`'s body, for a caller that already holds the workspace's reservation. */
|
|
979
|
+
private async isAncestorUnlocked(cwd: string, ancestor: string, descendant: string): Promise<boolean> {
|
|
980
|
+
try {
|
|
981
|
+
await this.git(cwd, ['merge-base', '--is-ancestor', ancestor, descendant]);
|
|
982
|
+
return true;
|
|
983
|
+
} catch (err) {
|
|
984
|
+
if (err instanceof GitRunError && !err.timedOut && err.exitCode === 1) return false;
|
|
985
|
+
throw err;
|
|
986
|
+
}
|
|
976
987
|
}
|
|
977
988
|
|
|
978
989
|
/**
|
|
@@ -1475,8 +1486,19 @@ export class GitService implements IGitService {
|
|
|
1475
1486
|
// stamped after the push instead, as one short write made as the
|
|
1476
1487
|
// repository's administrator (see `git`).
|
|
1477
1488
|
await this.git(cwd, ['push', 'origin', branch]);
|
|
1478
|
-
|
|
1479
|
-
|
|
1489
|
+
// The stamp is maintenance after a push that LANDED: a config the
|
|
1490
|
+
// repository's administrator could not write just now (a lock held by
|
|
1491
|
+
// another worktree's stamp) does not make the branch unpublished, and
|
|
1492
|
+
// reporting it as a refused push would hand a published commit to
|
|
1493
|
+
// recovery. Logged; the next push stamps it.
|
|
1494
|
+
try {
|
|
1495
|
+
await this.git(cwd, ['config', '--replace-all', `branch.${branch}.remote`, 'origin']);
|
|
1496
|
+
await this.git(cwd, ['config', '--replace-all', `branch.${branch}.merge`, `refs/heads/${branch}`]);
|
|
1497
|
+
} catch (err) {
|
|
1498
|
+
log.warn(`"${branch}" is pushed; its tracking config could not be stamped after the push:`, {
|
|
1499
|
+
detail: redactGitToken(err instanceof Error ? err.message : String(err), this.credentials.token()),
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1480
1502
|
}
|
|
1481
1503
|
}
|
|
1482
1504
|
|
|
@@ -1521,73 +1543,438 @@ export class GitService implements IGitService {
|
|
|
1521
1543
|
assertValidBranchName(sourceBranch);
|
|
1522
1544
|
assertValidBranchName(targetBranch);
|
|
1523
1545
|
assertValidAuthor(user);
|
|
1546
|
+
// The one merge protocol (`mergeRefIntoCheckout`), asked for what the
|
|
1547
|
+
// auto-merge needs: no resolutions, the conflicted paths alone, no
|
|
1548
|
+
// publish. Under the per-branch workspace model the workspace IS the
|
|
1549
|
+
// source branch, so the checked-out guard is not asked for; nor is the
|
|
1550
|
+
// dirty-tree one — save=share keeps the tree clean before an auto-merge,
|
|
1551
|
+
// and a dirty one is an upstream bug `statusInternal` already logs.
|
|
1524
1552
|
return this.mutex.run(workspaceId, async () => {
|
|
1525
|
-
const
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1553
|
+
const outcome = await this.mergeRefIntoCheckout(workspaceId, await this.repoDir(workspaceId), sourceBranch, targetBranch, user, {
|
|
1554
|
+
subject: `Refresh ${sourceBranch} from ${targetBranch}`,
|
|
1555
|
+
body:
|
|
1556
|
+
`Workflow auto-merge: incorporates the latest ${targetBranch} into ${sourceBranch} ` +
|
|
1557
|
+
`as part of opening or refreshing a change request. ` +
|
|
1558
|
+
`Conflicts (if any) would have been surfaced to the caller before this commit landed; ` +
|
|
1559
|
+
`none were detected, so the merge applied cleanly.`,
|
|
1560
|
+
resolutions: new Map(),
|
|
1561
|
+
guardCheckout: false,
|
|
1562
|
+
describeConflicts: false,
|
|
1563
|
+
});
|
|
1564
|
+
if (outcome.kind === 'conflicts') return { kind: 'conflicts' as const, paths: outcome.unresolved };
|
|
1565
|
+
return { kind: 'clean' as const, alreadyUpToDate: outcome.kind === 'nothing-to-merge' };
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/** The most of one side of a conflicted file that travels in an answer; past it the side is read by the caller instead. */
|
|
1570
|
+
private static readonly CONFLICT_SIDE_MAX_CHARS = 200_000;
|
|
1571
|
+
|
|
1572
|
+
async mergeIntoBranch(
|
|
1573
|
+
workspaceId: string,
|
|
1574
|
+
branch: string,
|
|
1575
|
+
fromBranch: string,
|
|
1576
|
+
user: AuthUser,
|
|
1577
|
+
opts: {
|
|
1578
|
+
subject: string;
|
|
1579
|
+
body: string;
|
|
1580
|
+
resolutions?: MergeResolution[];
|
|
1581
|
+
publish?: (ops: ReservedPushOps) => Promise<void>;
|
|
1582
|
+
authorizeResolutions?: (merge: { ours: string; theirs: string }) => Promise<void>;
|
|
1583
|
+
},
|
|
1584
|
+
): Promise<MergeIntoBranchOutcome> {
|
|
1585
|
+
assertValidBranchName(branch);
|
|
1586
|
+
assertValidBranchName(fromBranch);
|
|
1587
|
+
assertValidAuthor(user);
|
|
1588
|
+
if (/\n\s*\n/.test(opts.subject)) {
|
|
1589
|
+
throw new WorkflowValidationError('merge commit subject must be a single paragraph');
|
|
1590
|
+
}
|
|
1591
|
+
// Every resolution names a repository path; which of them are conflicted
|
|
1592
|
+
// is only known inside the merge, where they are matched exactly.
|
|
1593
|
+
const resolutions = new Map<string, MergeResolution>();
|
|
1594
|
+
// A resolution writes a file on the branch: it is somebody's to
|
|
1595
|
+
// authorize, and a caller that brings none writes nothing (closed).
|
|
1596
|
+
if ((opts.resolutions?.length ?? 0) > 0 && typeof opts.authorizeResolutions !== 'function') {
|
|
1597
|
+
throw new Error('mergeIntoBranch: resolutions need an `authorizeResolutions` to be asked inside the merge');
|
|
1598
|
+
}
|
|
1599
|
+
for (const r of opts.resolutions ?? []) {
|
|
1600
|
+
assertValidRelativePath(r.path);
|
|
1601
|
+
const p = this.stripRepoPrefix(r.path);
|
|
1602
|
+
const ways = [r.content !== undefined, r.choose !== undefined].filter(Boolean).length;
|
|
1603
|
+
if (ways !== 1) {
|
|
1604
|
+
throw new WorkflowValidationError(`resolution for "${p}": give either \`content\` or \`choose\`, not ${ways === 0 ? 'neither' : 'both'}`);
|
|
1605
|
+
}
|
|
1606
|
+
if (r.choose !== undefined && !['ours', 'theirs', 'delete'].includes(r.choose)) {
|
|
1607
|
+
throw new WorkflowValidationError(`resolution for "${p}": \`choose\` is \`ours\`, \`theirs\` or \`delete\``);
|
|
1608
|
+
}
|
|
1609
|
+
if (resolutions.has(p)) throw new WorkflowValidationError(`"${p}" is resolved twice`);
|
|
1610
|
+
resolutions.set(p, { ...r, path: p });
|
|
1611
|
+
}
|
|
1612
|
+
return this.mutex.run(workspaceId, async () =>
|
|
1613
|
+
this.mergeRefIntoCheckout(workspaceId, await this.repoDir(workspaceId), branch, fromBranch, user, {
|
|
1614
|
+
subject: opts.subject,
|
|
1615
|
+
body: opts.body,
|
|
1616
|
+
resolutions,
|
|
1617
|
+
guardCheckout: true,
|
|
1618
|
+
describeConflicts: true,
|
|
1619
|
+
publish: opts.publish,
|
|
1620
|
+
authorizeResolutions: opts.authorizeResolutions,
|
|
1621
|
+
}),
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
/**
|
|
1626
|
+
* THE merge of `origin/<fromBranch>` into the checkout at `cwd` — what
|
|
1627
|
+
* `mergeFromOrigin` (the change-request auto-merge) and `mergeIntoBranch`
|
|
1628
|
+
* (the agent's update) both run. The caller holds the workspace's
|
|
1629
|
+
* reservation. `--no-commit --no-ff`, so the commit is authored as `user`
|
|
1630
|
+
* and the record of what was merged is never fast-forwarded past.
|
|
1631
|
+
*
|
|
1632
|
+
* The checkout is never left mid-merge, whatever happens: every exit after
|
|
1633
|
+
* `git merge` has started — a conflict described, a resolution refused or
|
|
1634
|
+
* failed, a commit or a publish that failed — runs `merge --abort` first,
|
|
1635
|
+
* and an abort that itself fails is reported, never swallowed, because a
|
|
1636
|
+
* checkout stuck in a merge is what breaks the commit worker and every
|
|
1637
|
+
* later operation on the branch.
|
|
1638
|
+
*
|
|
1639
|
+
* `publish` runs over the merge commit inside this same reservation, so no
|
|
1640
|
+
* save or sync can land between the commit and its push; it is the
|
|
1641
|
+
* caller's push policy (the workflow's `pushWithRecovery`: one replay onto
|
|
1642
|
+
* the moved branch with the merge kept, `--rebase-merges`, then the typed
|
|
1643
|
+
* hand-off) over this checkout's own unlocked moves.
|
|
1644
|
+
*/
|
|
1645
|
+
private async mergeRefIntoCheckout(
|
|
1646
|
+
workspaceId: string,
|
|
1647
|
+
cwd: string,
|
|
1648
|
+
branch: string,
|
|
1649
|
+
fromBranch: string,
|
|
1650
|
+
user: AuthUser,
|
|
1651
|
+
opts: {
|
|
1652
|
+
subject: string;
|
|
1653
|
+
body: string;
|
|
1654
|
+
resolutions: Map<string, MergeResolution>;
|
|
1655
|
+
/** Refuse a checkout that is not on `branch`, or holds uncommitted edits. */
|
|
1656
|
+
guardCheckout: boolean;
|
|
1657
|
+
/** Carry every conflicted file's sides in the answer; else the paths alone. */
|
|
1658
|
+
describeConflicts: boolean;
|
|
1659
|
+
/** Run over the merge commit inside the reservation, with the pushes it may make. */
|
|
1660
|
+
publish?: (ops: ReservedPushOps) => Promise<void>;
|
|
1661
|
+
/**
|
|
1662
|
+
* Decide whether the caller may settle the paths the resolutions name,
|
|
1663
|
+
* given the two commits the merge is actually between — asked INSIDE
|
|
1664
|
+
* the reservation, after the fetch and BEFORE the merge touches the
|
|
1665
|
+
* checkout: the source rules read are those of the commit being
|
|
1666
|
+
* merged, not of a tracking ref from before it moved, and the
|
|
1667
|
+
* checkout's are the branch's own commit (`ours`), clean — never a
|
|
1668
|
+
* tree mid-merge, where a rule file's conflict markers or merged
|
|
1669
|
+
* content would decide. Throwing refuses before anything is written.
|
|
1670
|
+
*/
|
|
1671
|
+
authorizeResolutions?: (merge: { ours: string; theirs: string }) => Promise<void>;
|
|
1672
|
+
},
|
|
1673
|
+
): Promise<MergeIntoBranchOutcome> {
|
|
1674
|
+
const theirsRef = `refs/remotes/origin/${fromBranch}`;
|
|
1675
|
+
if (opts.guardCheckout) {
|
|
1676
|
+
const checkedOut = await this.currentBranch(cwd);
|
|
1677
|
+
if (checkedOut !== branch) {
|
|
1678
|
+
throw new WorkflowValidationError(`the workspace is on "${checkedOut}", not on "${branch}"`);
|
|
1679
|
+
}
|
|
1680
|
+
// A merge rewrites tracked files. Edits still on their way to a commit
|
|
1681
|
+
// (a save the worker has not landed) would be swept into the merge
|
|
1682
|
+
// commit under this user's name, or lost under the merge's version.
|
|
1683
|
+
const { stdout: dirty } = await this.git(cwd, ['status', '--porcelain=v1', '-z']);
|
|
1684
|
+
if (parsePorcelainZ(dirty).length > 0) {
|
|
1685
|
+
throw new WorkflowDomainError(
|
|
1686
|
+
`"${branch}" has edits that are not committed yet. Try again once they are saved.`,
|
|
1687
|
+
409,
|
|
1688
|
+
{ kind: 'branch-busy', branch },
|
|
1689
|
+
);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
// `git merge`/`git commit` need a committer identity (see `mergeChangeRequest`).
|
|
1693
|
+
await this.git(cwd, ['config', 'user.name', BOT_NAME]);
|
|
1694
|
+
await this.git(cwd, ['config', 'user.email', BOT_EMAIL]);
|
|
1695
|
+
// The published tip, explicitly: a bare `fetch origin <branch>` updates
|
|
1696
|
+
// the tracking ref only as far as `remote.origin.fetch` allows, and a
|
|
1697
|
+
// stale ref would hide exactly the conflicts this merge exists to surface.
|
|
1698
|
+
await this.git(cwd, ['fetch', '--no-write-fetch-head', 'origin', `+refs/heads/${fromBranch}:${theirsRef}`]);
|
|
1699
|
+
const ours = (await this.git(cwd, ['rev-parse', 'HEAD'])).stdout.trim();
|
|
1700
|
+
// The COMMIT the merge is of, from here on — never the ref it was read
|
|
1701
|
+
// from: the repository's refs are shared by every worktree and moved by
|
|
1702
|
+
// every fetch (the timer's sync, another workspace's merge), so what is
|
|
1703
|
+
// authorized, merged, described and recorded is this one sha.
|
|
1704
|
+
const theirs = (await this.git(cwd, ['rev-parse', theirsRef])).stdout.trim();
|
|
1705
|
+
// Resolutions are authorized here, on the clean checkout at `ours` with
|
|
1706
|
+
// `theirs` fetched, before the merge changes a single file. Which of
|
|
1707
|
+
// them are conflicted is matched after the merge; a stray one is refused.
|
|
1708
|
+
if (opts.resolutions.size > 0 && opts.authorizeResolutions) await opts.authorizeResolutions({ ours, theirs });
|
|
1709
|
+
// A merge with nothing to settle leaves no resolution a place to go: one
|
|
1710
|
+
// supplied anyway names a path that is not conflicted, and is refused as
|
|
1711
|
+
// such rather than dropped on the floor behind a published merge.
|
|
1712
|
+
const noConflictsFor = (): WorkflowValidationError =>
|
|
1713
|
+
new WorkflowValidationError(
|
|
1714
|
+
`nothing conflicted, so nothing is resolved here: ${[...opts.resolutions.keys()].join(', ')}.`,
|
|
1715
|
+
{ kind: 'not-conflicted', paths: [...opts.resolutions.keys()] },
|
|
1716
|
+
);
|
|
1717
|
+
// Nothing to merge is a question of ANCESTRY, not of content: a source
|
|
1718
|
+
// the branch already reaches is merged; one it does not reach is merged
|
|
1719
|
+
// in even when the two trees are identical, so the branch records that
|
|
1720
|
+
// it holds the source's history and stops reading as behind it.
|
|
1721
|
+
if (await this.isAncestorUnlocked(cwd, theirs, ours)) {
|
|
1722
|
+
if (opts.resolutions.size > 0) throw noConflictsFor();
|
|
1723
|
+
return { kind: 'nothing-to-merge', sha: ours, from: fromBranch };
|
|
1724
|
+
}
|
|
1548
1725
|
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1726
|
+
try {
|
|
1727
|
+
await this.git(cwd, ['merge', '--no-commit', '--no-ff', theirs]);
|
|
1728
|
+
} catch (err) {
|
|
1729
|
+
// A conflict, or another failure: either way the merge may be on disk
|
|
1730
|
+
// from here on, and every exit below undoes it.
|
|
1731
|
+
return this.settleMerge(cwd, async () => {
|
|
1732
|
+
const conflicted = await this.conflictedPaths(cwd);
|
|
1733
|
+
if (conflicted.length === 0) {
|
|
1734
|
+
throw new Error(
|
|
1735
|
+
`git merge failed without detectable conflicts: ${redactGitToken(
|
|
1736
|
+
err instanceof Error ? err.message : String(err),
|
|
1737
|
+
this.credentials.token(),
|
|
1738
|
+
)}`,
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
1741
|
+
const stray = [...opts.resolutions.keys()].filter((p) => !conflicted.includes(p));
|
|
1742
|
+
if (stray.length > 0) {
|
|
1743
|
+
// Only a conflicted path is written by a resolution: anything else
|
|
1744
|
+
// is an ordinary write, which goes through the file tools and their
|
|
1745
|
+
// locks. Refused whole, before any resolution is applied — naming
|
|
1746
|
+
// the caller's own paths only, never the conflicted set (which the
|
|
1747
|
+
// caller is told of, cut to what they may read, by the answer).
|
|
1748
|
+
throw new WorkflowValidationError(
|
|
1749
|
+
`not conflicted, so not resolved here: ${stray.join(', ')}.`,
|
|
1750
|
+
{ kind: 'not-conflicted', paths: stray },
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1753
|
+
const unresolved = conflicted.filter((p) => !opts.resolutions.has(p));
|
|
1754
|
+
if (unresolved.length > 0) {
|
|
1755
|
+
// Described while the merge is on disk — the three stages and the
|
|
1756
|
+
// marked-up working copy exist only then — and undone (by
|
|
1757
|
+
// `settleMerge`) before the answer leaves the reservation.
|
|
1758
|
+
const mergeBase = await this.git(cwd, ['merge-base', ours, theirs])
|
|
1759
|
+
.then((r) => r.stdout.trim() || null)
|
|
1760
|
+
.catch(() => null);
|
|
1761
|
+
const files: ConflictedFile[] = [];
|
|
1762
|
+
if (opts.describeConflicts) for (const p of conflicted) files.push(await this.describeConflict(cwd, p));
|
|
1763
|
+
return { done: false, outcome: { kind: 'conflicts', from: fromBranch, mergeBase, ours, theirs, files, unresolved } };
|
|
1764
|
+
}
|
|
1765
|
+
for (const p of conflicted) await this.applyResolution(cwd, opts.resolutions.get(p)!);
|
|
1766
|
+
const still = await this.conflictedPaths(cwd);
|
|
1767
|
+
if (still.length > 0) throw new Error(`still conflicted after the resolutions were applied: ${still.join(', ')}`);
|
|
1768
|
+
return {
|
|
1769
|
+
done: true,
|
|
1770
|
+
outcome: await this.commitMerge(workspaceId, cwd, branch, fromBranch, user, opts, conflicted),
|
|
1771
|
+
};
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
// A clean merge: committed whatever it staged — an empty diff (the two
|
|
1775
|
+
// sides made the same change) is still the source's history joined.
|
|
1776
|
+
return this.settleMerge(cwd, async () => {
|
|
1777
|
+
if (opts.resolutions.size > 0) throw noConflictsFor();
|
|
1778
|
+
return { done: true, outcome: await this.commitMerge(workspaceId, cwd, branch, fromBranch, user, opts, []) };
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
/**
|
|
1784
|
+
* Run `work` while a merge may be on disk, and leave none: an answer with
|
|
1785
|
+
* `done: false` (a conflict described, nothing to merge) is aborted before
|
|
1786
|
+
* it is returned, and a failure anywhere in `work` is aborted before it is
|
|
1787
|
+
* rethrown. A commit that landed (`done: true`) needs no abort. An abort
|
|
1788
|
+
* that fails while a merge is still in progress is the one thing never
|
|
1789
|
+
* swallowed: it is reported with the failure it followed.
|
|
1790
|
+
*/
|
|
1791
|
+
private async settleMerge(
|
|
1792
|
+
cwd: string,
|
|
1793
|
+
work: () => Promise<{ done: boolean; outcome: MergeIntoBranchOutcome }>,
|
|
1794
|
+
): Promise<MergeIntoBranchOutcome> {
|
|
1795
|
+
let result: { done: boolean; outcome: MergeIntoBranchOutcome };
|
|
1796
|
+
try {
|
|
1797
|
+
result = await work();
|
|
1798
|
+
} catch (err) {
|
|
1799
|
+
await this.abortMerge(cwd, err);
|
|
1800
|
+
throw err;
|
|
1801
|
+
}
|
|
1802
|
+
if (!result.done) await this.abortMerge(cwd);
|
|
1803
|
+
return result.outcome;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/**
|
|
1807
|
+
* `git merge --abort`, and proof that nothing is left: a failed abort with
|
|
1808
|
+
* no `MERGE_HEAD` is the benign "no merge to abort"; one that leaves
|
|
1809
|
+
* `MERGE_HEAD` behind is thrown (wrapping `after`, the failure that led
|
|
1810
|
+
* here, when there is one), because the checkout is now stuck.
|
|
1811
|
+
*/
|
|
1812
|
+
private async abortMerge(cwd: string, after?: unknown): Promise<void> {
|
|
1813
|
+
try {
|
|
1814
|
+
await this.git(cwd, ['merge', '--abort']);
|
|
1815
|
+
return;
|
|
1816
|
+
} catch (abortErr) {
|
|
1817
|
+
const merging = await this.refExists(cwd, 'MERGE_HEAD').catch(() => true);
|
|
1818
|
+
if (!merging) return;
|
|
1819
|
+
const detail = redactGitToken(abortErr instanceof Error ? abortErr.message : String(abortErr), this.credentials.token());
|
|
1820
|
+
log.error(`a merge in ${cwd} could not be undone; the checkout is left mid-merge:`, { detail });
|
|
1821
|
+
const lead = after === undefined ? 'the merge could not be undone' : `${after instanceof Error ? after.message : String(after)}; and the merge could not be undone`;
|
|
1822
|
+
throw new Error(`${lead}: ${detail}`, { cause: after ?? abortErr });
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
/** The merge commit, as `user`, and its publish when asked for. The caller settles a failure. */
|
|
1827
|
+
private async commitMerge(
|
|
1828
|
+
workspaceId: string,
|
|
1829
|
+
cwd: string,
|
|
1830
|
+
branch: string,
|
|
1831
|
+
fromBranch: string,
|
|
1832
|
+
user: AuthUser,
|
|
1833
|
+
opts: { subject: string; body: string; publish?: (ops: ReservedPushOps) => Promise<void> },
|
|
1834
|
+
resolvedPaths: string[],
|
|
1835
|
+
): Promise<MergeIntoBranchOutcome> {
|
|
1836
|
+
await this.git(cwd, ['commit', `--author=${user.name} <${user.email}>`, '-m', opts.subject, '-m', opts.body]);
|
|
1837
|
+
this.accessControl?.invalidate(workspaceId);
|
|
1838
|
+
// The publish runs here, inside the reservation the merge was made in,
|
|
1839
|
+
// over this checkout's own push and pull-rebase (the merge kept as a
|
|
1840
|
+
// merge) — the caller's policy, the git layer's moves. Whatever it
|
|
1841
|
+
// throws leaves the merge commit on the branch locally; a replay that
|
|
1842
|
+
// conflicts aborts itself (`rebaseOntoRemote`), so the checkout is clean.
|
|
1843
|
+
if (opts.publish) {
|
|
1844
|
+
await opts.publish({
|
|
1845
|
+
push: () => this.pushUnlocked(workspaceId, cwd, user),
|
|
1846
|
+
pullRebase: () => this.pullUnlocked(workspaceId, cwd, { preserveMerges: true }),
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
// Read AFTER the publish: a push the host rejected is retried with the
|
|
1850
|
+
// local commits replayed onto the moved branch, which gives the merge
|
|
1851
|
+
// commit a new id — the one that is now the published tip.
|
|
1852
|
+
const sha = (await this.git(cwd, ['rev-parse', 'HEAD'])).stdout.trim();
|
|
1853
|
+
return { kind: 'merged', sha, from: fromBranch, resolvedPaths };
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/**
|
|
1857
|
+
* One conflicted path as the merge on disk has it: the three index stages
|
|
1858
|
+
* (base, ours, theirs) and the working copy with git's markers. A side that
|
|
1859
|
+
* is not there (a delete on that side) is null; one that is not text or too
|
|
1860
|
+
* large to carry is left out and marked, for the caller to read at the
|
|
1861
|
+
* commit instead. Read while the merge is on disk — the stages exist only
|
|
1862
|
+
* then.
|
|
1863
|
+
*/
|
|
1864
|
+
private async describeConflict(cwd: string, repoRelativePath: string): Promise<ConflictedFile> {
|
|
1865
|
+
const truncated: NonNullable<ConflictedFile['truncated']> = [];
|
|
1866
|
+
const side = async (name: 'base' | 'ours' | 'theirs', stage: 1 | 2 | 3): Promise<string | null> => {
|
|
1867
|
+
let bytes: Buffer;
|
|
1553
1868
|
try {
|
|
1554
|
-
|
|
1555
|
-
'merge', '--no-commit', '--no-ff', `origin/${targetBranch}`,
|
|
1556
|
-
]);
|
|
1557
|
-
mergeStdout = r.stdout;
|
|
1869
|
+
bytes = (await this.gitRunner.run(cwd, ['show', `:${stage}:${repoRelativePath}`], { encoding: 'buffer' })).stdout;
|
|
1558
1870
|
} catch (err) {
|
|
1559
|
-
//
|
|
1560
|
-
|
|
1561
|
-
//
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
}
|
|
1567
|
-
if (conflictPaths.length === 0) throw err;
|
|
1568
|
-
return { kind: 'conflicts' as const, paths: conflictPaths };
|
|
1871
|
+
// ONLY a stage git says is not there is a side that has no file (that
|
|
1872
|
+
// side deleted it; the base never had it). Anything else — a deadline,
|
|
1873
|
+
// a repository failure — is not "deleted", and reporting it as such
|
|
1874
|
+
// would have a resolver drop a file over a hiccup.
|
|
1875
|
+
const msg = (err as { stderr?: string }).stderr ?? (err instanceof Error ? err.message : String(err));
|
|
1876
|
+
if (/is in the index, but not at stage|does not exist in|exists on disk, but not in|invalid object name/i.test(msg)) return null;
|
|
1877
|
+
throw err;
|
|
1569
1878
|
}
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1879
|
+
return this.carriedText(bytes, () => truncated.push(name));
|
|
1880
|
+
};
|
|
1881
|
+
const base = await side('base', 1);
|
|
1882
|
+
const ours = await side('ours', 2);
|
|
1883
|
+
const theirs = await side('theirs', 3);
|
|
1884
|
+
let merged: string | null = null;
|
|
1885
|
+
// The working copy is read from disk, so it is read only where that
|
|
1886
|
+
// stays inside the checkout: a link (the path itself, or a folder on
|
|
1887
|
+
// the way to it) is never followed — the side is left out instead.
|
|
1888
|
+
const file = path.join(cwd, repoRelativePath);
|
|
1889
|
+
const leaf = await lstatOrNull(file);
|
|
1890
|
+
if (leaf?.isSymbolicLink() || (await linkedAncestor(cwd, path.dirname(file))) !== null) {
|
|
1891
|
+
truncated.push('merged');
|
|
1892
|
+
} else {
|
|
1893
|
+
try {
|
|
1894
|
+
merged = this.carriedText(await fs.readFile(file), () => truncated.push('merged'));
|
|
1895
|
+
} catch (err) {
|
|
1896
|
+
if (!isAbsence(err)) throw err;
|
|
1576
1897
|
}
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1898
|
+
}
|
|
1899
|
+
return { path: repoRelativePath, base, ours, theirs, merged, ...(truncated.length > 0 ? { truncated } : {}) };
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
/**
|
|
1903
|
+
* `bytes` as text when they are text by the one rule every reader applies
|
|
1904
|
+
* (`utf8Text`: no NUL, valid UTF-8, BOM kept) and small enough to carry;
|
|
1905
|
+
* else null, and `left` is told.
|
|
1906
|
+
*/
|
|
1907
|
+
private carriedText(bytes: Buffer, left: () => void): string | null {
|
|
1908
|
+
const text = bytes.length > GitService.CONFLICT_SIDE_MAX_CHARS ? null : utf8Text(bytes);
|
|
1909
|
+
if (text === null) left();
|
|
1910
|
+
return text;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
/**
|
|
1914
|
+
* Settle one conflicted path on disk and in the index, as the resolution
|
|
1915
|
+
* says. `r.path` is one the merge itself listed as conflicted (matched
|
|
1916
|
+
* exactly by the caller), so it is a path git owns inside the checkout.
|
|
1917
|
+
* A side kept whole is restored by git from its index stage, byte for
|
|
1918
|
+
* byte — never through a text decoding — so a binary file survives it.
|
|
1919
|
+
*/
|
|
1920
|
+
private async applyResolution(cwd: string, r: MergeResolution): Promise<void> {
|
|
1921
|
+
const file = path.join(cwd, r.path);
|
|
1922
|
+
// Written and removed where that stays inside the checkout: a folder on
|
|
1923
|
+
// the way that is a link is refused, and a path that is itself a link is
|
|
1924
|
+
// replaced as a path (the link removed first), never written through.
|
|
1925
|
+
if ((await linkedAncestor(cwd, path.dirname(file))) !== null) {
|
|
1926
|
+
throw new WorkflowValidationError(`"${r.path}" is reached through a link, so it is not resolved here`);
|
|
1927
|
+
}
|
|
1928
|
+
if (r.content !== undefined) {
|
|
1929
|
+
if ((await lstatOrNull(file))?.isSymbolicLink()) await fs.unlink(file);
|
|
1930
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
1931
|
+
await fs.writeFile(file, r.content, 'utf-8');
|
|
1932
|
+
await this.git(cwd, ['add', '--', r.path]);
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
if (r.choose === 'delete') {
|
|
1936
|
+
// `rm` removes a link itself, never what it points at.
|
|
1937
|
+
await fs.rm(file, { force: true });
|
|
1938
|
+
await this.git(cwd, ['add', '-A', '--', r.path]);
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
// `ours`/`theirs`: git checks that side's stage out as it is. A side with
|
|
1942
|
+
// no stage (deleted there) is "does not have our/their version": the
|
|
1943
|
+
// file goes.
|
|
1944
|
+
try {
|
|
1945
|
+
await this.git(cwd, ['checkout', r.choose === 'ours' ? '--ours' : '--theirs', '--', r.path]);
|
|
1946
|
+
} catch (err) {
|
|
1947
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1948
|
+
if (!/does not have (our|their) version/i.test(msg)) throw err;
|
|
1949
|
+
await fs.rm(file, { force: true });
|
|
1950
|
+
}
|
|
1951
|
+
await this.git(cwd, ['add', '-A', '--', r.path]);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
async readFileAtBranch(
|
|
1955
|
+
workspaceId: string,
|
|
1956
|
+
relativePath: string,
|
|
1957
|
+
branch: string,
|
|
1958
|
+
authorize?: (commits: string[]) => Promise<void>,
|
|
1959
|
+
): Promise<{ sha: string; ref: string; content: string | null }> {
|
|
1960
|
+
assertValidRelativePath(relativePath);
|
|
1961
|
+
assertValidBranchName(branch);
|
|
1962
|
+
const repoRelativePath = this.stripRepoPrefix(relativePath);
|
|
1963
|
+
return this.mutex.run(workspaceId, async () => {
|
|
1964
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1965
|
+
// The PUBLISHED tip: what the branch's own workspace holds and has not
|
|
1966
|
+
// shared is nobody else's to read, so `origin/<branch>` comes first and
|
|
1967
|
+
// the local head serves only a branch origin does not have.
|
|
1968
|
+
const { ref, sha } = await this.resolvePublishedBranch(cwd, branch);
|
|
1969
|
+
// The caller's verdict, at every commit the branch resolves to here
|
|
1970
|
+
// (the served one first), BEFORE anything is said about the path at
|
|
1971
|
+
// it: a refusal that names what the path is (a folder) is an answer
|
|
1972
|
+
// about content the caller may not read.
|
|
1973
|
+
if (authorize) await authorize(await this.branchCommits(cwd, branch, sha));
|
|
1974
|
+
// Per file, as every history read: a folder's content at a commit is
|
|
1975
|
+
// every child's, which the per-file gate never checked.
|
|
1976
|
+
await this.assertNotTreeAtRef(cwd, sha, repoRelativePath, relativePath);
|
|
1977
|
+
return { sha, ref, content: await this.readFileAtRef(workspaceId, sha, repoRelativePath) };
|
|
1591
1978
|
});
|
|
1592
1979
|
}
|
|
1593
1980
|
|
|
@@ -2052,15 +2439,23 @@ export class GitService implements IGitService {
|
|
|
2052
2439
|
return stdout.trim().length > 0;
|
|
2053
2440
|
}
|
|
2054
2441
|
|
|
2055
|
-
/**
|
|
2442
|
+
/**
|
|
2443
|
+
* `git rev-parse --verify --quiet`: the commit `rev` names, or null when
|
|
2444
|
+
* the rev is ABSENT (an unborn HEAD, a ref nobody made) — and nothing
|
|
2445
|
+
* else. Git says "absent" with exit 1 and an empty stderr; a lookup that
|
|
2446
|
+
* fails any other way (a corrupt repository, a deadline) throws, because
|
|
2447
|
+
* every caller reads null as a fact about the repository — "unborn",
|
|
2448
|
+
* "no such tip", "nothing to authorize at" — and a failure dressed as that
|
|
2449
|
+
* fact would make a sync reset an unborn clone or a verdict list lose a
|
|
2450
|
+
* commit.
|
|
2451
|
+
*/
|
|
2056
2452
|
private async revParseOrNull(cwd: string, rev: string): Promise<string | null> {
|
|
2057
2453
|
try {
|
|
2058
2454
|
const { stdout } = await this.git(cwd, ['rev-parse', '--verify', '--quiet', rev]);
|
|
2059
2455
|
return stdout.trim() || null;
|
|
2060
2456
|
} catch (err) {
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
return null;
|
|
2457
|
+
if (err instanceof GitRunError && !err.timedOut && err.exitCode === 1 && (err.stderr ?? '').trim() === '') return null;
|
|
2458
|
+
throw err;
|
|
2064
2459
|
}
|
|
2065
2460
|
}
|
|
2066
2461
|
|
|
@@ -2273,23 +2668,29 @@ export class GitService implements IGitService {
|
|
|
2273
2668
|
workspaceId: string,
|
|
2274
2669
|
opts: { preserveMerges?: boolean } = {},
|
|
2275
2670
|
): Promise<{ treeChanged: boolean }> {
|
|
2276
|
-
return this.mutex.run(workspaceId, async () =>
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2671
|
+
return this.mutex.run(workspaceId, async () => this.pullUnlocked(workspaceId, await this.repoDir(workspaceId), opts));
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
/** `pull`'s body, for a caller that already holds the workspace's reservation. */
|
|
2675
|
+
private async pullUnlocked(
|
|
2676
|
+
workspaceId: string,
|
|
2677
|
+
cwd: string,
|
|
2678
|
+
opts: { preserveMerges?: boolean } = {},
|
|
2679
|
+
): Promise<{ treeChanged: boolean }> {
|
|
2680
|
+
const branch = await this.checkedOutBranch(cwd);
|
|
2681
|
+
// For `treeChanged` (see IGitService.pull): TREE ids, not commit ids —
|
|
2682
|
+
// a pull that lands only content-identical commits (an empty commit)
|
|
2683
|
+
// must not read as a content change. Tolerant of an unborn HEAD (a
|
|
2684
|
+
// clone of an empty upstream): "" before + a tree after correctly
|
|
2685
|
+
// reads as changed, "" both sides as not.
|
|
2686
|
+
const treeBefore = (await this.revParseOrNull(cwd, 'HEAD^{tree}')) ?? '';
|
|
2687
|
+
// Refresh WITHOUT `git pull`, and without touching `FETCH_HEAD` — see
|
|
2688
|
+
// `refreshRemoteBranchRef` for why both halves are load-bearing.
|
|
2689
|
+
const remoteRef = await this.refreshRemoteBranchRef(cwd, branch);
|
|
2690
|
+
await this.rebaseOntoRemote(cwd, branch, remoteRef, opts);
|
|
2691
|
+
this.accessControl?.invalidate(workspaceId);
|
|
2692
|
+
const treeAfter = (await this.revParseOrNull(cwd, 'HEAD^{tree}')) ?? '';
|
|
2693
|
+
return { treeChanged: treeAfter !== treeBefore };
|
|
2293
2694
|
}
|
|
2294
2695
|
|
|
2295
2696
|
/**
|
|
@@ -2775,7 +3176,8 @@ export class GitService implements IGitService {
|
|
|
2775
3176
|
relativePath: string,
|
|
2776
3177
|
fromBranch: string,
|
|
2777
3178
|
toBranch: string,
|
|
2778
|
-
|
|
3179
|
+
authorize?: (sides: { from: string[]; to: string[] }) => Promise<void>,
|
|
3180
|
+
): Promise<{ diff: string; from: string; to: string }> {
|
|
2779
3181
|
assertValidRelativePath(relativePath);
|
|
2780
3182
|
assertValidBranchName(fromBranch);
|
|
2781
3183
|
assertValidBranchName(toBranch);
|
|
@@ -2785,8 +3187,21 @@ export class GitService implements IGitService {
|
|
|
2785
3187
|
const repoRelativePath = this.stripRepoPrefix(relativePath);
|
|
2786
3188
|
return this.mutex.run(workspaceId, async () => {
|
|
2787
3189
|
const cwd = await this.repoDir(workspaceId);
|
|
2788
|
-
|
|
2789
|
-
|
|
3190
|
+
// Each side's COMMIT, read once here and diffed from here on: what the
|
|
3191
|
+
// caller authorizes the answer at. The ref is a name a fetch may move.
|
|
3192
|
+
const fromRef = (await this.git(cwd, ['rev-parse', await this.resolveBranchRef(cwd, fromBranch)])).stdout.trim();
|
|
3193
|
+
const toRef = (await this.git(cwd, ['rev-parse', await this.resolveBranchRef(cwd, toBranch)])).stdout.trim();
|
|
3194
|
+
const pinned = (diff: string): { diff: string; from: string; to: string } => ({ diff, from: fromRef, to: toRef });
|
|
3195
|
+
// The caller's verdict at every commit each side resolves to here (the
|
|
3196
|
+
// served one first) BEFORE anything is said about the path at either:
|
|
3197
|
+
// a refusal that names what the path is there (a folder) is an answer
|
|
3198
|
+
// about content the caller may not read.
|
|
3199
|
+
if (authorize) {
|
|
3200
|
+
await authorize({
|
|
3201
|
+
from: await this.branchCommits(cwd, fromBranch, fromRef),
|
|
3202
|
+
to: await this.branchCommits(cwd, toBranch, toRef),
|
|
3203
|
+
});
|
|
3204
|
+
}
|
|
2790
3205
|
await this.assertNotTreeAtRef(cwd, fromRef, repoRelativePath, relativePath);
|
|
2791
3206
|
await this.assertNotTreeAtRef(cwd, toRef, repoRelativePath, relativePath);
|
|
2792
3207
|
// When one side is the currently-checked-out branch, diff against the
|
|
@@ -2806,11 +3221,7 @@ export class GitService implements IGitService {
|
|
|
2806
3221
|
isToCurrent !== isFromCurrent &&
|
|
2807
3222
|
(await this.isPathUntracked(cwd, repoRelativePath))
|
|
2808
3223
|
) {
|
|
2809
|
-
return synthesizeUntrackedSideDiff(
|
|
2810
|
-
cwd,
|
|
2811
|
-
repoRelativePath,
|
|
2812
|
-
isToCurrent ? 'added' : 'removed',
|
|
2813
|
-
);
|
|
3224
|
+
return pinned(await synthesizeUntrackedSideDiff(cwd, repoRelativePath, isToCurrent ? 'added' : 'removed'));
|
|
2814
3225
|
}
|
|
2815
3226
|
const args = ['diff', '--no-color'];
|
|
2816
3227
|
if (isToCurrent && !isFromCurrent) {
|
|
@@ -2832,7 +3243,7 @@ export class GitService implements IGitService {
|
|
|
2832
3243
|
args.push('--', repoRelativePath);
|
|
2833
3244
|
try {
|
|
2834
3245
|
const { stdout } = await this.git(cwd, args);
|
|
2835
|
-
return stdout;
|
|
3246
|
+
return pinned(stdout);
|
|
2836
3247
|
} catch (err) {
|
|
2837
3248
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2838
3249
|
throw new Error(
|
|
@@ -3757,6 +4168,26 @@ export class GitService implements IGitService {
|
|
|
3757
4168
|
throw new WorkflowValidationError(`unknown branch: ${branch}`);
|
|
3758
4169
|
}
|
|
3759
4170
|
|
|
4171
|
+
/**
|
|
4172
|
+
* Every commit `branch` resolves to in this repository — its local head
|
|
4173
|
+
* and its published tip (`origin/<branch>`), whichever resolve, deduped,
|
|
4174
|
+
* `served` first. What a caller's read verdict is asked at: content of
|
|
4175
|
+
* another branch is served from one of them, and a grant at the served one
|
|
4176
|
+
* must not outrun a denial at the other (a draft's unshared commits may
|
|
4177
|
+
* carry rules its published tip does not, and the other way round) — the
|
|
4178
|
+
* rule the compare route always applied to both refs, pinned to commits.
|
|
4179
|
+
*/
|
|
4180
|
+
private async branchCommits(cwd: string, branch: string, served: string): Promise<string[]> {
|
|
4181
|
+
const commits = [served];
|
|
4182
|
+
for (const ref of [`refs/heads/${branch}`, `refs/remotes/origin/${branch}`]) {
|
|
4183
|
+
// Null is ABSENT and nothing else (`revParseOrNull`): a lookup that
|
|
4184
|
+
// fails otherwise throws, so this list never quietly loses a commit.
|
|
4185
|
+
const sha = await this.revParseOrNull(cwd, ref);
|
|
4186
|
+
if (sha && !commits.includes(sha)) commits.push(sha);
|
|
4187
|
+
}
|
|
4188
|
+
return commits;
|
|
4189
|
+
}
|
|
4190
|
+
|
|
3760
4191
|
/**
|
|
3761
4192
|
* Resolve a branch name to a concrete ref the local clone knows about.
|
|
3762
4193
|
* Tries the local head first, falls back to the matching remote-tracking
|