@open-agent-toolkit/cli 0.2.41 → 0.2.45
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/assets/agents/oat-phase-implementer.md +11 -1
- package/assets/bundle-metadata.json +1 -1
- package/assets/docs/cli-utilities/configuration.md +11 -1
- package/assets/docs/quickstart.md +9 -0
- package/assets/docs/reference/cli-reference.md +56 -1
- package/assets/docs/reference/file-locations.md +18 -1
- package/assets/docs/reference/oat-directory-structure.md +20 -4
- package/assets/docs/workflows/projects/artifacts.md +27 -1
- package/assets/docs/workflows/projects/implementation-execution.md +22 -0
- package/assets/docs/workflows/projects/index.md +6 -0
- package/assets/docs/workflows/projects/lifecycle.md +19 -5
- package/assets/docs/workflows/projects/picking-up-projects.md +75 -0
- package/assets/docs/workflows/projects/pr-flow.md +22 -1
- package/assets/docs/workflows/projects/reviewing-oat-prs.md +60 -0
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/oat-brainstorm/SKILL.md +135 -32
- package/assets/skills/oat-brainstorm/references/destinations.md +9 -6
- package/assets/skills/oat-cursor-cloud-projects/SKILL.md +11 -1
- package/assets/skills/oat-doctor/SKILL.md +8 -1
- package/assets/skills/oat-project-autonomous/SKILL.md +45 -1
- package/assets/skills/oat-project-autonomous/references/gate-inventory.md +8 -8
- package/assets/skills/oat-project-capture/SKILL.md +29 -9
- package/assets/skills/oat-project-complete/SKILL.md +610 -38
- package/assets/skills/oat-project-complete/scripts/parse-completion-retry-fields.mjs +265 -0
- package/assets/skills/oat-project-complete/scripts/recover-completion-receipts.mjs +764 -0
- package/assets/skills/oat-project-complete/scripts/resolve-completion-retry.mjs +134 -0
- package/assets/skills/oat-project-complete/scripts/validate-nonarchive-lifecycle-receipt.mjs +160 -0
- package/assets/skills/oat-project-design/SKILL.md +33 -9
- package/assets/skills/oat-project-discover/SKILL.md +16 -3
- package/assets/skills/oat-project-document/SKILL.md +10 -4
- package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +8 -8
- package/assets/skills/oat-project-implement/SKILL.md +14 -3
- package/assets/skills/oat-project-implement/references/completion-and-closeout.md +18 -6
- package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +8 -8
- package/assets/skills/oat-project-implement/references/phase-execution.md +9 -3
- package/assets/skills/oat-project-implement/references/plan-and-resume.md +3 -0
- package/assets/skills/oat-project-import-plan/SKILL.md +15 -9
- package/assets/skills/oat-project-new/SKILL.md +7 -5
- package/assets/skills/oat-project-next/SKILL.md +32 -10
- package/assets/skills/oat-project-plan/SKILL.md +18 -3
- package/assets/skills/oat-project-pr-final/SKILL.md +23 -2
- package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +8 -8
- package/assets/skills/oat-project-pr-progress/SKILL.md +23 -2
- package/assets/skills/oat-project-progress/SKILL.md +23 -9
- package/assets/skills/oat-project-promote-spec-driven/SKILL.md +20 -2
- package/assets/skills/oat-project-quick-start/SKILL.md +48 -18
- package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +8 -8
- package/assets/skills/oat-project-reconcile/SKILL.md +14 -10
- package/assets/skills/oat-project-retro/SKILL.md +3 -3
- package/assets/skills/oat-project-retro/references/apply-procedure.md +102 -6
- package/assets/skills/oat-project-retro-file/SKILL.md +57 -6
- package/assets/skills/oat-project-review-provide/SKILL.md +45 -4
- package/assets/skills/oat-project-review-receive/SKILL.md +16 -7
- package/assets/skills/oat-project-review-receive-remote/SKILL.md +9 -3
- package/assets/skills/oat-project-revise/SKILL.md +12 -3
- package/assets/skills/oat-project-spec/SKILL.md +16 -3
- package/assets/skills/oat-project-summary/SKILL.md +113 -13
- package/assets/skills/oat-wave-execute/SKILL.md +34 -2
- package/assets/skills/oat-worktree-bootstrap/SKILL.md +13 -1
- package/assets/skills/oat-worktree-bootstrap-auto/SKILL.md +13 -1
- package/dist/commands/config/index.d.ts +1 -0
- package/dist/commands/config/index.d.ts.map +1 -1
- package/dist/commands/config/index.js +30 -2
- package/dist/commands/doctor/index.d.ts +2 -0
- package/dist/commands/doctor/index.d.ts.map +1 -1
- package/dist/commands/doctor/index.js +27 -3
- package/dist/commands/doctor/synced-projects.d.ts +12 -0
- package/dist/commands/doctor/synced-projects.d.ts.map +1 -0
- package/dist/commands/doctor/synced-projects.js +208 -0
- package/dist/commands/init/gitattributes.d.ts +4 -0
- package/dist/commands/init/gitattributes.d.ts.map +1 -0
- package/dist/commands/init/gitattributes.js +12 -0
- package/dist/commands/init/gitignore.d.ts +8 -1
- package/dist/commands/init/gitignore.d.ts.map +1 -1
- package/dist/commands/init/gitignore.js +146 -41
- package/dist/commands/init/index.d.ts +2 -0
- package/dist/commands/init/index.d.ts.map +1 -1
- package/dist/commands/init/index.js +3 -0
- package/dist/commands/init/managed-block.d.ts +10 -0
- package/dist/commands/init/managed-block.d.ts.map +1 -0
- package/dist/commands/init/managed-block.js +40 -0
- package/dist/commands/init/tools/workflows/install-workflows.js +1 -1
- package/dist/commands/local/index.d.ts.map +1 -1
- package/dist/commands/local/index.js +5 -1
- package/dist/commands/local/sync.d.ts +4 -0
- package/dist/commands/local/sync.d.ts.map +1 -1
- package/dist/commands/local/sync.js +123 -2
- package/dist/commands/pjm/adoption.d.ts +2 -0
- package/dist/commands/pjm/adoption.d.ts.map +1 -1
- package/dist/commands/pjm/adoption.js +1 -1
- package/dist/commands/project/archive/archive-utils.d.ts +24 -0
- package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
- package/dist/commands/project/archive/archive-utils.js +349 -22
- package/dist/commands/project/archive/index.d.ts.map +1 -1
- package/dist/commands/project/archive/index.js +1 -0
- package/dist/commands/project/archive/push-runner.d.ts +5 -1
- package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
- package/dist/commands/project/archive/push-runner.js +33 -4
- package/dist/commands/project/complete-state/index.d.ts.map +1 -1
- package/dist/commands/project/complete-state/index.js +3 -1
- package/dist/commands/project/index.d.ts.map +1 -1
- package/dist/commands/project/index.js +12 -0
- package/dist/commands/project/links/compute.d.ts +9 -0
- package/dist/commands/project/links/compute.d.ts.map +1 -0
- package/dist/commands/project/links/compute.js +27 -0
- package/dist/commands/project/links/index.d.ts +17 -0
- package/dist/commands/project/links/index.d.ts.map +1 -0
- package/dist/commands/project/links/index.js +72 -0
- package/dist/commands/project/links/refresh.d.ts +26 -0
- package/dist/commands/project/links/refresh.d.ts.map +1 -0
- package/dist/commands/project/links/refresh.js +92 -0
- package/dist/commands/project/links/render.d.ts +24 -0
- package/dist/commands/project/links/render.d.ts.map +1 -0
- package/dist/commands/project/links/render.js +68 -0
- package/dist/commands/project/list.d.ts +5 -0
- package/dist/commands/project/list.d.ts.map +1 -1
- package/dist/commands/project/list.js +182 -9
- package/dist/commands/project/migrate/index.d.ts +22 -0
- package/dist/commands/project/migrate/index.d.ts.map +1 -0
- package/dist/commands/project/migrate/index.js +79 -0
- package/dist/commands/project/new/index.d.ts +2 -0
- package/dist/commands/project/new/index.d.ts.map +1 -1
- package/dist/commands/project/new/index.js +14 -2
- package/dist/commands/project/new/scaffold.d.ts +22 -1
- package/dist/commands/project/new/scaffold.d.ts.map +1 -1
- package/dist/commands/project/new/scaffold.js +287 -69
- package/dist/commands/project/open/index.d.ts +16 -0
- package/dist/commands/project/open/index.d.ts.map +1 -1
- package/dist/commands/project/open/index.js +151 -19
- package/dist/commands/project/pause/index.d.ts +8 -0
- package/dist/commands/project/pause/index.d.ts.map +1 -1
- package/dist/commands/project/pause/index.js +129 -19
- package/dist/commands/project/prune/index.d.ts +21 -0
- package/dist/commands/project/prune/index.d.ts.map +1 -0
- package/dist/commands/project/prune/index.js +95 -0
- package/dist/commands/project/pull/index.d.ts +28 -0
- package/dist/commands/project/pull/index.d.ts.map +1 -0
- package/dist/commands/project/pull/index.js +157 -0
- package/dist/commands/project/push/index.d.ts +22 -0
- package/dist/commands/project/push/index.d.ts.map +1 -0
- package/dist/commands/project/push/index.js +100 -0
- package/dist/commands/project/scope/index.d.ts +17 -0
- package/dist/commands/project/scope/index.d.ts.map +1 -0
- package/dist/commands/project/scope/index.js +107 -0
- package/dist/commands/project/split/run.d.ts +4 -0
- package/dist/commands/project/split/run.d.ts.map +1 -1
- package/dist/commands/project/split/run.js +48 -10
- package/dist/commands/project/sync/git.d.ts +18 -0
- package/dist/commands/project/sync/git.d.ts.map +1 -0
- package/dist/commands/project/sync/git.js +60 -0
- package/dist/commands/project/sync/record.d.ts +22 -0
- package/dist/commands/project/sync/record.d.ts.map +1 -0
- package/dist/commands/project/sync/record.js +133 -0
- package/dist/commands/project/sync/ref-sync.d.ts +120 -0
- package/dist/commands/project/sync/ref-sync.d.ts.map +1 -0
- package/dist/commands/project/sync/ref-sync.js +962 -0
- package/dist/commands/project/sync/resolve-target.d.ts +27 -0
- package/dist/commands/project/sync/resolve-target.d.ts.map +1 -0
- package/dist/commands/project/sync/resolve-target.js +159 -0
- package/dist/commands/shared/oat-paths.d.ts +4 -1
- package/dist/commands/shared/oat-paths.d.ts.map +1 -1
- package/dist/commands/shared/oat-paths.js +2 -2
- package/dist/commands/shared/project-scope.d.ts +23 -0
- package/dist/commands/shared/project-scope.d.ts.map +1 -0
- package/dist/commands/shared/project-scope.js +106 -0
- package/dist/commands/tools/migrate/index.js +1 -1
- package/dist/commands/tools/shared/pack-inventory.d.ts.map +1 -1
- package/dist/commands/tools/shared/pack-inventory.js +11 -0
- package/dist/commands/tools/shared/pack-lifecycle.js +1 -1
- package/dist/commands/tools/shared/pack-reconcile.d.ts.map +1 -1
- package/dist/commands/tools/shared/pack-reconcile.js +4 -1
- package/dist/commands/tools/update/index.d.ts.map +1 -1
- package/dist/commands/tools/update/index.js +20 -10
- package/dist/commands/tools/update/update-tools.d.ts +2 -0
- package/dist/commands/tools/update/update-tools.d.ts.map +1 -1
- package/dist/config/oat-config.d.ts +6 -3
- package/dist/config/oat-config.d.ts.map +1 -1
- package/dist/config/oat-config.js +41 -7
- package/dist/config/resolve.d.ts.map +1 -1
- package/dist/config/resolve.js +2 -1
- package/dist/fs/io.d.ts +2 -1
- package/dist/fs/io.d.ts.map +1 -1
- package/dist/fs/io.js +5 -2
- package/dist/projects/split/finalize.d.ts.map +1 -1
- package/dist/projects/split/finalize.js +55 -2
- package/dist/projects/split/resume.d.ts +3 -0
- package/dist/projects/split/resume.d.ts.map +1 -1
- package/dist/projects/split/resume.js +34 -7
- package/dist/projects/split/seed-children.d.ts.map +1 -1
- package/dist/projects/split/seed-children.js +6 -0
- package/dist/projects/split/write-parent.d.ts +7 -0
- package/dist/projects/split/write-parent.d.ts.map +1 -1
- package/dist/projects/split/write-parent.js +7 -4
- package/dist/release/public-package-contract.d.ts.map +1 -1
- package/dist/release/public-package-contract.js +1 -0
- package/dist/validation/skills.d.ts.map +1 -1
- package/dist/validation/skills.js +434 -0
- package/package.json +4 -4
- package/dist/__tests__/skills/split-flow-fixtures.d.ts +0 -50
- package/dist/__tests__/skills/split-flow-fixtures.d.ts.map +0 -1
- package/dist/__tests__/skills/split-flow-fixtures.js +0 -161
- package/dist/commands/__tests__/helpers.d.ts +0 -12
- package/dist/commands/__tests__/helpers.d.ts.map +0 -1
- package/dist/commands/__tests__/helpers.js +0 -36
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, readdir, realpath, rm, stat, writeFile, } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { ensureScopedRootGitignore } from '../../init/gitignore.js';
|
|
4
|
+
import { getFrontmatterBlock } from '../../shared/frontmatter.js';
|
|
5
|
+
import { canonicalizePath, isSyncedCheckout, resolveScopeRoot, SYNCED_REMOTE, syncedRefName, syncedRecordPath, } from '../../shared/project-scope.js';
|
|
6
|
+
import { readOatLocalConfig, writeOatLocalConfig } from '../../../config/oat-config.js';
|
|
7
|
+
import { CliError } from '../../../errors/cli-error.js';
|
|
8
|
+
import { copyDirectory } from '../../../fs/io.js';
|
|
9
|
+
import YAML from 'yaml';
|
|
10
|
+
import { buildSyncedRecord, readSyncedRecord, writeSyncedRecord, } from './record.js';
|
|
11
|
+
export function classifyRemoteRefLookup(result, remote, ref) {
|
|
12
|
+
if (result.code === 0)
|
|
13
|
+
return 'present';
|
|
14
|
+
if (result.code === 2 && result.stdout === '' && result.stderr === '') {
|
|
15
|
+
return 'absent';
|
|
16
|
+
}
|
|
17
|
+
throw new CliError(`Unable to verify whether ${remote}/${ref} exists (exit ${result.code}): ${result.stderr || result.stdout || 'unexpected ls-remote result'}`, 2);
|
|
18
|
+
}
|
|
19
|
+
export async function classifyAdoptionRecord(target, git) {
|
|
20
|
+
const recordPath = syncedRecordPath(target.syncedRoot, target.slug);
|
|
21
|
+
if ((await readSyncedRecord(recordPath)) === null)
|
|
22
|
+
return 'create';
|
|
23
|
+
const relativeRecordPath = repoRelativePath(target.repoRoot, recordPath);
|
|
24
|
+
const tracked = await git.run(['ls-files', '--error-unmatch', '--', relativeRecordPath], { cwd: target.repoRoot, allowFailure: true });
|
|
25
|
+
assertExpectedGitResult('git ls-files --error-unmatch', tracked, [0, 1]);
|
|
26
|
+
if (tracked.code === 1)
|
|
27
|
+
return 'pending';
|
|
28
|
+
const status = await git.run([
|
|
29
|
+
'status',
|
|
30
|
+
'--porcelain=v1',
|
|
31
|
+
'--untracked-files=all',
|
|
32
|
+
'--',
|
|
33
|
+
relativeRecordPath,
|
|
34
|
+
], { cwd: target.repoRoot });
|
|
35
|
+
return status.stdout === '' ? 'durable' : 'pending';
|
|
36
|
+
}
|
|
37
|
+
async function assertNoMigrationSourceLinks(directory, sourceRoot) {
|
|
38
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
const entryPath = join(directory, entry.name);
|
|
41
|
+
if (entry.isSymbolicLink()) {
|
|
42
|
+
const displayPath = relative(sourceRoot, entryPath);
|
|
43
|
+
throw new CliError(`Shared project migration refuses symbolic link ${displayPath}; replace it with repository-owned files before retrying.`, 1);
|
|
44
|
+
}
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
await assertNoMigrationSourceLinks(entryPath, sourceRoot);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!entry.isFile()) {
|
|
50
|
+
const displayPath = relative(sourceRoot, entryPath);
|
|
51
|
+
throw new CliError(`Shared project migration refuses unsupported filesystem entry ${displayPath}.`, 1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export async function assertConfinedMigrationSource(target, sourcePath) {
|
|
56
|
+
const lexicalSource = resolve(sourcePath);
|
|
57
|
+
const sharedRoot = resolve(target.sharedRoot);
|
|
58
|
+
let sourceStat;
|
|
59
|
+
try {
|
|
60
|
+
sourceStat = await lstat(lexicalSource);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
64
|
+
throw new CliError(`Shared project ${sourcePath} does not exist as a directory.`, 1);
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
if (sourceStat.isSymbolicLink()) {
|
|
69
|
+
throw new CliError(`Shared project ${sourcePath} is a symbolic link; migration requires a repository-owned directory.`, 1);
|
|
70
|
+
}
|
|
71
|
+
if (!sourceStat.isDirectory()) {
|
|
72
|
+
throw new CliError(`Shared project ${sourcePath} must be a directory.`, 1);
|
|
73
|
+
}
|
|
74
|
+
const canonicalLexicalSource = canonicalizePath(lexicalSource);
|
|
75
|
+
const canonicalSharedRoot = canonicalizePath(sharedRoot);
|
|
76
|
+
if (dirname(canonicalLexicalSource) !== canonicalSharedRoot ||
|
|
77
|
+
canonicalLexicalSource !== resolve(canonicalSharedRoot, target.slug)) {
|
|
78
|
+
throw new CliError(`Shared project ${sourcePath} must be the configured direct child for ${target.slug}.`, 1);
|
|
79
|
+
}
|
|
80
|
+
const [canonicalSource, realSharedRoot] = await Promise.all([
|
|
81
|
+
realpath(lexicalSource),
|
|
82
|
+
realpath(sharedRoot),
|
|
83
|
+
]);
|
|
84
|
+
if (canonicalSource !== resolve(realSharedRoot, target.slug)) {
|
|
85
|
+
throw new CliError(`Shared project ${sourcePath} resolves outside its configured direct-child boundary.`, 1);
|
|
86
|
+
}
|
|
87
|
+
await assertNoMigrationSourceLinks(lexicalSource, lexicalSource);
|
|
88
|
+
}
|
|
89
|
+
const ZERO_OBJECT_ID = '0000000000000000000000000000000000000000';
|
|
90
|
+
const EMPTY_TREE_OBJECT_ID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
91
|
+
const DISABLE_HOOKS_CONFIG = ['-c', 'core.hooksPath=/dev/null'];
|
|
92
|
+
function errorMessage(error) {
|
|
93
|
+
return error instanceof Error ? error.message : String(error);
|
|
94
|
+
}
|
|
95
|
+
function shellQuote(value) {
|
|
96
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
97
|
+
}
|
|
98
|
+
function withHooksDisabled(args) {
|
|
99
|
+
return [...DISABLE_HOOKS_CONFIG, ...args];
|
|
100
|
+
}
|
|
101
|
+
export function buildSyncTarget(repoRoot, projectsRoot, slug) {
|
|
102
|
+
const syncedRoot = resolveScopeRoot(repoRoot, projectsRoot, 'synced');
|
|
103
|
+
return {
|
|
104
|
+
repoRoot: resolve(repoRoot),
|
|
105
|
+
sharedRoot: resolveScopeRoot(repoRoot, projectsRoot, 'shared'),
|
|
106
|
+
syncedRoot,
|
|
107
|
+
slug,
|
|
108
|
+
projectPath: resolve(syncedRoot, slug),
|
|
109
|
+
ref: syncedRefName(slug),
|
|
110
|
+
remote: SYNCED_REMOTE,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export async function assertCanonicalSyncTargetIdentity(target, options = {}) {
|
|
114
|
+
const exitCode = options.exitCode ?? 2;
|
|
115
|
+
const expectedRef = syncedRefName(target.slug);
|
|
116
|
+
const expectedPath = resolve(target.syncedRoot, target.slug);
|
|
117
|
+
if (resolve(target.projectPath) !== expectedPath ||
|
|
118
|
+
target.ref !== expectedRef) {
|
|
119
|
+
throw new CliError(`Refusing synced mutation: ${target.projectPath} must identify the canonical direct child and ref for ${target.slug}.`, exitCode);
|
|
120
|
+
}
|
|
121
|
+
const exists = await (options.pathExists ?? pathExists)(target.projectPath);
|
|
122
|
+
if (!exists)
|
|
123
|
+
return false;
|
|
124
|
+
const canonicalize = options.realpath ?? realpath;
|
|
125
|
+
const [canonicalCheckout, canonicalSyncedRoot] = await Promise.all([
|
|
126
|
+
canonicalize(target.projectPath),
|
|
127
|
+
canonicalize(target.syncedRoot),
|
|
128
|
+
]);
|
|
129
|
+
const canonicalDirectChild = resolve(canonicalSyncedRoot, target.slug);
|
|
130
|
+
if (canonicalCheckout !== canonicalDirectChild) {
|
|
131
|
+
throw new CliError(`Refusing synced mutation: ${target.projectPath} must resolve to its canonical direct child of the synced project root.`, exitCode);
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
async function registeredWorktreePaths(target, git) {
|
|
136
|
+
const result = await git.run(['worktree', 'list', '--porcelain'], {
|
|
137
|
+
cwd: target.repoRoot,
|
|
138
|
+
});
|
|
139
|
+
return result.stdout
|
|
140
|
+
.split('\n')
|
|
141
|
+
.filter((line) => line.startsWith('worktree '))
|
|
142
|
+
.map((line) => resolve(line.slice('worktree '.length)));
|
|
143
|
+
}
|
|
144
|
+
async function canonicalRegisteredWorktreePath(registeredPath) {
|
|
145
|
+
try {
|
|
146
|
+
return await realpath(registeredPath);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error.code === 'ENOENT') {
|
|
150
|
+
return resolve(registeredPath);
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function canonicalTargetPath(target) {
|
|
156
|
+
const canonicalRepoRoot = await realpath(target.repoRoot);
|
|
157
|
+
return resolve(canonicalRepoRoot, relative(resolve(target.repoRoot), resolve(target.projectPath)));
|
|
158
|
+
}
|
|
159
|
+
async function isTargetRegistered(target, git) {
|
|
160
|
+
const expected = await canonicalTargetPath(target);
|
|
161
|
+
return (await registeredWorktreePaths(target, git)).includes(expected);
|
|
162
|
+
}
|
|
163
|
+
async function assertCreateTargetAvailable(target, git) {
|
|
164
|
+
if (await pathExists(target.projectPath)) {
|
|
165
|
+
throw new CliError(`Synced project ${target.slug} already exists at ${target.projectPath}.`, 2);
|
|
166
|
+
}
|
|
167
|
+
if (await isTargetRegistered(target, git)) {
|
|
168
|
+
throw new CliError(`Synced project ${target.slug} already has a registered worktree at ${target.projectPath}.`, 2);
|
|
169
|
+
}
|
|
170
|
+
const localRef = await git.run(['show-ref', '--verify', '--quiet', target.ref], {
|
|
171
|
+
cwd: target.repoRoot,
|
|
172
|
+
allowFailure: true,
|
|
173
|
+
});
|
|
174
|
+
assertExpectedGitResult('git show-ref --verify', localRef, [0, 1]);
|
|
175
|
+
if (localRef.code === 0) {
|
|
176
|
+
throw new CliError(`Synced project ${target.slug} local ref ${target.ref} already exists.`, 2);
|
|
177
|
+
}
|
|
178
|
+
const remoteRef = await git.run(['ls-remote', '--exit-code', target.remote, target.ref], { cwd: target.repoRoot, allowFailure: true });
|
|
179
|
+
assertExpectedGitResult('git ls-remote synced ref', remoteRef, [0, 2]);
|
|
180
|
+
if (remoteRef.code === 0) {
|
|
181
|
+
throw new CliError(`Synced project ${target.slug} remote ref ${target.ref} already exists on ${target.remote}.`, 2);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export async function createSyncedProject(target, git) {
|
|
185
|
+
await assertCanonicalSyncTargetIdentity(target);
|
|
186
|
+
await assertCreateTargetAvailable(target, git);
|
|
187
|
+
const commit = await git.run([
|
|
188
|
+
'commit-tree',
|
|
189
|
+
EMPTY_TREE_OBJECT_ID,
|
|
190
|
+
'-m',
|
|
191
|
+
`chore(oat): init synced project ${target.slug}`,
|
|
192
|
+
], { cwd: target.repoRoot });
|
|
193
|
+
await git.run(['update-ref', target.ref, commit.stdout, ZERO_OBJECT_ID], {
|
|
194
|
+
cwd: target.repoRoot,
|
|
195
|
+
});
|
|
196
|
+
let worktreeAdded = false;
|
|
197
|
+
try {
|
|
198
|
+
await mkdir(dirname(target.projectPath), { recursive: true });
|
|
199
|
+
await git.run(withHooksDisabled([
|
|
200
|
+
'worktree',
|
|
201
|
+
'add',
|
|
202
|
+
'--detach',
|
|
203
|
+
target.projectPath,
|
|
204
|
+
target.ref,
|
|
205
|
+
]), { cwd: target.repoRoot });
|
|
206
|
+
worktreeAdded = true;
|
|
207
|
+
await assertNestedWorktree(target, git);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (worktreeAdded && (await isTargetRegistered(target, git))) {
|
|
211
|
+
await git.run(['worktree', 'remove', '--force', target.projectPath], {
|
|
212
|
+
cwd: target.repoRoot,
|
|
213
|
+
});
|
|
214
|
+
await git.run(['worktree', 'prune'], { cwd: target.repoRoot });
|
|
215
|
+
}
|
|
216
|
+
await git.run(['update-ref', '-d', target.ref, commit.stdout], {
|
|
217
|
+
cwd: target.repoRoot,
|
|
218
|
+
});
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async function headSha(target, git) {
|
|
223
|
+
return (await git.run(['rev-parse', 'HEAD'], { cwd: target.projectPath }))
|
|
224
|
+
.stdout;
|
|
225
|
+
}
|
|
226
|
+
async function listConflicts(target, git) {
|
|
227
|
+
const unmerged = await git.run(['diff', '--name-only', '--diff-filter=U'], {
|
|
228
|
+
cwd: target.projectPath,
|
|
229
|
+
});
|
|
230
|
+
return unmerged.stdout
|
|
231
|
+
.split('\n')
|
|
232
|
+
.map((line) => line.trim())
|
|
233
|
+
.filter(Boolean)
|
|
234
|
+
.sort();
|
|
235
|
+
}
|
|
236
|
+
export async function pendingRebaseConflicts(target, git) {
|
|
237
|
+
await assertNestedWorktree(target, git);
|
|
238
|
+
for (const rebaseDirectory of ['rebase-merge', 'rebase-apply']) {
|
|
239
|
+
const gitPath = await git.run(['rev-parse', '--git-path', rebaseDirectory], { cwd: target.projectPath });
|
|
240
|
+
const absoluteGitPath = isAbsolute(gitPath.stdout)
|
|
241
|
+
? gitPath.stdout
|
|
242
|
+
: resolve(target.projectPath, gitPath.stdout);
|
|
243
|
+
if (await pathExists(absoluteGitPath)) {
|
|
244
|
+
return listConflicts(target, git);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
function isNonFastForwardPushRejection(stderr) {
|
|
250
|
+
return /(?:! \[rejected\]|\brejected\b)[\s\S]*\((?:non-fast-forward|fetch first)\)/i.test(stderr);
|
|
251
|
+
}
|
|
252
|
+
async function fetchRemoteRefIfPresent(target, git) {
|
|
253
|
+
const remoteRef = await git.run(['ls-remote', '--exit-code', target.remote, target.ref], { cwd: target.repoRoot, allowFailure: true });
|
|
254
|
+
assertExpectedGitResult('git ls-remote synced ref', remoteRef, [0, 2]);
|
|
255
|
+
if (remoteRef.code === 2) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
await git.run(['fetch', target.remote, `+${target.ref}:${target.ref}`], {
|
|
259
|
+
cwd: target.repoRoot,
|
|
260
|
+
});
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
function assertExpectedGitResult(command, result, expectedCodes) {
|
|
264
|
+
if (!expectedCodes.includes(result.code)) {
|
|
265
|
+
throw new CliError(`${command} failed (exit ${result.code}): ${result.stderr || result.stdout || 'unknown git error'}`, 2);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
export async function pushSynced(target, git, options) {
|
|
269
|
+
const pendingConflicts = await pendingRebaseConflicts(target, git);
|
|
270
|
+
if (pendingConflicts !== null) {
|
|
271
|
+
return {
|
|
272
|
+
status: 'conflict',
|
|
273
|
+
sha: await headSha(target, git),
|
|
274
|
+
conflicts: pendingConflicts,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
await git.run(['add', '-A'], { cwd: target.projectPath });
|
|
278
|
+
const staged = await git.run(['diff', '--cached', '--quiet'], {
|
|
279
|
+
cwd: target.projectPath,
|
|
280
|
+
allowFailure: true,
|
|
281
|
+
});
|
|
282
|
+
assertExpectedGitResult('git diff --cached --quiet', staged, [0, 1]);
|
|
283
|
+
if (staged.code === 1) {
|
|
284
|
+
await git.run(withHooksDisabled([
|
|
285
|
+
'commit',
|
|
286
|
+
'-m',
|
|
287
|
+
options.message ?? `chore(oat): sync ${target.slug} artifacts`,
|
|
288
|
+
]), { cwd: target.projectPath });
|
|
289
|
+
}
|
|
290
|
+
const localCommit = await headSha(target, git);
|
|
291
|
+
const remoteExists = await fetchRemoteRefIfPresent(target, git);
|
|
292
|
+
let remoteBeforePushSha = null;
|
|
293
|
+
if (remoteExists) {
|
|
294
|
+
remoteBeforePushSha = (await git.run(['rev-parse', target.ref], { cwd: target.repoRoot })).stdout;
|
|
295
|
+
const ancestor = await git.run(['merge-base', '--is-ancestor', target.ref, 'HEAD'], { cwd: target.projectPath, allowFailure: true });
|
|
296
|
+
assertExpectedGitResult('git merge-base --is-ancestor', ancestor, [0, 1]);
|
|
297
|
+
if (ancestor.code === 1) {
|
|
298
|
+
const rebased = await git.run(withHooksDisabled(['rebase', target.ref]), {
|
|
299
|
+
cwd: target.projectPath,
|
|
300
|
+
allowFailure: true,
|
|
301
|
+
});
|
|
302
|
+
if (rebased.code !== 0) {
|
|
303
|
+
const conflicts = await listConflicts(target, git);
|
|
304
|
+
if (conflicts.length === 0) {
|
|
305
|
+
assertExpectedGitResult('git rebase synced ref', rebased, [0]);
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
status: 'conflict',
|
|
309
|
+
sha: localCommit,
|
|
310
|
+
conflicts,
|
|
311
|
+
remoteBeforePushSha,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const currentHead = await headSha(target, git);
|
|
316
|
+
const fetchedHead = (await git.run(['rev-parse', target.ref], { cwd: target.repoRoot })).stdout;
|
|
317
|
+
if (currentHead === fetchedHead) {
|
|
318
|
+
return {
|
|
319
|
+
status: 'up-to-date',
|
|
320
|
+
sha: currentHead,
|
|
321
|
+
remoteBeforePushSha,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const pushedHead = await headSha(target, git);
|
|
326
|
+
const pushed = await git.run(withHooksDisabled([
|
|
327
|
+
'-C',
|
|
328
|
+
target.projectPath,
|
|
329
|
+
'push',
|
|
330
|
+
target.remote,
|
|
331
|
+
`HEAD:${target.ref}`,
|
|
332
|
+
]), { cwd: target.repoRoot, allowFailure: true });
|
|
333
|
+
if (pushed.code !== 0) {
|
|
334
|
+
if (isNonFastForwardPushRejection(pushed.stderr)) {
|
|
335
|
+
return { status: 'rejected', sha: pushedHead, remoteBeforePushSha };
|
|
336
|
+
}
|
|
337
|
+
assertExpectedGitResult('git push synced ref', pushed, [0]);
|
|
338
|
+
}
|
|
339
|
+
await git.run(['update-ref', target.ref, pushedHead], {
|
|
340
|
+
cwd: target.repoRoot,
|
|
341
|
+
});
|
|
342
|
+
return { status: 'pushed', sha: pushedHead, remoteBeforePushSha };
|
|
343
|
+
}
|
|
344
|
+
async function pathExists(path) {
|
|
345
|
+
try {
|
|
346
|
+
await stat(path);
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async function reconcilePulledRef(target, git) {
|
|
357
|
+
await assertNestedWorktree(target, git);
|
|
358
|
+
const status = await git.run(['status', '--porcelain'], {
|
|
359
|
+
cwd: target.projectPath,
|
|
360
|
+
});
|
|
361
|
+
if (status.stdout !== '') {
|
|
362
|
+
const conflicts = await listConflicts(target, git);
|
|
363
|
+
if (conflicts.length > 0) {
|
|
364
|
+
return {
|
|
365
|
+
status: 'conflict',
|
|
366
|
+
sha: await headSha(target, git),
|
|
367
|
+
conflicts,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
return { status: 'dirty', sha: await headSha(target, git) };
|
|
371
|
+
}
|
|
372
|
+
const beforeRebase = await headSha(target, git);
|
|
373
|
+
const remoteHead = (await git.run(['rev-parse', target.ref], { cwd: target.repoRoot })).stdout;
|
|
374
|
+
if (beforeRebase === remoteHead) {
|
|
375
|
+
return { status: 'up-to-date', sha: beforeRebase };
|
|
376
|
+
}
|
|
377
|
+
const rebased = await git.run(withHooksDisabled(['rebase', target.ref]), {
|
|
378
|
+
cwd: target.projectPath,
|
|
379
|
+
allowFailure: true,
|
|
380
|
+
});
|
|
381
|
+
if (rebased.code !== 0) {
|
|
382
|
+
const conflicts = await listConflicts(target, git);
|
|
383
|
+
if (conflicts.length === 0) {
|
|
384
|
+
assertExpectedGitResult('git rebase synced ref', rebased, [0]);
|
|
385
|
+
}
|
|
386
|
+
return { status: 'conflict', sha: beforeRebase, conflicts };
|
|
387
|
+
}
|
|
388
|
+
const afterRebase = await headSha(target, git);
|
|
389
|
+
return {
|
|
390
|
+
status: afterRebase === beforeRebase ? 'up-to-date' : 'updated',
|
|
391
|
+
sha: afterRebase,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
async function ensureSyncedRootIgnored(target, git) {
|
|
395
|
+
const repair = await ensureScopedRootGitignore(target.repoRoot, target.syncedRoot, 'synced', git, 'Cannot add the configured synced-project rule because .gitignore has staged or unstaged changes; commit or stash those changes, then retry.');
|
|
396
|
+
return repair.changed;
|
|
397
|
+
}
|
|
398
|
+
export async function pullSynced(target, git, options = {}) {
|
|
399
|
+
const adoptionRecord = options.adoptionRecord ?? (options.adopt ? 'create' : 'durable');
|
|
400
|
+
const shouldAdopt = adoptionRecord !== 'durable';
|
|
401
|
+
const checkoutExists = await assertCanonicalSyncTargetIdentity(target);
|
|
402
|
+
await git.run(['fetch', target.remote, `+${target.ref}:${target.ref}`], {
|
|
403
|
+
cwd: target.repoRoot,
|
|
404
|
+
});
|
|
405
|
+
await git.run(['worktree', 'prune'], { cwd: target.repoRoot });
|
|
406
|
+
if (!checkoutExists) {
|
|
407
|
+
const gitignoreChanged = shouldAdopt
|
|
408
|
+
? await ensureSyncedRootIgnored(target, git)
|
|
409
|
+
: false;
|
|
410
|
+
await mkdir(dirname(target.projectPath), { recursive: true });
|
|
411
|
+
await git.run(withHooksDisabled([
|
|
412
|
+
'worktree',
|
|
413
|
+
'add',
|
|
414
|
+
'--detach',
|
|
415
|
+
target.projectPath,
|
|
416
|
+
target.ref,
|
|
417
|
+
]), { cwd: target.repoRoot });
|
|
418
|
+
await assertNestedWorktree(target, git);
|
|
419
|
+
const result = {
|
|
420
|
+
status: 'created',
|
|
421
|
+
sha: await headSha(target, git),
|
|
422
|
+
};
|
|
423
|
+
return shouldAdopt
|
|
424
|
+
? await prepareAdoptionRecord(target, result, adoptionRecord, options.now ?? new Date(), gitignoreChanged ? [join(target.repoRoot, '.gitignore')] : [])
|
|
425
|
+
: result;
|
|
426
|
+
}
|
|
427
|
+
const result = await reconcilePulledRef(target, git);
|
|
428
|
+
return shouldAdopt &&
|
|
429
|
+
(result.status === 'updated' || result.status === 'up-to-date')
|
|
430
|
+
? await prepareAdoptionRecord(target, result, adoptionRecord, options.now ?? new Date())
|
|
431
|
+
: result;
|
|
432
|
+
}
|
|
433
|
+
async function prepareAdoptionRecord(target, result, adoptionRecord, now, additionalPendingPaths = []) {
|
|
434
|
+
const recordPath = syncedRecordPath(dirname(target.projectPath), target.slug);
|
|
435
|
+
if (adoptionRecord === 'create') {
|
|
436
|
+
await writeSyncedRecord(recordPath, buildSyncedRecord(target.slug, now));
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
...result,
|
|
440
|
+
adopted: true,
|
|
441
|
+
adoptionRecordOwnership: adoptionRecord === 'create' ? 'created' : 'existing',
|
|
442
|
+
pendingRecordPaths: [recordPath, ...additionalPendingPaths],
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
export async function pullChildren(parentTarget, git) {
|
|
446
|
+
await assertCanonicalSyncTargetIdentity(parentTarget);
|
|
447
|
+
const statePath = resolve(parentTarget.projectPath, 'state.md');
|
|
448
|
+
const block = getFrontmatterBlock(await readFile(statePath, 'utf8'));
|
|
449
|
+
if (!block)
|
|
450
|
+
return [];
|
|
451
|
+
const parsed = YAML.parse(block);
|
|
452
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
453
|
+
return [];
|
|
454
|
+
const frontmatter = parsed;
|
|
455
|
+
if (frontmatter['oat_kind'] !== 'coordination')
|
|
456
|
+
return [];
|
|
457
|
+
const children = frontmatter['oat_children'];
|
|
458
|
+
if (!Array.isArray(children)) {
|
|
459
|
+
throw new CliError(`Malformed coordination state for ${parentTarget.slug}: oat_children must be an array.`, 1);
|
|
460
|
+
}
|
|
461
|
+
const slugs = children.map((value) => {
|
|
462
|
+
if (typeof value !== 'string' ||
|
|
463
|
+
value.startsWith('-') ||
|
|
464
|
+
!/^[a-zA-Z0-9_-]+$/.test(value)) {
|
|
465
|
+
throw new CliError('Coordination parent contains an invalid child slug.', 2);
|
|
466
|
+
}
|
|
467
|
+
return value;
|
|
468
|
+
});
|
|
469
|
+
const syncedRoot = dirname(parentTarget.projectPath);
|
|
470
|
+
const results = [];
|
|
471
|
+
for (const slug of slugs) {
|
|
472
|
+
const childTarget = {
|
|
473
|
+
...parentTarget,
|
|
474
|
+
slug,
|
|
475
|
+
projectPath: resolve(syncedRoot, slug),
|
|
476
|
+
ref: syncedRefName(slug),
|
|
477
|
+
};
|
|
478
|
+
try {
|
|
479
|
+
await assertCanonicalSyncTargetIdentity(childTarget);
|
|
480
|
+
const adoptionRecord = await classifyAdoptionRecord(childTarget, git);
|
|
481
|
+
const remote = await git.run(['ls-remote', '--exit-code', childTarget.remote, childTarget.ref], { cwd: parentTarget.repoRoot, allowFailure: true });
|
|
482
|
+
if (remote.code === 2 &&
|
|
483
|
+
remote.stdout.trim() === '' &&
|
|
484
|
+
remote.stderr.trim() === '') {
|
|
485
|
+
results.push({
|
|
486
|
+
slug,
|
|
487
|
+
status: 'missing',
|
|
488
|
+
message: `Remote ref ${childTarget.ref} is absent.`,
|
|
489
|
+
exitCode: 1,
|
|
490
|
+
});
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (remote.code !== 0) {
|
|
494
|
+
results.push({
|
|
495
|
+
slug,
|
|
496
|
+
status: 'error',
|
|
497
|
+
message: `git ls-remote ${childTarget.remote} ${childTarget.ref} failed (exit ${remote.code}): ${remote.stderr || remote.stdout || 'unknown Git error'}`,
|
|
498
|
+
exitCode: 2,
|
|
499
|
+
});
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const result = await pullSynced(childTarget, git, {
|
|
503
|
+
adopt: adoptionRecord !== 'durable',
|
|
504
|
+
adoptionRecord,
|
|
505
|
+
});
|
|
506
|
+
results.push({
|
|
507
|
+
slug,
|
|
508
|
+
...result,
|
|
509
|
+
...(result.status === 'conflict' || result.status === 'dirty'
|
|
510
|
+
? { exitCode: 1 }
|
|
511
|
+
: {}),
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
results.push({
|
|
516
|
+
slug,
|
|
517
|
+
status: 'error',
|
|
518
|
+
message: error instanceof Error ? error.message : String(error),
|
|
519
|
+
exitCode: error instanceof CliError && error.exitCode === 1 ? 1 : 2,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return results;
|
|
524
|
+
}
|
|
525
|
+
export async function continueSynced(target, git, options = {}) {
|
|
526
|
+
await assertNestedWorktree(target, git);
|
|
527
|
+
const continued = await git.run(withHooksDisabled(['rebase', '--continue']), {
|
|
528
|
+
cwd: target.projectPath,
|
|
529
|
+
env: { GIT_EDITOR: 'true' },
|
|
530
|
+
allowFailure: true,
|
|
531
|
+
});
|
|
532
|
+
if (continued.code !== 0) {
|
|
533
|
+
const conflicts = await listConflicts(target, git);
|
|
534
|
+
if (conflicts.length === 0) {
|
|
535
|
+
assertExpectedGitResult('git rebase --continue', continued, [0]);
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
status: 'conflict',
|
|
539
|
+
sha: await headSha(target, git),
|
|
540
|
+
conflicts,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
const result = {
|
|
544
|
+
status: 'updated',
|
|
545
|
+
sha: await headSha(target, git),
|
|
546
|
+
};
|
|
547
|
+
const adoptionRecord = options.adoptionRecord ?? (options.adopt ? 'create' : 'durable');
|
|
548
|
+
return adoptionRecord === 'durable'
|
|
549
|
+
? result
|
|
550
|
+
: prepareAdoptionRecord(target, result, adoptionRecord, options.now ?? new Date());
|
|
551
|
+
}
|
|
552
|
+
export async function abortSynced(target, git) {
|
|
553
|
+
await assertNestedWorktree(target, git);
|
|
554
|
+
await git.run(withHooksDisabled(['rebase', '--abort']), {
|
|
555
|
+
cwd: target.projectPath,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
function normalizedPathspecs(repoRoot, pathspecs) {
|
|
559
|
+
return pathspecs.map((pathspec) => repoRelativePath(repoRoot, pathspec));
|
|
560
|
+
}
|
|
561
|
+
export async function commitRecordChange(repoRoot, pathspecs, message, git, options) {
|
|
562
|
+
assertAllowlistedPathspecs(repoRoot, pathspecs, options);
|
|
563
|
+
const normalized = normalizedPathspecs(repoRoot, pathspecs);
|
|
564
|
+
await git.run(['add', '--', ...normalized], { cwd: repoRoot });
|
|
565
|
+
const changed = await git.run(['diff', '--cached', '--quiet', '--', ...normalized], { cwd: repoRoot, allowFailure: true });
|
|
566
|
+
assertExpectedGitResult('git diff --cached --quiet', changed, [0, 1]);
|
|
567
|
+
if (changed.code === 0) {
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
await git.run(['commit', '-m', message, '--', ...normalized], {
|
|
571
|
+
cwd: repoRoot,
|
|
572
|
+
});
|
|
573
|
+
const sha = (await git.run(['rev-parse', 'HEAD'], { cwd: repoRoot })).stdout;
|
|
574
|
+
return { sha };
|
|
575
|
+
}
|
|
576
|
+
export async function preflightSyncedCheckout(target, git) {
|
|
577
|
+
const checkoutExists = await assertCanonicalSyncTargetIdentity(target);
|
|
578
|
+
const remoteExists = await fetchRemoteRefIfPresent(target, git);
|
|
579
|
+
if (!checkoutExists) {
|
|
580
|
+
return { status: 'absent' };
|
|
581
|
+
}
|
|
582
|
+
await assertNestedWorktree(target, git);
|
|
583
|
+
const sha = await headSha(target, git);
|
|
584
|
+
const status = await git.run(['status', '--porcelain'], {
|
|
585
|
+
cwd: target.projectPath,
|
|
586
|
+
});
|
|
587
|
+
if (status.stdout !== '') {
|
|
588
|
+
return { status: 'dirty', sha };
|
|
589
|
+
}
|
|
590
|
+
if (!remoteExists) {
|
|
591
|
+
return { status: 'unpushed', sha };
|
|
592
|
+
}
|
|
593
|
+
const remoteSha = (await git.run(['rev-parse', target.ref], { cwd: target.repoRoot })).stdout;
|
|
594
|
+
return { status: sha === remoteSha ? 'clean' : 'unpushed', sha };
|
|
595
|
+
}
|
|
596
|
+
export async function removeSyncedCheckout(target, git, options = {}) {
|
|
597
|
+
const preflight = await preflightSyncedCheckout(target, git);
|
|
598
|
+
if (preflight.status === 'absent') {
|
|
599
|
+
await git.run(['worktree', 'prune'], { cwd: target.repoRoot });
|
|
600
|
+
if (await isTargetRegistered(target, git)) {
|
|
601
|
+
throw new CliError(`Unable to remove stale worktree registration for ${target.projectPath}.`, 2);
|
|
602
|
+
}
|
|
603
|
+
return { status: 'absent' };
|
|
604
|
+
}
|
|
605
|
+
if (!options.force &&
|
|
606
|
+
(preflight.status === 'dirty' || preflight.status === 'unpushed')) {
|
|
607
|
+
return { status: preflight.status };
|
|
608
|
+
}
|
|
609
|
+
return removePreflightedSyncedCheckout(target, git, options.force ?? false);
|
|
610
|
+
}
|
|
611
|
+
async function removePreflightedSyncedCheckout(target, git, force) {
|
|
612
|
+
await git.run(['worktree', 'remove', ...(force ? ['--force'] : []), target.projectPath], { cwd: target.repoRoot });
|
|
613
|
+
await git.run(['worktree', 'prune'], { cwd: target.repoRoot });
|
|
614
|
+
return { status: 'removed' };
|
|
615
|
+
}
|
|
616
|
+
export async function pruneSynced(target, git, options) {
|
|
617
|
+
await assertCanonicalSyncTargetIdentity(target);
|
|
618
|
+
const scopeRelativeSyncedRoot = relative(resolve(target.repoRoot), resolve(target.syncedRoot));
|
|
619
|
+
if (scopeRelativeSyncedRoot === '' ||
|
|
620
|
+
isAbsolute(scopeRelativeSyncedRoot) ||
|
|
621
|
+
scopeRelativeSyncedRoot === '..' ||
|
|
622
|
+
scopeRelativeSyncedRoot.startsWith(`..${sep}`)) {
|
|
623
|
+
throw new CliError(`Refusing to prune ${target.slug}: configured synced root is outside the repository worktree.`, 2);
|
|
624
|
+
}
|
|
625
|
+
const recordPath = syncedRecordPath(target.syncedRoot, target.slug);
|
|
626
|
+
const relativeRecordPath = repoRelativePath(target.repoRoot, recordPath);
|
|
627
|
+
const stagedDeletion = await git.run([
|
|
628
|
+
'diff',
|
|
629
|
+
'--cached',
|
|
630
|
+
'--diff-filter=D',
|
|
631
|
+
'--name-only',
|
|
632
|
+
'--',
|
|
633
|
+
relativeRecordPath,
|
|
634
|
+
], { cwd: target.repoRoot, allowFailure: true });
|
|
635
|
+
if (stagedDeletion.code === 0 &&
|
|
636
|
+
stagedDeletion.stdout === relativeRecordPath) {
|
|
637
|
+
const localRef = await git.run(['show-ref', '--verify', '--quiet', target.ref], { cwd: target.repoRoot, allowFailure: true });
|
|
638
|
+
const remoteRef = await git.run(['ls-remote', '--exit-code', target.remote, target.ref], { cwd: target.repoRoot, allowFailure: true });
|
|
639
|
+
const remoteState = classifyRemoteRefLookup(remoteRef, target.remote, target.ref);
|
|
640
|
+
if (localRef.code !== 0 && remoteState === 'absent') {
|
|
641
|
+
if (!options.commit) {
|
|
642
|
+
return { status: 'pruned', lifecycleCommit: null };
|
|
643
|
+
}
|
|
644
|
+
await git.run([
|
|
645
|
+
'commit',
|
|
646
|
+
'-m',
|
|
647
|
+
`chore(oat): prune synced project ${target.slug}`,
|
|
648
|
+
'--',
|
|
649
|
+
relativeRecordPath,
|
|
650
|
+
], { cwd: target.repoRoot });
|
|
651
|
+
const lifecycleCommit = (await git.run(['rev-parse', 'HEAD'], { cwd: target.repoRoot })).stdout;
|
|
652
|
+
return { status: 'pruned', lifecycleCommit };
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
await git.run(['worktree', 'prune'], { cwd: target.repoRoot });
|
|
656
|
+
const registeredPaths = await registeredWorktreePaths(target, git);
|
|
657
|
+
const canonicalRegisteredPaths = [
|
|
658
|
+
...new Set(await Promise.all(registeredPaths.map((path) => canonicalRegisteredWorktreePath(path)))),
|
|
659
|
+
];
|
|
660
|
+
const registeredSet = new Set(canonicalRegisteredPaths);
|
|
661
|
+
const checkoutsByPath = new Map();
|
|
662
|
+
for (const parentWorktreeRoot of canonicalRegisteredPaths) {
|
|
663
|
+
const syncedRoot = resolve(parentWorktreeRoot, scopeRelativeSyncedRoot);
|
|
664
|
+
const projectPath = resolve(syncedRoot, target.slug);
|
|
665
|
+
if (registeredSet.has(projectPath)) {
|
|
666
|
+
checkoutsByPath.set(projectPath, {
|
|
667
|
+
...target,
|
|
668
|
+
syncedRoot,
|
|
669
|
+
projectPath,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
const checkouts = [...checkoutsByPath.values()];
|
|
674
|
+
for (const checkout of checkouts) {
|
|
675
|
+
const preflight = await preflightSyncedCheckout(checkout, git);
|
|
676
|
+
if (!options.force &&
|
|
677
|
+
(preflight.status === 'dirty' || preflight.status === 'unpushed')) {
|
|
678
|
+
throw new CliError(`Refusing to prune ${target.slug}: checkout ${checkout.projectPath} is ${preflight.status}. Push it first or pass --force. Pinned PR links will stop resolving after prune.`, 1);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
await git.run(['push', target.remote, `:${target.ref}`], {
|
|
683
|
+
cwd: target.repoRoot,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
catch (error) {
|
|
687
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
688
|
+
const forceFlag = options.force ? ' --force' : '';
|
|
689
|
+
throw new CliError(`Unable to delete remote ref ${target.ref}; no local checkouts were removed. Restore access to ${target.remote}, then retry oat project prune ${shellQuote(target.slug)}${forceFlag}. ${detail}`, 2);
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
for (const checkout of checkouts) {
|
|
693
|
+
const removed = await removePreflightedSyncedCheckout(checkout, git, options.force);
|
|
694
|
+
if (removed.status !== 'removed' && removed.status !== 'absent') {
|
|
695
|
+
throw new CliError(`Refusing to prune ${target.slug}: checkout ${checkout.projectPath} is ${removed.status}.`, 1);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
catch (error) {
|
|
700
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
701
|
+
const forceFlag = options.force ? ' --force' : '';
|
|
702
|
+
throw new CliError(`Remote ref ${target.ref} was already deleted from ${target.remote}, but local checkout removal failed. The local ref, record, and remaining checkouts were retained. Do not run oat project push ${shellQuote(target.slug)}, which would republish the ref; resolve the checkout error, then safely retry oat project prune ${shellQuote(target.slug)}${forceFlag}. ${detail}`, 2);
|
|
703
|
+
}
|
|
704
|
+
await git.run(['update-ref', '-d', target.ref], { cwd: target.repoRoot });
|
|
705
|
+
await rm(recordPath, { force: true });
|
|
706
|
+
const committed = options.commit
|
|
707
|
+
? await commitRecordChange(target.repoRoot, [recordPath], `chore(oat): prune synced project ${target.slug}`, git, { projectRoots: target })
|
|
708
|
+
: null;
|
|
709
|
+
return { status: 'pruned', lifecycleCommit: committed?.sha ?? null };
|
|
710
|
+
}
|
|
711
|
+
async function readOptionalFile(path) {
|
|
712
|
+
try {
|
|
713
|
+
return await readFile(path, 'utf8');
|
|
714
|
+
}
|
|
715
|
+
catch (error) {
|
|
716
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
throw error;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
export async function migrateSharedToSynced(target, git, options) {
|
|
723
|
+
const sourcePath = resolve(options.sourcePath);
|
|
724
|
+
await assertConfinedMigrationSource(target, sourcePath);
|
|
725
|
+
const sourceRelative = repoRelativePath(target.repoRoot, sourcePath);
|
|
726
|
+
const recordPath = syncedRecordPath(target.syncedRoot, target.slug);
|
|
727
|
+
const destinationRelative = repoRelativePath(target.repoRoot, target.projectPath);
|
|
728
|
+
const gitignorePath = join(target.repoRoot, '.gitignore');
|
|
729
|
+
const preMigrationHead = (await git.run(['rev-parse', 'HEAD'], { cwd: target.repoRoot })).stdout;
|
|
730
|
+
const originalGitignore = await readOptionalFile(gitignorePath);
|
|
731
|
+
const readLocal = options.readOatLocalConfig ?? readOatLocalConfig;
|
|
732
|
+
const writeLocal = options.writeOatLocalConfig ?? writeOatLocalConfig;
|
|
733
|
+
const originalLocalConfig = await readLocal(target.repoRoot);
|
|
734
|
+
const sourceStatus = await git.run(['status', '--porcelain', '--untracked-files=all', '--', sourceRelative], { cwd: target.repoRoot });
|
|
735
|
+
if (sourceStatus.stdout !== '') {
|
|
736
|
+
throw new CliError(`Shared project ${sourceRelative} is dirty or has untracked files; commit or clean it before migration.`, 1);
|
|
737
|
+
}
|
|
738
|
+
const tracked = await git.run(['ls-files', '--', sourceRelative], {
|
|
739
|
+
cwd: target.repoRoot,
|
|
740
|
+
});
|
|
741
|
+
if (!tracked.stdout) {
|
|
742
|
+
throw new CliError(`Shared project ${sourceRelative} is not tracked on the current branch.`, 1);
|
|
743
|
+
}
|
|
744
|
+
await git.run(['remote', 'get-url', target.remote], {
|
|
745
|
+
cwd: target.repoRoot,
|
|
746
|
+
});
|
|
747
|
+
if ((await readSyncedRecord(recordPath)) !== null) {
|
|
748
|
+
throw new CliError(`Synced project record already exists for ${target.slug}.`, 1);
|
|
749
|
+
}
|
|
750
|
+
const syncedProbe = `${repoRelativePath(target.repoRoot, join(target.syncedRoot, '__probe__'))}/`;
|
|
751
|
+
const ignored = await git.run(['check-ignore', '--quiet', '--no-index', syncedProbe], {
|
|
752
|
+
cwd: target.repoRoot,
|
|
753
|
+
allowFailure: true,
|
|
754
|
+
});
|
|
755
|
+
assertExpectedGitResult('git check-ignore synced project', ignored, [0, 1]);
|
|
756
|
+
const needsGitignoreHeal = ignored.code === 1;
|
|
757
|
+
let created = false;
|
|
758
|
+
let publishedRemoteSha = null;
|
|
759
|
+
let prePublishRemoteSha = null;
|
|
760
|
+
let activeProjectUpdated = false;
|
|
761
|
+
let gitignoreSelfHealStarted = false;
|
|
762
|
+
try {
|
|
763
|
+
if (needsGitignoreHeal) {
|
|
764
|
+
const repair = await (options.ensureScopedRootGitignore ?? ensureScopedRootGitignore)(target.repoRoot, target.syncedRoot, 'synced', git, 'Cannot add the synced-project rule because .gitignore has staged or unstaged changes; commit or stash those changes, or add the managed rule manually, then retry migration.');
|
|
765
|
+
gitignoreSelfHealStarted = repair.changed;
|
|
766
|
+
}
|
|
767
|
+
await createSyncedProject(target, git);
|
|
768
|
+
created = true;
|
|
769
|
+
await (options.copyDirectory ?? copyDirectory)(sourcePath, target.projectPath);
|
|
770
|
+
const pushed = await pushSynced(target, git, {
|
|
771
|
+
message: `chore(oat): migrate ${target.slug} artifacts`,
|
|
772
|
+
});
|
|
773
|
+
if (pushed.status !== 'pushed' && pushed.status !== 'up-to-date') {
|
|
774
|
+
throw new CliError(`Unable to publish migrated project ${target.slug}: ${pushed.status}.`, 1);
|
|
775
|
+
}
|
|
776
|
+
if (pushed.status === 'pushed') {
|
|
777
|
+
publishedRemoteSha = pushed.sha;
|
|
778
|
+
prePublishRemoteSha = pushed.remoteBeforePushSha ?? null;
|
|
779
|
+
}
|
|
780
|
+
await writeSyncedRecord(recordPath, buildSyncedRecord(target.slug, options.now ?? new Date()));
|
|
781
|
+
await rm(sourcePath, { recursive: true });
|
|
782
|
+
const gitignoreChanged = gitignoreSelfHealStarted &&
|
|
783
|
+
(await readOptionalFile(gitignorePath)) !== originalGitignore;
|
|
784
|
+
let lifecycleCommit = null;
|
|
785
|
+
if (options.commit) {
|
|
786
|
+
const committed = await commitRecordChange(target.repoRoot, [sourcePath, recordPath, ...(gitignoreChanged ? [gitignorePath] : [])], `chore(oat): migrate ${target.slug} to synced scope`, git, { projectRoots: target });
|
|
787
|
+
lifecycleCommit = committed?.sha ?? null;
|
|
788
|
+
await options.afterBranchCommit?.();
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
await git.run([
|
|
792
|
+
'reset',
|
|
793
|
+
'-q',
|
|
794
|
+
'--',
|
|
795
|
+
sourceRelative,
|
|
796
|
+
repoRelativePath(target.repoRoot, recordPath),
|
|
797
|
+
...(gitignoreChanged ? ['.gitignore'] : []),
|
|
798
|
+
], { cwd: target.repoRoot });
|
|
799
|
+
}
|
|
800
|
+
if (originalLocalConfig.activeProject === sourceRelative) {
|
|
801
|
+
activeProjectUpdated = true;
|
|
802
|
+
await writeLocal(target.repoRoot, {
|
|
803
|
+
...originalLocalConfig,
|
|
804
|
+
activeProject: destinationRelative,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
return { status: 'migrated', lifecycleCommit, sha: pushed.sha };
|
|
808
|
+
}
|
|
809
|
+
catch (error) {
|
|
810
|
+
const compensationFailures = [];
|
|
811
|
+
const compensate = async (resource, recovery, action) => {
|
|
812
|
+
try {
|
|
813
|
+
await action();
|
|
814
|
+
}
|
|
815
|
+
catch (compensationError) {
|
|
816
|
+
compensationFailures.push({
|
|
817
|
+
resource,
|
|
818
|
+
recovery,
|
|
819
|
+
error: compensationError,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
await compensate('parent branch HEAD', `git reset --soft ${preMigrationHead}`, async () => {
|
|
824
|
+
const currentHead = (await git.run(['rev-parse', 'HEAD'], { cwd: target.repoRoot })).stdout;
|
|
825
|
+
if (currentHead !== preMigrationHead) {
|
|
826
|
+
await git.run(['reset', '--soft', preMigrationHead], {
|
|
827
|
+
cwd: target.repoRoot,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
await compensate('parent index', `git reset -q -- ${shellQuote(sourceRelative)} ${shellQuote(repoRelativePath(target.repoRoot, recordPath))}`, async () => {
|
|
832
|
+
await git.run([
|
|
833
|
+
'reset',
|
|
834
|
+
'-q',
|
|
835
|
+
'--',
|
|
836
|
+
sourceRelative,
|
|
837
|
+
repoRelativePath(target.repoRoot, recordPath),
|
|
838
|
+
...(gitignoreSelfHealStarted ? ['.gitignore'] : []),
|
|
839
|
+
], { cwd: target.repoRoot });
|
|
840
|
+
});
|
|
841
|
+
await compensate('shared migration source', `git checkout -- ${shellQuote(sourceRelative)}`, async () => {
|
|
842
|
+
await git.run(['checkout', '--', sourceRelative], {
|
|
843
|
+
cwd: target.repoRoot,
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
await compensate('synced discovery record', `rm -f ${shellQuote(recordPath)}`, async () => rm(recordPath, { force: true }));
|
|
847
|
+
if (gitignoreSelfHealStarted) {
|
|
848
|
+
await compensate('managed .gitignore rule', `git checkout -- ${shellQuote(gitignorePath)}`, async () => {
|
|
849
|
+
if (originalGitignore === null) {
|
|
850
|
+
await rm(gitignorePath, { force: true });
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
await writeFile(gitignorePath, originalGitignore, 'utf8');
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
if (publishedRemoteSha !== null) {
|
|
858
|
+
const rollbackRefspec = prePublishRemoteSha
|
|
859
|
+
? `${prePublishRemoteSha}:${target.ref}`
|
|
860
|
+
: `:${target.ref}`;
|
|
861
|
+
await compensate(`remote ref ${target.remote}/${target.ref}`, `git push --force-with-lease=${target.ref}:${publishedRemoteSha} ${target.remote} ${rollbackRefspec}`, async () => git
|
|
862
|
+
.run([
|
|
863
|
+
'push',
|
|
864
|
+
`--force-with-lease=${target.ref}:${publishedRemoteSha}`,
|
|
865
|
+
target.remote,
|
|
866
|
+
rollbackRefspec,
|
|
867
|
+
], { cwd: target.repoRoot })
|
|
868
|
+
.then(() => undefined));
|
|
869
|
+
}
|
|
870
|
+
if (created) {
|
|
871
|
+
await compensate(`local checkout ${target.projectPath} and ref ${target.ref}`, `git worktree remove --force ${shellQuote(target.projectPath)} && git update-ref -d ${target.ref}`, async () => rollbackCreatedSyncedProject(target, git));
|
|
872
|
+
}
|
|
873
|
+
if (activeProjectUpdated) {
|
|
874
|
+
await compensate('active project configuration', `oat config set activeProject ${shellQuote(originalLocalConfig.activeProject ?? '')}`, async () => writeLocal(target.repoRoot, originalLocalConfig));
|
|
875
|
+
}
|
|
876
|
+
if (compensationFailures.length > 0) {
|
|
877
|
+
const retained = compensationFailures
|
|
878
|
+
.map(({ resource, recovery, error: compensationError }) => `- ${resource}: ${errorMessage(compensationError)} Recovery: ${recovery}`)
|
|
879
|
+
.join('\n');
|
|
880
|
+
throw new CliError(`Migration failed: ${errorMessage(error)} Rollback was incomplete; retained resources require recovery:\n${retained}`, 2);
|
|
881
|
+
}
|
|
882
|
+
throw error;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Compensate only a synced checkout/ref created by the current invocation.
|
|
887
|
+
* Callers must establish that ownership before invoking this helper.
|
|
888
|
+
*/
|
|
889
|
+
export async function rollbackCreatedSyncedProject(target, git) {
|
|
890
|
+
await assertCanonicalSyncTargetIdentity(target);
|
|
891
|
+
if (await isTargetRegistered(target, git)) {
|
|
892
|
+
await git.run(['worktree', 'remove', '--force', target.projectPath], {
|
|
893
|
+
cwd: target.repoRoot,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
await git.run(['worktree', 'prune'], { cwd: target.repoRoot });
|
|
897
|
+
await git.run(['update-ref', '-d', target.ref], { cwd: target.repoRoot });
|
|
898
|
+
}
|
|
899
|
+
export async function assertNestedWorktree(target, git) {
|
|
900
|
+
await assertCanonicalSyncTargetIdentity(target);
|
|
901
|
+
if (resolve(target.projectPath) === resolve(target.repoRoot) ||
|
|
902
|
+
!(await isSyncedCheckout(target.projectPath))) {
|
|
903
|
+
throw new CliError(`Refusing synced mutation: ${target.projectPath} is not a nested git worktree.`, 2);
|
|
904
|
+
}
|
|
905
|
+
const topLevel = await git.run(['rev-parse', '--show-toplevel'], {
|
|
906
|
+
cwd: target.projectPath,
|
|
907
|
+
});
|
|
908
|
+
const [actualTopLevel, expectedTopLevel] = await Promise.all([
|
|
909
|
+
realpath(topLevel.stdout),
|
|
910
|
+
realpath(target.projectPath),
|
|
911
|
+
]);
|
|
912
|
+
if (actualTopLevel !== expectedTopLevel) {
|
|
913
|
+
throw new CliError(`Refusing synced mutation: expected nested toplevel ${expectedTopLevel}, got ${actualTopLevel}.`, 2);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
function isWithin(root, candidate) {
|
|
917
|
+
const child = relative(root, candidate);
|
|
918
|
+
return (child === '' ||
|
|
919
|
+
(child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child)));
|
|
920
|
+
}
|
|
921
|
+
function repoRelativePath(repoRoot, pathspec) {
|
|
922
|
+
const canonicalRepoRoot = canonicalizePath(repoRoot);
|
|
923
|
+
const absolute = canonicalizePath(isAbsolute(pathspec) ? resolve(pathspec) : resolve(repoRoot, pathspec));
|
|
924
|
+
if (!isWithin(canonicalRepoRoot, absolute)) {
|
|
925
|
+
throw new CliError(`Refusing parent-branch mutation outside the repository: ${pathspec}`, 2);
|
|
926
|
+
}
|
|
927
|
+
return relative(canonicalRepoRoot, absolute).split(sep).join('/');
|
|
928
|
+
}
|
|
929
|
+
export function assertAllowlistedPathspecs(repoRoot, pathspecs, options) {
|
|
930
|
+
const sharedRoot = canonicalizePath(options.projectRoots.sharedRoot);
|
|
931
|
+
const syncedRoot = canonicalizePath(options.projectRoots.syncedRoot);
|
|
932
|
+
const summaryRoot = options.summaryExportPath
|
|
933
|
+
? isAbsolute(options.summaryExportPath)
|
|
934
|
+
? canonicalizePath(options.summaryExportPath)
|
|
935
|
+
: canonicalizePath(resolve(repoRoot, options.summaryExportPath))
|
|
936
|
+
: null;
|
|
937
|
+
const recapRoot = options.recapExportRoot
|
|
938
|
+
? isAbsolute(options.recapExportRoot)
|
|
939
|
+
? canonicalizePath(options.recapExportRoot)
|
|
940
|
+
: canonicalizePath(resolve(repoRoot, options.recapExportRoot))
|
|
941
|
+
: null;
|
|
942
|
+
for (const pathspec of pathspecs) {
|
|
943
|
+
const repoRelative = repoRelativePath(repoRoot, pathspec);
|
|
944
|
+
const absolute = resolve(canonicalizePath(repoRoot), repoRelative);
|
|
945
|
+
const sharedRelative = relative(sharedRoot, absolute);
|
|
946
|
+
const syncedRelative = relative(syncedRoot, absolute);
|
|
947
|
+
const isSharedProjectPath = isWithin(sharedRoot, absolute) &&
|
|
948
|
+
sharedRelative !== '' &&
|
|
949
|
+
/^[a-zA-Z0-9_-]+(?:[\\/].*)?$/.test(sharedRelative);
|
|
950
|
+
const isSyncedRecordPath = isWithin(syncedRoot, absolute) &&
|
|
951
|
+
/^[a-zA-Z0-9_-]+\.json$/.test(syncedRelative);
|
|
952
|
+
const allowed = repoRelative === '.gitignore' ||
|
|
953
|
+
repoRelative === '.gitattributes' ||
|
|
954
|
+
isSyncedRecordPath ||
|
|
955
|
+
isSharedProjectPath ||
|
|
956
|
+
(summaryRoot !== null && isWithin(summaryRoot, absolute)) ||
|
|
957
|
+
(recapRoot !== null && isWithin(recapRoot, absolute));
|
|
958
|
+
if (!allowed) {
|
|
959
|
+
throw new CliError(`Refusing non-allowlisted parent-branch pathspec: ${pathspec}`, 2);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|