@knowttl/gitea-axi 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/search.js ADDED
@@ -0,0 +1,111 @@
1
+ import { collect, object, read } from './api.js';
2
+ import { CliError } from './error.js';
3
+ import { continuationHelp, issue, issueFields, positive, repository, selectedFields } from './reads.js';
4
+ export function validateSearch(kind, query, options) {
5
+ if (!query.trim() || /\S+:\S*/.test(query))
6
+ throw new CliError('USAGE', 'Use plain search text and explicit flags; query qualifiers are unsupported', 2);
7
+ if (kind === 'code')
8
+ throw new CliError('CAPABILITY_GAP', 'Indexed code search is unresolved; repository contents are not indexed search');
9
+ const allowed = kind === 'issues' || kind === 'prs' ? ['owner', 'state', 'label', 'assignee', 'author', 'sort', 'order', 'limit', 'fields', ...(kind === 'prs' ? ['draft'] : [])] :
10
+ kind === 'repos' ? ['owner', 'sort', 'order', 'limit', 'language', 'stars', 'fields'] : ['author', 'sort', 'order', 'limit', 'revision', 'fields'];
11
+ for (const key of Object.keys(options)) {
12
+ if (!['origin', 'allow-insecure-http', 'config', 'profile', 'repo'].includes(key) && !allowed.includes(key))
13
+ throw new CliError('CAPABILITY_GAP', '--' + key + ' is not evidenced for search ' + kind);
14
+ }
15
+ positive(options.limit, 1000, 1000);
16
+ if (options.state && !['open', 'closed', 'all'].includes(String(options.state)))
17
+ throw new CliError('USAGE', '--state must be open, closed or all', 2);
18
+ const sorts = kind === 'repos' ? ['name', 'created', 'updated', 'stars'] : kind === 'commits' ? ['created'] : ['created', 'updated', 'comments'];
19
+ if (options.sort && !sorts.includes(String(options.sort)))
20
+ throw new CliError('USAGE', 'Choose --sort ' + sorts.join(','), 2);
21
+ if (options.order && !['asc', 'desc'].includes(String(options.order)))
22
+ throw new CliError('USAGE', '--order must be asc or desc', 2);
23
+ if (options.stars && !/^(?:>=|<=|>|<|=)?\d+$/.test(String(options.stars)))
24
+ throw new CliError('USAGE', '--stars requires an integer comparison such as >100', 2);
25
+ selectedFields(options, kind === 'repos' ? ['id', 'full_name', 'description', 'language', 'stars_count'] : kind === 'commits' ? ['sha', 'message', 'author', 'created', 'url'] : issueFields.concat(['closedAt', 'commentsCount', 'isLocked', ...(kind === 'prs' ? ['isDraft'] : [])]), []);
26
+ }
27
+ export async function search(client, kind, query, options) {
28
+ const target = typeof options.repo === 'string' ? repository(options.repo, client.origin) : undefined;
29
+ const base = target ? '/api/v1/repos/' + target.split('/').map(encodeURIComponent).join('/') : undefined;
30
+ let page;
31
+ let revision = null;
32
+ if (kind === 'commits') {
33
+ if (!base)
34
+ throw new CliError('USAGE', 'Commit search requires --repo OWNER/NAME', 2);
35
+ const repo = object(await read(client, base));
36
+ const ref = String(options.revision ?? repo.default_branch);
37
+ const commit = object(await read(client, base + '/git/commits/' + encodeURIComponent(ref)));
38
+ if (typeof commit.sha !== 'string' || !commit.sha)
39
+ throw new CliError('INVALID_RESPONSE', 'Revision has no commit SHA');
40
+ revision = commit.sha;
41
+ page = await collect(client, base + '/commits?sha=' + encodeURIComponent(commit.sha));
42
+ }
43
+ else if (kind === 'repos') {
44
+ page = await collect(client, '/api/v1/repos/search?q=' + encodeURIComponent(query) + '&includeDesc=true', 'data');
45
+ }
46
+ else {
47
+ page = await collect(client, base ? base + (kind === 'prs' ? '/pulls' : '/issues') + '?state=all' + (kind === 'issues' ? '&type=issues' : '') :
48
+ '/api/v1/repos/issues/search?state=all&type=' + (kind === 'prs' ? 'pulls' : 'issues') + '&q=' + encodeURIComponent(query) + (options.owner ? '&owner=' + encodeURIComponent(String(options.owner)) : ''));
49
+ }
50
+ if (kind === 'prs' && !base) {
51
+ page.items = page.items.map(row => ({ ...row, draft: row.pull_request ? object(row.pull_request).draft : undefined }));
52
+ }
53
+ const needle = query.toLowerCase();
54
+ const rows = page.items.filter(row => {
55
+ const fullName = kind === 'repos' ? row.full_name : row.repository ? object(row.repository).full_name : target;
56
+ if (target && kind === 'repos' && fullName !== target)
57
+ return false;
58
+ if (options.owner && String(fullName).split('/')[0] !== options.owner)
59
+ return false;
60
+ if (kind === 'repos') {
61
+ if (!String(row.full_name ?? '').toLowerCase().includes(needle) && !String(row.description ?? '').toLowerCase().includes(needle))
62
+ return false;
63
+ if (options.language && String(row.language).toLowerCase() !== String(options.language).toLowerCase())
64
+ return false;
65
+ if (options.stars) {
66
+ const [, op, value] = /^(>=|<=|>|<|=)?(\d+)$/.exec(String(options.stars));
67
+ const count = Number(row.stars_count), expected = Number(value);
68
+ if (!(op === '>' ? count > expected : op === '<' ? count < expected : op === '>=' ? count >= expected : op === '<=' ? count <= expected : count === expected))
69
+ return false;
70
+ }
71
+ }
72
+ else if (kind === 'commits') {
73
+ if (!String(object(row.commit).message).toLowerCase().includes(needle))
74
+ return false;
75
+ if (options.author && (!row.author || object(row.author).login !== options.author))
76
+ return false;
77
+ }
78
+ else {
79
+ if (kind === 'issues' && row.pull_request)
80
+ return false;
81
+ if (!(String(row.title) + '\n' + String(row.body ?? '')).toLowerCase().includes(needle))
82
+ return false;
83
+ if (options.state && options.state !== 'all' && row.state !== options.state)
84
+ return false;
85
+ if (options.author && (!row.user || object(row.user).login !== options.author))
86
+ return false;
87
+ if (options.assignee && !(row.assignees ?? []).some(user => user.login === options.assignee))
88
+ return false;
89
+ if (options.label && !(row.labels ?? []).some(label => String(options.label).split(',').includes(String(label.name))))
90
+ return false;
91
+ if (options.draft && row.draft !== true)
92
+ return false;
93
+ }
94
+ return true;
95
+ });
96
+ const key = options.sort === 'updated' ? 'updated_at' : options.sort === 'comments' ? 'comments' : options.sort === 'stars' ? 'stars_count' : options.sort === 'name' ? 'full_name' : kind === 'commits' ? 'created' : 'created_at';
97
+ rows.sort((a, b) => {
98
+ const left = a[key] ?? '', right = b[key] ?? '';
99
+ const compared = typeof left === 'number' && typeof right === 'number' ? left - right : String(left).localeCompare(String(right));
100
+ return (compared || String(a.id ?? a.sha).localeCompare(String(b.id ?? b.sha))) * (options.order === 'asc' ? 1 : -1);
101
+ });
102
+ const limit = positive(options.limit, 1000, 1000);
103
+ const fields = typeof options.fields === 'string' ? options.fields.split(',') : kind === 'repos' ? ['id', 'full_name', 'description'] : kind === 'commits' ? ['sha', 'message', 'author'] : ['number', 'title', 'state'];
104
+ return { items: rows.slice(0, limit).map(row => {
105
+ const projection = kind === 'repos' ? row : kind === 'commits' ? { sha: row.sha, message: object(row.commit).message, author: row.author, created: row.created, url: row.html_url } :
106
+ { ...issue(row, Boolean(options.fields)), closedAt: row.closed_at ?? null, commentsCount: row.comments ?? 0, isLocked: row.is_locked ?? false, isDraft: row.draft ?? null };
107
+ return { ...(kind === 'issues' || kind === 'prs' ? { repository: row.repository ? object(row.repository).full_name : target } : {}), ...Object.fromEntries(fields.map(field => [field, projection[field]])) };
108
+ }), count: Math.min(rows.length, limit), total: page.truncated ? null : rows.length, truncated: page.truncated || rows.length > limit, next: page.next,
109
+ scope: { repository: target ?? 'visible repositories', owner: options.owner ?? null, revision, pages: 'maximum 20 pages / 1000 candidates per collection', indexed: false },
110
+ continuation: page.truncated || rows.length > limit, help: continuationHelp(page, rows.length, limit) };
111
+ }
package/dist/stacks.js ADDED
@@ -0,0 +1,486 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { Ajv } from 'ajv';
5
+ import { Git } from './git.js';
6
+ import { CliError } from './error.js';
7
+ import { collect, object, read } from './api.js';
8
+ import { repoPath } from './repo-workflows.js';
9
+ import { pullPlan } from './pulls.js';
10
+ import { gap, load, lock, same, save, usage, verifyPlannedPull } from './local-workflows.js';
11
+ import { write } from './writes.js';
12
+ const branch = { type: 'string', minLength: 1 };
13
+ const tips = { type: 'object', additionalProperties: { type: 'string', pattern: '^[a-f0-9]{40}$' } };
14
+ const schema = new Ajv().compile({
15
+ type: 'object', additionalProperties: false, required: ['version', 'revision', 'id', 'trunk', 'branches', 'remote', 'origin', 'repository', 'pulls'],
16
+ properties: {
17
+ version: { const: 1 }, revision: { type: 'integer', minimum: 0 }, id: branch, trunk: branch,
18
+ branches: { type: 'array', uniqueItems: true, items: branch }, remote: branch, origin: branch, repository: branch,
19
+ pulls: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['branch', 'number', 'base', 'head', 'state', 'merged'],
20
+ properties: { branch, number: { type: 'integer', minimum: 1 }, base: branch, head: { type: 'string', pattern: '^[a-f0-9]{40}$' },
21
+ state: { enum: ['open', 'closed'] }, merged: { type: 'boolean' } } } },
22
+ pending: { type: 'object', additionalProperties: false, required: ['action', 'confirmations', 'originalBranch', 'originalTips', 'steps'], properties: { action: branch,
23
+ confirmations: { type: 'array', uniqueItems: true, items: { type: 'string', pattern: '^[a-f0-9-]{36}$' } },
24
+ originalBranch: { type: 'string' }, originalTips: { type: 'string' },
25
+ steps: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['effect', 'state'],
26
+ properties: { effect: branch, state: { enum: ['pending', 'dispatched', 'completed'] } } } },
27
+ rebase: { type: 'object', additionalProperties: false,
28
+ required: ['branches', 'oldBases', 'originalTips', 'expectedTips', 'originalBranch', 'cursor', 'dispatched', 'authorDate'],
29
+ properties: { branches: { type: 'array', items: branch, uniqueItems: true }, oldBases: tips, originalTips: tips, expectedTips: tips,
30
+ originalBranch: branch, cursor: { type: 'integer', minimum: 0 }, dispatched: { type: 'boolean' }, authorDate: { type: 'boolean' } } } } },
31
+ },
32
+ });
33
+ async function stackAt(path, git) {
34
+ if (!existsSync(path))
35
+ return undefined;
36
+ const value = await load(path);
37
+ if (!schema(value) || value.origin !== git.origin || value.branches.includes(value.trunk) ||
38
+ new Set(value.pulls.map(pull => pull.branch)).size !== value.pulls.length ||
39
+ value.pulls.some(pull => !value.branches.includes(pull.branch))) {
40
+ throw new CliError('STACK_INVALID', 'Stack metadata is invalid, inconsistent or belongs to a different origin');
41
+ }
42
+ const rebase = value.pending?.rebase;
43
+ if (rebase) {
44
+ if (rebase.cursor > rebase.branches.length || rebase.branches.some(branch => !value.branches.includes(branch) || !rebase.oldBases[branch]) ||
45
+ Object.keys(rebase.oldBases).length !== rebase.branches.length ||
46
+ [rebase.originalTips, rebase.expectedTips].some(tips => Object.keys(tips).length !== value.branches.length || value.branches.some(branch => !Object.hasOwn(tips, branch)))) {
47
+ throw new CliError('STACK_INVALID', 'Rebase checkpoints must describe only the owned stack branches');
48
+ }
49
+ await git.branch(rebase.originalBranch);
50
+ }
51
+ for (const name of [value.trunk, ...value.branches])
52
+ await git.branch(name);
53
+ return value;
54
+ }
55
+ async function ancestor(git, parent, child) {
56
+ return (await git.run(['merge-base', parent, child])) === await git.tip(parent);
57
+ }
58
+ export async function stackWorkflow(client, identity, action, args, options) {
59
+ const git = await new Git(String(options.workspace), identity.origin, identity.token).workspace();
60
+ const directory = join(await git.run(['rev-parse', '--path-format=absolute', '--git-common-dir']), 'gitea-axi');
61
+ const path = join(directory, 'stacks.json');
62
+ const before = await stackAt(path, git);
63
+ const snapshot = await git.snapshot();
64
+ if (action === 'view') {
65
+ if (args.length)
66
+ usage('stack view takes no positional arguments');
67
+ return { stack: before ?? null, current: snapshot.branch, recovery: before?.pending ?? null };
68
+ }
69
+ if (action === 'submit')
70
+ gap('Stack submit requires --auto scheduling; actual merge_when_checks_succeed scheduling is not verified, so no branch or PR was changed');
71
+ if (options.all && options.update)
72
+ usage('Choose --all or --update');
73
+ if ((options.all || options.update) && !options.message)
74
+ usage('--all and --update require --message');
75
+ if (options.continue && options.abort)
76
+ usage('Choose --continue or --abort');
77
+ const recoveringRebase = action === 'rebase' && (options.continue || options.abort);
78
+ if (!snapshot.branch && !(recoveringRebase && before?.pending?.rebase))
79
+ usage('Check out a branch before running stack commands');
80
+ const recoveringPush = action === 'sync' && before?.pending?.action === 'push';
81
+ const recoveringSync = action === 'sync' && before?.pending?.action === 'sync';
82
+ const recovering = recoveringRebase || recoveringPush || recoveringSync;
83
+ if (recoveringRebase && (args.length || ['downstack', 'upstack', 'no-trunk', 'committer-date-is-author-date', 'remote'].some(key => options[key])))
84
+ usage('Recovery uses the recorded rebase options only');
85
+ if (before?.pending && !recovering)
86
+ throw new CliError('STACK_RECOVERY_REQUIRED', 'Stack operation is pending; inspect stack view and the native Git state before recovery');
87
+ if (!recovering && !(action === 'add' && options.message))
88
+ await git.clean();
89
+ if (!before && !['init', 'link'].includes(action))
90
+ usage('Initialize or link the stack first');
91
+ const remote = await git.remote(String(options.remote ?? before?.remote ?? 'origin'));
92
+ if (before && remote.repository !== before.repository)
93
+ usage('Selected remote must address the recorded stack repository');
94
+ const base = repoPath(remote.repository);
95
+ const remoteRefs = ['push', 'sync', 'rebase'].includes(action) ? await git.run(['ls-remote', '--heads', '--', remote.url]) : '';
96
+ if (recoveringPush) {
97
+ if (before.pending.originalTips !== snapshot.refs || remote.name !== before.remote)
98
+ throw new CliError('STACK_RECOVERY_REQUIRED', 'Push recovery requires the recorded branch tips and remote');
99
+ for (const name of before.branches) {
100
+ if (!remoteRefs.split('\n').includes(await git.tip('refs/heads/' + name) + '\trefs/heads/' + name)) {
101
+ throw new CliError('STACK_RECOVERY_REQUIRED', 'Remote does not confirm the interrupted push; no effect repeated');
102
+ }
103
+ }
104
+ }
105
+ let state = before ? structuredClone(before) : { version: 1, revision: 0, id: '', trunk: '', branches: [],
106
+ remote: remote.name, origin: identity.origin, repository: remote.repository, pulls: [] };
107
+ state.remote = remote.name;
108
+ const plans = [];
109
+ const commands = [];
110
+ let destination;
111
+ let remove = false;
112
+ let rebase;
113
+ const apiPreconditions = [];
114
+ const select = (selector) => {
115
+ if (state.branches.includes(selector) || selector === state.trunk)
116
+ return selector;
117
+ if (selector === state.id)
118
+ return state.branches[0];
119
+ const url = /^https?:/.test(selector) ? new URL(selector) : undefined;
120
+ if (url && (url.origin !== identity.origin || url.pathname.split('/').slice(1, 3).join('/') !== state.repository || url.search || url.hash || url.username || url.password))
121
+ usage('PR URL must belong to the stack repository');
122
+ const number = url ? /^\/[^/]+\/[^/]+\/pulls\/([1-9]\d*)$/.exec(url.pathname)?.[1] : selector;
123
+ return state.pulls.find(pull => String(pull.number) === number)?.branch ?? usage('Select an owned stack, branch or linked PR');
124
+ };
125
+ if (action === 'init' || action === 'link') {
126
+ if (before && action === 'init')
127
+ usage('A stack already exists; use stack add or unstack first');
128
+ if (!args.length)
129
+ usage('Select ordered existing branches or PR refs');
130
+ state.trunk = await git.branch(String(options.base ?? before?.trunk ?? snapshot.branch));
131
+ const branches = [];
132
+ const pulls = [];
133
+ for (const selector of args) {
134
+ let name = selector;
135
+ let number;
136
+ if (action === 'link') {
137
+ if (/^[1-9]\d*$/.test(selector))
138
+ number = selector;
139
+ else if (/^https?:/.test(selector)) {
140
+ const url = new URL(selector);
141
+ if (url.origin !== identity.origin || url.username || url.password || url.search || url.hash ||
142
+ !url.pathname.startsWith('/' + remote.repository + '/pulls/'))
143
+ usage('Select PR URLs in the stack repository');
144
+ number = url.pathname.split('/').at(-1);
145
+ if (!/^[1-9]\d*$/.test(number ?? ''))
146
+ usage('Select a positive PR number');
147
+ }
148
+ if (!number) {
149
+ const page = await collect(client, base + '/pulls?state=open');
150
+ if (page.truncated)
151
+ throw new CliError('INCOMPLETE_READ', 'Link requires the complete PR collection');
152
+ const matches = page.items.filter(pull => object(pull.head).ref === selector);
153
+ if (matches.length !== 1)
154
+ usage('Select a branch with exactly one open PR');
155
+ number = String(matches[0].number);
156
+ }
157
+ const pull = object(await read(client, base + '/pulls/' + number));
158
+ const head = object(pull.head);
159
+ if (pull.state !== 'open' || object(head.repo).full_name !== remote.repository)
160
+ usage('Link requires open same-repository PRs');
161
+ name = String(head.ref);
162
+ if (await git.tip('refs/heads/' + await git.branch(name)) !== head.sha)
163
+ throw new CliError('STALE_HEAD', 'Local linked branch differs from its PR head');
164
+ const parent = branches.at(-1) ?? state.trunk;
165
+ const plan = await pullPlan(client, 'pr edit', base, number, { base: parent });
166
+ plans.push(plan);
167
+ apiPreconditions.push(plan.preconditions);
168
+ pulls.push({ branch: name, number: Number(number), base: parent, head: String(head.sha), state: 'open', merged: false });
169
+ }
170
+ await git.branch(name);
171
+ if (name === state.trunk || branches.includes(name))
172
+ usage('Stack branch membership must be unique and exclude trunk');
173
+ const parent = branches.at(-1) ?? state.trunk;
174
+ if (!await ancestor(git, 'refs/heads/' + parent, 'refs/heads/' + name))
175
+ usage('Branches must form the requested ancestor order');
176
+ branches.push(name);
177
+ }
178
+ state = { ...state, id: branches[0], branches, pulls };
179
+ }
180
+ else if (action === 'add') {
181
+ if (args.length > 1)
182
+ usage('Select at most one existing branch');
183
+ const name = await git.branch(args[0] ?? snapshot.branch);
184
+ if (state.branches.includes(name) || name === state.trunk)
185
+ usage('Select a branch outside the stack');
186
+ if (options.message && name !== snapshot.branch)
187
+ usage('Commit options require the current branch');
188
+ if (!await ancestor(git, 'refs/heads/' + (state.branches.at(-1) ?? state.trunk), 'refs/heads/' + name))
189
+ usage('New branch must descend from the stack top');
190
+ if (options.all || options.update)
191
+ commands.push(['add', '--update', '--', '.']);
192
+ if (options.message)
193
+ commands.push(['commit', '-m', String(options.message)]);
194
+ state.branches.push(name);
195
+ }
196
+ else if (['checkout', 'up', 'down', 'top', 'bottom', 'trunk'].includes(action)) {
197
+ if (action === 'checkout') {
198
+ if (args.length !== 1)
199
+ usage('Select one stack, branch, PR number or URL');
200
+ destination = select(args[0]);
201
+ }
202
+ else if (action === 'up' || action === 'down') {
203
+ if (args.length > 1 || args[0] !== undefined && !/^[1-9]\d*$/.test(args[0]))
204
+ usage('Select a positive step count');
205
+ const index = state.branches.indexOf(snapshot.branch);
206
+ if (index < 0 && snapshot.branch !== state.trunk)
207
+ usage('Current branch is outside the stack');
208
+ const target = index + (action === 'up' ? 1 : -1) * Number(args[0] ?? 1);
209
+ if (target < -1 || target >= state.branches.length)
210
+ usage('Movement is outside the stack');
211
+ destination = target === -1 ? state.trunk : state.branches[target];
212
+ }
213
+ else {
214
+ if (args.length)
215
+ usage('Navigation takes no positional arguments');
216
+ destination = action === 'trunk' ? state.trunk : action === 'top' ? state.branches.at(-1) : state.branches[0];
217
+ if (!destination)
218
+ usage('The stack has no remaining branches');
219
+ }
220
+ commands.push(['checkout', destination]);
221
+ }
222
+ else if (action === 'push') {
223
+ if (args.length)
224
+ usage('stack push takes no positional arguments');
225
+ if (!state.branches.length)
226
+ usage('The stack has no remaining branches to push');
227
+ const leases = state.branches.map(name => '--force-with-lease=refs/heads/' + name + ':' +
228
+ (remoteRefs.split('\n').find(line => line.endsWith('\trefs/heads/' + name))?.split('\t')[0] ?? ''));
229
+ commands.push(['push', '--atomic', ...leases, '--', remote.url, ...state.branches.map(name => 'refs/heads/' + name + ':refs/heads/' + name)]);
230
+ }
231
+ else if (action === 'sync') {
232
+ if (args.length)
233
+ usage('stack sync takes no positional arguments');
234
+ const page = await collect(client, base + '/pulls?state=all');
235
+ if (page.truncated)
236
+ throw new CliError('INCOMPLETE_READ', 'Sync requires a complete PR collection');
237
+ apiPreconditions.push(page.items);
238
+ for (const linked of state.pulls) {
239
+ const pull = page.items.find(pull => pull.number === linked.number);
240
+ if (!pull)
241
+ throw new CliError('NOT_FOUND_OR_HIDDEN', 'A recorded PR is missing from the complete sync result');
242
+ const head = object(pull.head);
243
+ if (head.ref !== linked.branch || object(head.repo).full_name !== state.repository ||
244
+ !/^[a-f0-9]{40}$/.test(String(head.sha)) || !['open', 'closed'].includes(String(pull.state))) {
245
+ throw new CliError('INVALID_RESPONSE', 'A synced PR changed its recorded branch or repository identity');
246
+ }
247
+ linked.head = String(head.sha);
248
+ linked.base = await git.branch(String(object(pull.base).ref));
249
+ linked.state = pull.state;
250
+ linked.merged = pull.merged === true;
251
+ }
252
+ const existing = [state.trunk, ...state.branches].filter(name => remoteRefs.split('\n').some(line => line.endsWith('\trefs/heads/' + name)));
253
+ if (existing.length)
254
+ commands.push(['fetch', '--no-tags', '--', remote.url, ...existing.map(name => 'refs/heads/' + name + ':refs/remotes/' + remote.name + '/' + name)]);
255
+ if (options.prune) {
256
+ for (const member of [...state.branches]) {
257
+ const linked = state.pulls.find(pull => pull.branch === member);
258
+ const pull = page.items.find(pull => pull.number === linked?.number);
259
+ if (!linked || !pull || pull.merged !== true || object(pull.head).sha !== await git.tip('refs/heads/' + member))
260
+ continue;
261
+ if (member === snapshot.branch || snapshot.worktrees.split('\n').includes('branch refs/heads/' + member))
262
+ continue;
263
+ const merge = String(pull.merge_commit_sha);
264
+ if (!/^[a-f0-9]{40}$/.test(merge) || !await git.run(['rev-parse', '--verify', '--quiet', merge + '^{commit}'], [0, 1]) ||
265
+ !await ancestor(git, 'refs/heads/' + member, merge))
266
+ continue;
267
+ commands.push(['branch', '-d', member]);
268
+ }
269
+ }
270
+ }
271
+ else if (action === 'unstack') {
272
+ if (args.length > 1 || args[0] !== undefined && args[0] !== state.id)
273
+ usage('Select the recorded stack identity');
274
+ if (!options.local) {
275
+ for (const pull of state.pulls) {
276
+ const plan = await pullPlan(client, 'pr edit', base, String(pull.number), { base: state.trunk });
277
+ plans.push(plan);
278
+ apiPreconditions.push(plan.preconditions);
279
+ }
280
+ }
281
+ remove = true;
282
+ }
283
+ else if (action === 'merge') {
284
+ if (args.length !== 1)
285
+ usage('Select the stack identity or linked PR');
286
+ const selected = args[0] === state.id ? state.branches.at(-1) : select(args[0]);
287
+ const styles = ['merge', 'squash', 'rebase'].filter(key => options[key]);
288
+ if (styles.length + Number(options['merge-method'] !== undefined) !== 1)
289
+ usage('Choose exactly one merge method');
290
+ const method = String(options['merge-method'] ?? styles[0]);
291
+ if (!['merge', 'squash', 'rebase'].includes(method))
292
+ usage('Use merge, squash or rebase');
293
+ for (const member of state.branches.slice(0, state.branches.indexOf(selected) + 1)) {
294
+ const linked = state.pulls.find(pull => pull.branch === member) ?? usage('Every merged stack branch requires a linked PR');
295
+ const pull = object(await read(client, base + '/pulls/' + linked.number));
296
+ apiPreconditions.push(pull);
297
+ if (pull.merged === true) {
298
+ const head = object(pull.head);
299
+ if (pull.state !== 'closed' || head.ref !== member || object(head.repo).full_name !== state.repository ||
300
+ head.sha !== await git.tip('refs/heads/' + member))
301
+ throw new CliError('STALE_HEAD', 'Completed dependency differs from its owned branch');
302
+ linked.state = 'closed';
303
+ linked.merged = true;
304
+ linked.head = String(head.sha);
305
+ continue;
306
+ }
307
+ if (linked.base !== state.trunk) {
308
+ const edit = await pullPlan(client, 'pr edit', base, String(linked.number), { base: state.trunk });
309
+ plans.push(edit);
310
+ apiPreconditions.push(edit.preconditions);
311
+ }
312
+ const plan = await pullPlan(client, 'pr merge', base, String(linked.number), { method, 'match-head-commit': await git.tip('refs/heads/' + member) });
313
+ plans.push(plan);
314
+ apiPreconditions.push(plan.preconditions);
315
+ }
316
+ }
317
+ else if (action === 'rebase') {
318
+ if (recoveringRebase) {
319
+ rebase = state.pending?.rebase ?? usage('No recorded stack rebase to recover');
320
+ }
321
+ else {
322
+ if (args.length > 1)
323
+ usage('Select at most one stack branch');
324
+ const selected = args[0] ?? snapshot.branch;
325
+ const index = state.branches.indexOf(selected);
326
+ if (index < 0)
327
+ usage('Select a stack branch for rebase');
328
+ const first = options.downstack ? 0 : index;
329
+ const last = options.upstack ? state.branches.length : index + 1;
330
+ const branches = state.branches.slice(first, last).filter(name => !(options['no-trunk'] && state.branches.indexOf(name) === 0));
331
+ if (!branches.length)
332
+ usage('The selected rebase scope is empty');
333
+ const originalTips = Object.create(null), oldBases = Object.create(null);
334
+ for (const name of state.branches)
335
+ originalTips[name] = await git.tip('refs/heads/' + name);
336
+ for (const name of branches) {
337
+ const parent = state.branches[state.branches.indexOf(name) - 1] ?? state.trunk;
338
+ oldBases[name] = await git.run(['merge-base', 'refs/heads/' + parent, 'refs/heads/' + name]);
339
+ }
340
+ rebase = { branches, oldBases, originalTips, expectedTips: { ...originalTips }, originalBranch: snapshot.branch, cursor: 0, dispatched: false,
341
+ authorDate: Boolean(options['committer-date-is-author-date']) };
342
+ state.pending = { action: 'rebase', rebase, confirmations: [], originalBranch: snapshot.branch, originalTips: snapshot.refs, steps: [] };
343
+ }
344
+ }
345
+ else
346
+ usage('Unknown stack operation');
347
+ const localAction = action === 'rebase' ? options.continue ? 'continue' : options.abort ? 'abort' : 'rebase' :
348
+ ['up', 'down', 'top', 'bottom', 'trunk'].includes(action) ? 'checkout' : action;
349
+ const local = 'gitea.local.stack.' + localAction;
350
+ const begin = { method: 'LOCAL', path: git.cwd, local, payload: { phase: 'begin', action, args, options: { ...options, preview: undefined, confirm: undefined }, remote,
351
+ ...(recovering ? { recoverLocal: before.pending.confirmations } : {}) } };
352
+ const finish = { method: 'LOCAL', path: git.cwd, local, payload: { phase: 'finish', state, commands, remove, destination } };
353
+ const steps = [begin];
354
+ const apiResultIndices = [];
355
+ for (const [index, plan] of plans.entries()) {
356
+ steps.push({ method: 'LOCAL', path: git.cwd, local, payload: { phase: 'prepare', index } });
357
+ apiResultIndices.push(steps.length);
358
+ steps.push(...plan.steps, { method: 'LOCAL', path: git.cwd, local, payload: { phase: 'verify', index } });
359
+ }
360
+ steps.push(finish);
361
+ const plan = { target: git.cwd, reads: [], steps, preconditions: { metadata: before ?? null, snapshot, api: apiPreconditions,
362
+ remoteRefs: remoteRefs || null },
363
+ ...(recovering ? { recoverLocal: before.pending.confirmations } : {}),
364
+ notes: action === 'merge' ? { yes: true, order: plans.filter(plan => plan.pull?.action === 'merge').map(plan => plan.target) } : undefined };
365
+ let unlock;
366
+ const runtimePlans = [...plans];
367
+ try {
368
+ return await write(client, identity, plan, options, async (step, results, confirmation) => {
369
+ const payload = object(step.payload);
370
+ if (payload.phase === 'begin') {
371
+ unlock = await lock(directory);
372
+ if (!same(before, await stackAt(path, git)) || !same(snapshot, await git.snapshot()))
373
+ throw new CliError('CONFIRMATION_MISMATCH', 'Stack or workspace changed before dispatch');
374
+ if (action !== 'rebase')
375
+ state.pending = { action, confirmations: recovering ? [...before.pending.confirmations] : [],
376
+ originalBranch: before?.pending?.originalBranch ?? snapshot.branch, originalTips: before?.pending?.originalTips ?? snapshot.refs,
377
+ steps: [...plans.map(plan => ({ effect: plan.target, state: 'pending' })), ...commands.map(command => ({ effect: command[0], state: 'pending' }))] };
378
+ state.pending.confirmations.push(confirmation);
379
+ await save(path, state);
380
+ return { state: 'started' };
381
+ }
382
+ if (payload.phase === 'prepare') {
383
+ const index = Number(payload.index), planned = plans[index];
384
+ if (planned.pull?.action === 'merge') {
385
+ const fresh = await pullPlan(client, 'pr merge', base, planned.target.split('/').at(-1), {
386
+ method: String(object(planned.steps[0].payload).Do), 'match-head-commit': String(planned.pull.expected.headSHA),
387
+ });
388
+ if (!same(fresh.steps, planned.steps) || fresh.pull.expected.base !== state.trunk)
389
+ throw new CliError('STALE_HEAD', 'Stack merge base or checked head changed');
390
+ runtimePlans[index] = fresh;
391
+ }
392
+ state.pending.steps[index].state = 'dispatched';
393
+ await save(path, state);
394
+ return { prepared: planned.target };
395
+ }
396
+ if (payload.phase === 'verify') {
397
+ const index = Number(payload.index);
398
+ await verifyPlannedPull(client, runtimePlans[index], results[apiResultIndices[index]]);
399
+ const verified = runtimePlans[index].pull;
400
+ const member = state.pulls.find(pull => pull.number === Number(runtimePlans[index].target.split('/').at(-1)));
401
+ if (member) {
402
+ member.base = String(verified.expected.base);
403
+ member.head = String(verified.expected.headSHA);
404
+ member.merged = verified.action === 'merge' || verified.expected.merged === true;
405
+ member.state = member.merged ? 'closed' : verified.expected.state;
406
+ }
407
+ state.pending.steps[index].state = 'completed';
408
+ await save(path, state);
409
+ return { verified: plans[index].target };
410
+ }
411
+ if (rebase) {
412
+ const nativeRebase = existsSync(await git.run(['rev-parse', '--path-format=absolute', '--git-path', 'rebase-merge'])) ||
413
+ existsSync(await git.run(['rev-parse', '--path-format=absolute', '--git-path', 'rebase-apply']));
414
+ for (const [name, expected] of Object.entries(rebase.expectedTips)) {
415
+ if (await git.tip('refs/heads/' + name) !== expected)
416
+ throw new CliError('STACK_RECOVERY_REQUIRED', 'A branch tip changed outside the recorded checkpoint; no effect repeated');
417
+ }
418
+ if (options.abort) {
419
+ if (nativeRebase)
420
+ await git.run(['rebase', '--abort']);
421
+ for (const [name, expected] of Object.entries(rebase.expectedTips)) {
422
+ if (expected !== rebase.originalTips[name])
423
+ await git.run(['update-ref', 'refs/heads/' + name, rebase.originalTips[name], expected]);
424
+ }
425
+ await git.run(['checkout', rebase.originalBranch]);
426
+ }
427
+ else {
428
+ if (rebase.dispatched) {
429
+ if (!nativeRebase || !options.continue)
430
+ throw new CliError('STACK_RECOVERY_REQUIRED', 'Dispatched rebase has no native conflict state; inspect it without repeating the effect');
431
+ await git.run(['rebase', '--continue']);
432
+ const name = rebase.branches[rebase.cursor];
433
+ rebase.expectedTips[name] = await git.tip('refs/heads/' + name);
434
+ rebase.cursor++;
435
+ rebase.dispatched = false;
436
+ await save(path, state);
437
+ }
438
+ for (; rebase.cursor < rebase.branches.length;) {
439
+ const name = rebase.branches[rebase.cursor];
440
+ const parent = state.branches[state.branches.indexOf(name) - 1] ?? state.trunk;
441
+ rebase.dispatched = true;
442
+ await save(path, state);
443
+ await git.run(['rebase', ...(rebase.authorDate ? ['--committer-date-is-author-date'] : []), '--onto',
444
+ 'refs/heads/' + parent, rebase.oldBases[name], name]);
445
+ rebase.expectedTips[name] = await git.tip('refs/heads/' + name);
446
+ rebase.cursor++;
447
+ rebase.dispatched = false;
448
+ await save(path, state);
449
+ }
450
+ await git.run(['checkout', rebase.originalBranch]);
451
+ }
452
+ }
453
+ else {
454
+ for (const [index, command] of commands.entries()) {
455
+ state.pending.steps[plans.length + index].state = 'dispatched';
456
+ await save(path, state);
457
+ await git.run(command);
458
+ if (action === 'sync' && command[0] === 'branch') {
459
+ state.branches = state.branches.filter(name => name !== command[2]);
460
+ state.pulls = state.pulls.filter(pull => pull.branch !== command[2]);
461
+ }
462
+ state.pending.steps[plans.length + index].state = 'completed';
463
+ await save(path, state);
464
+ }
465
+ if (action === 'push') {
466
+ const refs = await git.run(['ls-remote', '--heads', '--', remote.url]);
467
+ for (const name of state.branches) {
468
+ if (!refs.split('\n').includes(await git.tip('refs/heads/' + name) + '\trefs/heads/' + name))
469
+ throw new CliError('READBACK_MISMATCH', 'Selected remote did not confirm all pushed branch tips');
470
+ }
471
+ }
472
+ }
473
+ delete state.pending;
474
+ state.revision++;
475
+ if (remove)
476
+ await rm(path);
477
+ else
478
+ await save(path, state);
479
+ return { stack: remove ? null : state, current: await git.run(['symbolic-ref', '--short', 'HEAD']) };
480
+ });
481
+ }
482
+ finally {
483
+ if (unlock)
484
+ await unlock();
485
+ }
486
+ }