atris 3.74.3 → 3.74.5
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/FOR_AGENTS.md +12 -12
- package/atris/AGENTS.md +7 -7
- package/atris/CLAUDE.md +10 -10
- package/atris/policies/atris-backend.md +1 -1
- package/atris/policies/atris-design.md +71 -71
- package/atris/policies/design-seed.md +28 -28
- package/atris/policies/writing.md +1 -1
- package/atris/skills/apps/SKILL.md +3 -3
- package/atris/skills/atris-feedback/SKILL.md +2 -2
- package/atris/skills/autopilot/SKILL.md +10 -10
- package/atris/skills/blocks/SKILL.md +16 -16
- package/atris/skills/calendar/SKILL.md +1 -1
- package/atris/skills/clawhub/atris/SKILL.md +4 -4
- package/atris/skills/clawhub/philosophy-of-work/SKILL.md +7 -7
- package/atris/skills/create-app/SKILL.md +18 -18
- package/atris/skills/create-member/SKILL.md +11 -11
- package/atris/skills/design/SKILL.md +1 -1
- package/atris/skills/drive/SKILL.md +25 -25
- package/atris/skills/email-agent/SKILL.md +11 -11
- package/atris/skills/endgame/SKILL.md +33 -33
- package/atris/skills/magic-inbox/SKILL.md +15 -15
- package/atris/skills/notion/SKILL.md +1 -1
- package/atris/skills/ramp/SKILL.md +7 -7
- package/atris/skills/research-search/SKILL.md +7 -7
- package/atris/skills/slides/SKILL.md +7 -7
- package/atris/skills/tidy/SKILL.md +11 -11
- package/atris/skills/wake/SKILL.md +11 -11
- package/atris/skills/writing/SKILL.md +5 -5
- package/atris/team/_template/SOUL.md +3 -3
- package/atris/team/brainstormer/MEMBER.md +2 -2
- package/atris/team/launcher/MEMBER.md +2 -2
- package/atris/team/mission-lead/MISSION.md +7 -7
- package/atris/team/opus-overnight/MISSION.md +16 -16
- package/atris/team/opus-overnight/STEERING.md +5 -5
- package/atris.md +9 -0
- package/bin/atris.js +16 -3
- package/commands/campaign.js +17 -2
- package/commands/card.js +19 -12
- package/commands/engine.js +92 -17
- package/commands/gm.js +30 -0
- package/commands/idea.js +263 -0
- package/commands/init.js +11 -2
- package/commands/land.js +39 -18
- package/commands/mission.js +4 -3
- package/commands/ninety-nine.js +288 -0
- package/commands/one-lap.js +46 -27
- package/commands/reel.js +12 -7
- package/commands/sync.js +10 -2
- package/lib/campaign-keep.js +23 -8
- package/lib/campaign-loop.js +24 -5
- package/lib/claude-boot-block.js +7 -4
- package/lib/engine-registry.js +139 -1
- package/lib/fleet.js +11 -2
- package/lib/gm-bet.js +338 -34
- package/lib/gm-phone-page.js +325 -33
- package/lib/gm-phone.js +5 -1
- package/lib/gm-season.js +14 -0
- package/lib/headless-chrome.js +39 -0
- package/lib/idea-gate.js +33 -0
- package/lib/idea-inbox.js +220 -0
- package/lib/idea-ledger.js +196 -0
- package/lib/idea-tick.js +384 -0
- package/lib/known-commands.js +1 -1
- package/lib/member-engine.js +1 -0
- package/lib/ninety-nine-sync.js +156 -0
- package/lib/ninety-nine.js +247 -0
- package/lib/opening-lines.js +94 -0
- package/lib/roster-markdown.js +39 -1
- package/lib/roster-models.js +1 -1
- package/lib/roster-swap.js +353 -0
- package/lib/spawn-env.js +29 -2
- package/lib/wish-audit.js +14 -0
- package/lib/yes-lock.js +358 -241
- package/lib/zen-apply.js +5 -3
- package/lib/zen.js +13 -1
- package/package.json +1 -1
- package/templates/business-starter/CLAUDE.md +7 -7
- package/templates/business-starter/MAP.md +1 -1
- package/templates/business-starter/TODO.md +1 -1
- package/templates/business-starter/atris.md +2 -2
- package/templates/business-starter/context/README.md +6 -6
- package/templates/business-starter/context/live-workspace.md +1 -1
- package/templates/business-starter/goals.md +1 -1
- package/templates/business-starter/instructions.md +1 -1
- package/templates/business-starter/memory.md +4 -4
- package/templates/business-starter/persona.md +3 -3
- package/templates/business-starter/policies/LESSONS.md +1 -1
- package/templates/business-starter/reports/README.md +1 -1
- package/templates/business-starter/skills/README.md +1 -1
- package/templates/business-starter/team/README.md +1 -1
- package/templates/business-starter/team/START_HERE.md +1 -1
- package/templates/business-starter/team/_template/SOUL.md +3 -3
- package/templates/business-starter/team/comms/SOUL.md +2 -2
- package/templates/business-starter/team/ops/SOUL.md +4 -4
- package/templates/business-starter/team/research/SOUL.md +3 -3
- package/templates/business-starter/wiki/index.md +1 -1
- package/templates/research-canonical/CLAUDE.md +7 -7
- package/templates/research-canonical/MAP.md +1 -1
- package/templates/research-canonical/TODO.md +1 -1
- package/templates/research-canonical/atris.md +2 -2
- package/templates/research-canonical/context/README.md +8 -8
- package/templates/research-canonical/context/live-workspace.md +1 -1
- package/templates/research-canonical/goals.md +2 -2
- package/templates/research-canonical/instructions.md +1 -1
- package/templates/research-canonical/memory.md +4 -4
- package/templates/research-canonical/persona.md +1 -1
- package/templates/research-canonical/policies/LESSONS.md +1 -1
- package/templates/research-canonical/reports/README.md +1 -1
- package/templates/research-canonical/skills/README.md +1 -1
- package/templates/research-canonical/team/README.md +1 -1
- package/templates/research-canonical/team/eval/SOUL.md +1 -1
- package/templates/research-canonical/team/experiment/SOUL.md +2 -2
- package/templates/research-canonical/team/hypothesis/SOUL.md +3 -3
- package/templates/research-canonical/team/literature/SOUL.md +2 -2
- package/templates/research-canonical/wiki/briefs/research-program.md +1 -1
- package/templates/research-canonical/wiki/index.md +1 -1
- package/utils/auth.js +1 -0
package/lib/yes-lock.js
CHANGED
|
@@ -64,6 +64,8 @@ function handoffParent(stateDir, missionId) {
|
|
|
64
64
|
|
|
65
65
|
// Yes cards for held handed-off runs: every lock record under the handoff
|
|
66
66
|
// seal, asked and not yet answered, with the job it was handed off from.
|
|
67
|
+
// The card says who wants what to go where, in plain words; the raw command
|
|
68
|
+
// stays in the lock record (command).
|
|
67
69
|
function handoffYesCards(stateDir) {
|
|
68
70
|
let files = [];
|
|
69
71
|
try { files = fs.readdirSync(path.join(stateDir, 'yes-locks')).filter(f => f.endsWith('.json')); } catch { return []; }
|
|
@@ -73,12 +75,180 @@ function handoffYesCards(stateDir) {
|
|
|
73
75
|
try { data = JSON.parse(fs.readFileSync(path.join(stateDir, 'yes-locks', name), 'utf8')); } catch { continue; }
|
|
74
76
|
if (!data || !data.mission_id || !Array.isArray(data.locks) || !data.locks.length || !data.locks.every(l => l && l.text === HANDOFF_SEAL_TEXT)) continue;
|
|
75
77
|
for (const lock of askedYesLocks(stateDir, data.mission_id)) {
|
|
76
|
-
|
|
78
|
+
const who = data.owner || 'a handed-off job';
|
|
79
|
+
cards.push({ mission_id: data.mission_id, parent_mission_id: data.parent_mission_id || null, who, step: lock.step, hash: lock.hash, command: lock.held_command || null, ask: `${who} wants to ${plainWhat(lock.held_key, who)}` });
|
|
77
80
|
}
|
|
78
81
|
}
|
|
79
82
|
return cards;
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
// What a held command would do, for the owner's yes card: what goes out and
|
|
86
|
+
// where, with no flags, settings, or paths. key is the request's record of
|
|
87
|
+
// the exact command ({ tool, args, cwd }).
|
|
88
|
+
function plainWhat(key, who = '') {
|
|
89
|
+
let cmd = {};
|
|
90
|
+
try { cmd = JSON.parse(key); } catch { return 'run a command the lock holds'; }
|
|
91
|
+
const name = path.basename(String(cmd.tool || ''));
|
|
92
|
+
const args = (Array.isArray(cmd.args) ? cmd.args : []).map(String);
|
|
93
|
+
const cwd = cmd.cwd || process.cwd();
|
|
94
|
+
if (name === 'git') {
|
|
95
|
+
const p = parseGit(args);
|
|
96
|
+
if (p.sub === 'push') return plainPush(p, cwd, who);
|
|
97
|
+
if (p.sub === 'send-pack' || p.sub === 'http-push') return 'push to another computer';
|
|
98
|
+
return 'use a setting that could let a push skip the lock';
|
|
99
|
+
}
|
|
100
|
+
// gh: said exactly, verb and target, or else where it goes and the words
|
|
101
|
+
// it names; a delete never reads as a release
|
|
102
|
+
if (name === 'gh') return describeGh(args) || `run a command that changes GitHub: "${args.join(' ')}"`;
|
|
103
|
+
if (name === 'npm' || name === 'yarn' || name === 'pnpm') {
|
|
104
|
+
const [sub = '', folder = ''] = packageWords(args).words.map(String);
|
|
105
|
+
// the package it names: a folder given to publish, else this folder
|
|
106
|
+
let pkg = 'a package';
|
|
107
|
+
for (const dir of [folder && path.resolve(cwd, folder), cwd].filter(Boolean)) {
|
|
108
|
+
try { pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).name || pkg; break; } catch { /* not a package folder */ }
|
|
109
|
+
}
|
|
110
|
+
if (PKG_PUBLISH.has(sub)) return sub.startsWith('dist-tag') ? `change which version of ${pkg} npm installs` : `publish ${pkg} to npm`;
|
|
111
|
+
if (sub === 'version') return `make a new version of ${pkg}`;
|
|
112
|
+
return `run ${name} in a way the lock does not know`;
|
|
113
|
+
}
|
|
114
|
+
if (name === 'npx' || name === 'pnpx' || name === 'bunx') return 'run a tool downloaded from npm';
|
|
115
|
+
if (name === 'atris') {
|
|
116
|
+
const [verb = ''] = positionals(args, new Set(['--account', '--as', '--from', '--to', '--channel']));
|
|
117
|
+
const read = readAtris(args);
|
|
118
|
+
if (read.cats.includes('spend')) return `spend Atris credits on ${verb}`;
|
|
119
|
+
// one phrase, the weightiest kind it touches
|
|
120
|
+
const phrase = { deploy: 'deploy a site', publish: 'publish something', merge: 'land work on master', tag: 'push a version tag', post: 'post in public', send: 'send a message' };
|
|
121
|
+
const kind = ['deploy', 'publish', 'merge', 'tag', 'post', 'send'].find(c => read.cats.includes(c));
|
|
122
|
+
if (kind) return phrase[kind];
|
|
123
|
+
if (ATRIS_REFUSED.has(verb)) return 'answer one of your asks for you';
|
|
124
|
+
return `run atris ${verb}`;
|
|
125
|
+
}
|
|
126
|
+
return 'run a command the lock holds';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// "push its job deck branch to GitHub": the branch in words, and the host
|
|
130
|
+
function plainPush(p, cwd, who) {
|
|
131
|
+
const words = positionals(p.rest, PUSH_VALUE_FLAGS);
|
|
132
|
+
const remote = words[0] || 'origin';
|
|
133
|
+
let url = '';
|
|
134
|
+
try { url = String(spawnSync('git', [...p.prefix, 'remote', 'get-url', '--push', remote], { cwd, encoding: 'utf8', timeout: 5000 }).stdout || '').trim(); } catch { /* no repo */ }
|
|
135
|
+
const host = (url.match(/^[a-z+]+:\/\/(?:[^@/]+@)?([^/:]+)/i) || url.match(/^[^@/:]+@([^:/]+):/) || [])[1] || '';
|
|
136
|
+
const where = /github\.com$/i.test(host) ? 'GitHub' : /gitlab\.com$/i.test(host) ? 'GitLab' : host ? host : (url ? 'a folder on this computer' : 'its remote');
|
|
137
|
+
if (p.rest.some(a => /^--(tags|follow-tags|mirror|all)/.test(a))) return `push tags or every branch to ${where}`;
|
|
138
|
+
const ref = String(words[1] || '');
|
|
139
|
+
const dst = ref.replace(/^\+/, '').split(':').pop().replace(/^refs\/(heads|tags)\//, '');
|
|
140
|
+
// a delete is said as a delete, with the name it removes
|
|
141
|
+
if (p.rest.some(a => a === '--delete' || a === '-d') || /^\+?:/.test(ref)) return dst ? `delete ${dst} on ${where}` : `delete something on ${where}`;
|
|
142
|
+
const force = /^\+/.test(ref) || p.rest.some(a => /^(--force|-f|--force-with-lease|--force-if-includes)(=|$)/.test(a));
|
|
143
|
+
const said = pushWords(dst, ref, where, who);
|
|
144
|
+
return force ? `force ${said}, replacing what is there` : said;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function pushWords(dst, ref, where, who) {
|
|
148
|
+
if (!dst) return `push to ${where}`;
|
|
149
|
+
if (isMainRef(dst)) return `push to master on ${where}`;
|
|
150
|
+
if (/^refs\/tags\//.test(ref.split(':').pop()) || /^v?\d+\.\d+/.test(dst)) return `push the tag ${dst} to ${where}`;
|
|
151
|
+
const first = String(who).toLowerCase();
|
|
152
|
+
const plain = dst.split('/').pop().replace(/[-_]\d{8}(?:[-_]\d{4,6})?$/, '').split(/[-_.]+/).filter((w, i) => w && !(i === 0 && w.toLowerCase() === first)).join(' ');
|
|
153
|
+
return plain ? `push its ${plain} branch to ${where}` : `push a branch to ${where}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// One command's flags as it takes them: known switches, known options with
|
|
157
|
+
// one value each, and the words left over. Anything else is null.
|
|
158
|
+
function readArgs(words, { switches = [], values = [] } = {}) {
|
|
159
|
+
const out = { flags: new Set(), opts: {}, rest: [] };
|
|
160
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
161
|
+
const w = words[i];
|
|
162
|
+
if (!w.startsWith('-') || w === '-') { out.rest.push(w); continue; }
|
|
163
|
+
const eq = w.indexOf('=');
|
|
164
|
+
const name = eq > 0 ? w.slice(0, eq) : w;
|
|
165
|
+
if (values.includes(name)) {
|
|
166
|
+
const v = eq > 0 ? w.slice(eq + 1) : words[(i += 1)];
|
|
167
|
+
if (!v || v.startsWith('-')) return null;
|
|
168
|
+
out.opts[name] = v;
|
|
169
|
+
} else if (eq < 0 && switches.includes(w)) out.flags.add(w);
|
|
170
|
+
else return null;
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const NAME = /^[\w.@+-]+$/;
|
|
176
|
+
const REPO = /^[\w.-]+\/[\w.-]+$/;
|
|
177
|
+
|
|
178
|
+
// Exact words for a held command, verb and target, or null when it
|
|
179
|
+
// cannot be said exactly. words: the tool, then its arguments.
|
|
180
|
+
function describeCommand(words) {
|
|
181
|
+
if (words[0] === 'gh') return describeGh(words.slice(1));
|
|
182
|
+
if (words[0] === 'git' && words[1] === 'push') return describePush(words.slice(2));
|
|
183
|
+
if ((words[0] === 'npm' || words[0] === 'pnpm') && words[1] === 'publish') return describePublish(words.slice(2));
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function describeGh([group, verb, ...more]) {
|
|
188
|
+
const repo = a => {
|
|
189
|
+
const r = a.opts['-R'] || a.opts['--repo'];
|
|
190
|
+
if (!r) return 'in this repo';
|
|
191
|
+
return REPO.test(r) ? `in ${r}` : null;
|
|
192
|
+
};
|
|
193
|
+
const has = (a, ...names) => names.some(n => a.flags.has(n));
|
|
194
|
+
if (group === 'release' && verb === 'delete') {
|
|
195
|
+
const a = readArgs(more, { switches: ['--yes', '-y', '--cleanup-tag'], values: ['-R', '--repo'] });
|
|
196
|
+
if (!a || a.rest.length !== 1 || !NAME.test(a.rest[0]) || !repo(a)) return null;
|
|
197
|
+
return `delete the GitHub release ${a.rest[0]}${has(a, '--cleanup-tag') ? ' and its tag' : ''} ${repo(a)}`;
|
|
198
|
+
}
|
|
199
|
+
if (group === 'release' && verb === 'create') {
|
|
200
|
+
const a = readArgs(more, { switches: ['--draft', '-d', '--prerelease', '-p', '--generate-notes'], values: ['-R', '--repo', '--title', '-t', '--notes', '-n', '--target'] });
|
|
201
|
+
if (!a || a.rest.length !== 1 || !NAME.test(a.rest[0]) || !repo(a)) return null;
|
|
202
|
+
const target = a.opts['--target'];
|
|
203
|
+
if (target && !NAME.test(target)) return null;
|
|
204
|
+
const kind = [has(a, '--draft', '-d') && ' as a draft', has(a, '--prerelease', '-p') && ' as a prerelease'].filter(Boolean).join(' and');
|
|
205
|
+
return `create the GitHub release ${a.rest[0]}${kind}${target ? ` from ${target}` : ''} ${repo(a)}`;
|
|
206
|
+
}
|
|
207
|
+
if (group === 'pr' && (verb === 'merge' || verb === 'close')) {
|
|
208
|
+
const a = readArgs(more, { switches: verb === 'merge' ? ['--merge', '-m', '--squash', '-s', '--rebase', '-r', '--delete-branch', '-d'] : ['--delete-branch', '-d'], values: ['-R', '--repo'] });
|
|
209
|
+
if (!a || a.rest.length > 1 || !repo(a)) return null;
|
|
210
|
+
const which = a.rest[0];
|
|
211
|
+
if (which && !NAME.test(which)) return null;
|
|
212
|
+
const pr = !which ? 'the pull request for this branch' : /^\d+$/.test(which) ? `pull request ${which}` : `the pull request for ${which}`;
|
|
213
|
+
const how = [has(a, '--merge', '-m') && 'merge', has(a, '--squash', '-s') && 'squash and merge', has(a, '--rebase', '-r') && 'rebase and merge'].filter(Boolean);
|
|
214
|
+
if (how.length > 1) return null;
|
|
215
|
+
const act = verb === 'close' ? 'close' : how[0] || 'merge';
|
|
216
|
+
return `${act} ${pr}${has(a, '--delete-branch', '-d') ? ' and delete its branch' : ''} ${repo(a)}`;
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function describePush(args) {
|
|
222
|
+
const a = readArgs(args, { switches: ['--tags', '--force', '-f', '--delete', '-d', '--set-upstream', '-u'] });
|
|
223
|
+
if (!a || a.rest.length > 2) return null;
|
|
224
|
+
const [remote, ref] = a.rest;
|
|
225
|
+
if (remote && !NAME.test(remote)) return null;
|
|
226
|
+
const to = remote || 'the default remote';
|
|
227
|
+
const force = a.flags.has('--force') || a.flags.has('-f');
|
|
228
|
+
const del = a.flags.has('--delete') || a.flags.has('-d');
|
|
229
|
+
if (a.flags.has('--tags')) return ref || force || del ? null : `push every tag to ${to}`;
|
|
230
|
+
if (!ref) return del ? null : `${force ? 'force push' : 'push'} this branch to ${to}${force ? ', replacing what is there' : ''}`;
|
|
231
|
+
const plus = ref.startsWith('+');
|
|
232
|
+
const parts = (plus ? ref.slice(1) : ref).split(':');
|
|
233
|
+
if (parts.length > 2 || parts.some(x => x && !/^[\w./-]+$/.test(x))) return null;
|
|
234
|
+
const [src, dst] = parts;
|
|
235
|
+
if (del || (parts.length === 2 && !src)) return del && parts.length === 2 ? null : plus || force ? null : `delete ${dst || src} on ${to}`;
|
|
236
|
+
if (parts.length === 2 && !dst) return null;
|
|
237
|
+
const strong = force || plus;
|
|
238
|
+
return `${strong ? 'force push' : 'push'} ${dst ? `${src} to ${dst} on ${to}` : `${src} to ${to}`}${strong ? ', replacing what is there' : ''}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function describePublish(args) {
|
|
242
|
+
const a = readArgs(args, { values: ['--tag', '--access'] });
|
|
243
|
+
if (!a || a.rest.length > 1) return null;
|
|
244
|
+
const access = a.opts['--access'];
|
|
245
|
+
if (access && access !== 'public' && access !== 'restricted') return null;
|
|
246
|
+
const tag = a.opts['--tag'];
|
|
247
|
+
if (tag && !NAME.test(tag)) return null;
|
|
248
|
+
const extra = [tag && `under the ${tag} tag`, access].filter(Boolean).join(', ');
|
|
249
|
+
return `publish ${a.rest.length ? `the package in "${a.rest[0]}"` : 'this package'} to npm${extra ? ` ${extra}` : ''}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
82
252
|
// ---- what an owner step locks ------------------------------------------------------
|
|
83
253
|
|
|
84
254
|
const STEP_WORDS = [
|
|
@@ -133,9 +303,9 @@ function writeState(stateDir, missionId, data) {
|
|
|
133
303
|
fs.renameSync(tmp, file);
|
|
134
304
|
}
|
|
135
305
|
|
|
136
|
-
function writeYesLocks(stateDir, missionId, { betId = null, seal = [], parentMissionId = null } = {}) {
|
|
306
|
+
function writeYesLocks(stateDir, missionId, { betId = null, seal = [], parentMissionId = null, owner = null } = {}) {
|
|
137
307
|
const locks = normalizeSeal(seal).map(s => ({ ...s, asked_at: null, approved_at: null, consumed_at: null }));
|
|
138
|
-
writeState(stateDir, missionId, { mission_id: missionId, bet_id: betId, ...(parentMissionId ? { parent_mission_id: parentMissionId } : {}), locks });
|
|
308
|
+
writeState(stateDir, missionId, { mission_id: missionId, bet_id: betId, ...(parentMissionId ? { parent_mission_id: parentMissionId } : {}), ...(owner ? { owner } : {}), locks });
|
|
139
309
|
return locks;
|
|
140
310
|
}
|
|
141
311
|
|
|
@@ -190,7 +360,7 @@ function claimFile(stateDir, missionId, lock, request) {
|
|
|
190
360
|
function saveLocks(stateDir, missionId, locks, betId) {
|
|
191
361
|
const prior = readState(stateDir, missionId);
|
|
192
362
|
const rows = locks.map(l => ({ step: l.step, text: l.text, categories: l.categories, requests: rowRequests(l) }));
|
|
193
|
-
writeState(stateDir, missionId, { mission_id: missionId, bet_id: (prior && prior.bet_id) || betId || null, ...(prior && prior.parent_mission_id ? { parent_mission_id: prior.parent_mission_id } : {}), locks: rows });
|
|
363
|
+
writeState(stateDir, missionId, { mission_id: missionId, bet_id: (prior && prior.bet_id) || betId || null, ...(prior && prior.parent_mission_id ? { parent_mission_id: prior.parent_mission_id } : {}), ...(prior && prior.owner ? { owner: prior.owner } : {}), locks: rows });
|
|
194
364
|
}
|
|
195
365
|
|
|
196
366
|
// The yes covers the one command on the card the owner answered: the waiting
|
|
@@ -553,11 +723,15 @@ const ONTO_BRANCH = new Set(['merge', 'commit', 'cherry-pick', 'rebase', 'am', '
|
|
|
553
723
|
|
|
554
724
|
function readGit(args, ctx, depth = 0) {
|
|
555
725
|
const p = parseGit(args);
|
|
556
|
-
const risky = [...p.configs.map(configKey), ...ctx.envConfigKeys()].find(k => RISKY_GIT_CONFIG.test(k));
|
|
557
|
-
if (risky) return refused(`git config ${risky} can change where a push goes, so it is closed while owner steps wait`);
|
|
558
726
|
const repo = ctx.forRepo(p.prefix);
|
|
559
727
|
const sub = p.sub;
|
|
560
728
|
if (!sub) return CLEAR;
|
|
729
|
+
// config that can reroute matters to a push, or a command that reaches a
|
|
730
|
+
// network remote; local work passes whatever config it runs with
|
|
731
|
+
const risky = [...p.configs.map(configKey), ...ctx.envConfigKeys()].find(k => RISKY_GIT_CONFIG.test(k));
|
|
732
|
+
// an alias given with -c can turn any command into a push
|
|
733
|
+
const reaches = ['push', 'send-pack', 'http-push'].includes(sub) || /^alias\./i.test(risky || '') || talksToNetwork(sub, p.rest, repo);
|
|
734
|
+
if (risky && reaches) return refused(`git config ${risky} can change where a push goes, so it is closed while owner steps wait`);
|
|
561
735
|
// git never applies an alias named after one of its own commands, so
|
|
562
736
|
// neither does this: a stored alias.push is ignored and push is push
|
|
563
737
|
const alias = isGitBuiltin(sub, repo) ? null : repo.alias(sub);
|
|
@@ -642,6 +816,7 @@ function gitContext(gitBin, cwd, env = process.env, prefix = []) {
|
|
|
642
816
|
config: key => out(['config', '--get', key]) || '',
|
|
643
817
|
configBool: key => out(['config', '--bool', '--get', key]) || '',
|
|
644
818
|
configAll: key => (out(['config', '--get-all', key]) || '').split('\n').filter(Boolean),
|
|
819
|
+
remoteNames: () => (out(['remote']) || '').split('\n').filter(Boolean),
|
|
645
820
|
// with insteadOf, pushInsteadOf and pushurl applied, as git push sees them
|
|
646
821
|
remoteUrls: (remote, { push = false } = {}) => (out(['remote', 'get-url', ...(push ? ['--push'] : []), '--all', remote]) || '').split('\n').filter(Boolean),
|
|
647
822
|
alias: name => out(['config', '--get', `alias.${name}`]),
|
|
@@ -656,47 +831,44 @@ function gitContext(gitBin, cwd, env = process.env, prefix = []) {
|
|
|
656
831
|
};
|
|
657
832
|
}
|
|
658
833
|
|
|
659
|
-
// ---- the handoff seal
|
|
834
|
+
// ---- the handoff seal --------------------------------------------------------------
|
|
660
835
|
//
|
|
661
|
-
// A run woken by a handoff
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
// name
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
836
|
+
// A run woken by a handoff guards git push, not every git command. Nothing
|
|
837
|
+
// local publishes anything, and landing goes through origin/master, not
|
|
838
|
+
// local master; the engines and test suites run far more git than any list
|
|
839
|
+
// can name (3.74.0 to 3.74.4 held 546 harmless commands). What stays guarded
|
|
840
|
+
// is what can reach a network remote: a push, and the -c or environment that
|
|
841
|
+
// could run a push past the wrapper, since git runs a program named in its
|
|
842
|
+
// config with its own folder first on PATH. gh and npm, yarn, pnpm keep their
|
|
843
|
+
// lists below, and atris holds its outward and spend verbs. A bet's owner
|
|
844
|
+
// steps still lock by kind (analyze above).
|
|
669
845
|
|
|
670
846
|
const pass = (args = null) => ({ ok: true, args });
|
|
671
847
|
const hold = why => ({ ok: false, why });
|
|
672
848
|
const isHandoffSeal = sealed => sealed.some(s => s.text === HANDOFF_SEAL_TEXT);
|
|
673
849
|
const PUSH_HINT = 'a branch push runs without a yes as: git push origin <branch>';
|
|
674
850
|
|
|
675
|
-
// git reads, plus local work that cannot move a ref other than the branch it is on
|
|
676
|
-
const GIT_SEALED_READS = new Set(['status', 'diff', 'log', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'rev-list', 'merge-base', 'cat-file', 'ls-tree', 'show-ref', 'for-each-ref', 'shortlog', 'check-ignore', 'help', 'version']);
|
|
677
|
-
const GIT_SEALED_LOCAL = new Set(['add', 'rm', 'mv', 'restore']);
|
|
678
|
-
const GIT_SEALED_GLOBALS = new Set(['--no-pager', '-P', '--no-optional-locks', '--literal-pathspecs', '--version', '--help', '-h']);
|
|
679
851
|
// the shape of a branch name git accepts, without the corners that read as something else
|
|
680
852
|
const BRANCH_NAME = /^(?!-)(?!\.)(?!.*\.\.)(?!.*\/\/)(?!.*\/\.)(?!.*@\{)(?!.*\.lock(?:\/|$))[A-Za-z0-9._/-]+(?<![./])$/;
|
|
681
853
|
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
return
|
|
854
|
+
// -c a woken run may pass: a value that only turns something off, or a key
|
|
855
|
+
// that only changes how output looks or who a commit is by. The engines
|
|
856
|
+
// harden their own git calls this way. A -c that names a program, path,
|
|
857
|
+
// command, alias, helper, pager, editor, or route is held, and so are the
|
|
858
|
+
// keys whose value is a name, URL, or refspec (alias, remote, url, push,
|
|
859
|
+
// include, credential, branch routing), where even an empty value can
|
|
860
|
+
// reroute (url.<x>.insteadOf=).
|
|
861
|
+
const GIT_DISPLAY_KEYS = new Set(['format.pretty', 'log.showsignature', 'status.showuntrackedfiles', 'core.quotepath', 'core.safecrlf', 'safe.barerepository', 'color.ui', 'user.name', 'user.email']);
|
|
862
|
+
const GIT_ROUTE_KEYS = /^(alias|remote|url|push|include|includeif|credential)\.|^branch\..+\.(remote|pushremote|merge)$/;
|
|
863
|
+
|
|
864
|
+
function sealedGitConfigOk(pair) {
|
|
865
|
+
const eq = String(pair).indexOf('=');
|
|
866
|
+
const key = (eq < 0 ? String(pair) : String(pair).slice(0, eq)).trim().toLowerCase();
|
|
867
|
+
const value = eq < 0 ? 'true' : String(pair).slice(eq + 1).trim();
|
|
868
|
+
if (GIT_DISPLAY_KEYS.has(key)) return true;
|
|
869
|
+
if (key === 'core.hookspath') return value === '/dev/null';
|
|
870
|
+
if (GIT_ROUTE_KEYS.test(key)) return false;
|
|
871
|
+
return /^(|false|never|0|no|off)$/i.test(value);
|
|
700
872
|
}
|
|
701
873
|
|
|
702
874
|
// The same, set through the environment. GIT_CONFIG_NOSYSTEM only turns the
|
|
@@ -706,197 +878,156 @@ function gitEnvProblem(env) {
|
|
|
706
878
|
return names.find(k => (/^GIT_CONFIG/.test(k) && k !== 'GIT_CONFIG_NOSYSTEM') || k === 'GIT_EXEC_PATH') || null;
|
|
707
879
|
}
|
|
708
880
|
|
|
709
|
-
//
|
|
710
|
-
|
|
711
|
-
// and no name that is a symbolic ref to master.
|
|
712
|
-
function targetProblem(name, repo) {
|
|
713
|
-
const n = String(name === undefined ? '' : name);
|
|
714
|
-
if (n === 'HEAD' || n.startsWith('refs/') || !BRANCH_NAME.test(n)) return `${n || '(no name)'} is not a plain branch name`;
|
|
715
|
-
if (isMainRef(n)) return `${n} is master`;
|
|
716
|
-
const full = typeof repo.fullRefName === 'function' ? repo.fullRefName(n) : '';
|
|
717
|
-
if (full && isMainRef(full)) return `${n} resolves to master`;
|
|
718
|
-
return null;
|
|
719
|
-
}
|
|
881
|
+
// git prints these and stops without running the command after them
|
|
882
|
+
const GIT_PRINT_AND_EXIT = new Set(['--exec-path', '--version', '--help', '-h', '--html-path', '--man-path', '--info-path']);
|
|
720
883
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
if (rest[0] !== 'branch') return pass();
|
|
737
|
-
const why = targetProblem(positionals(rest.slice(1), new Set())[0], repo);
|
|
738
|
-
return why ? hold(`git stash branch: ${why}`) : pass();
|
|
739
|
-
}
|
|
740
|
-
case 'checkout': case 'switch': return sealedSwitch(p.sub, rest, repo);
|
|
741
|
-
case 'branch': return sealedBranch(rest, repo);
|
|
742
|
-
case 'config': return sealedConfig(rest);
|
|
743
|
-
case 'remote': {
|
|
744
|
-
const op = rest.filter(a => a !== '-v' && a !== '--verbose')[0] || '';
|
|
745
|
-
return op === '' || op === 'get-url' ? pass() : hold(`git remote ${op} changes where git sends things`);
|
|
884
|
+
// The options before the command. -C, --git-dir, --work-tree, and
|
|
885
|
+
// --namespace pick the repo; a -c that fails the reading above, --config-env,
|
|
886
|
+
// and --exec-path=<dir> are noted as bad, which matters only for a command
|
|
887
|
+
// that talks to a network remote.
|
|
888
|
+
function sealedGitPrefix(args) {
|
|
889
|
+
const prefix = [];
|
|
890
|
+
let bad = null;
|
|
891
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
892
|
+
const a = args[i];
|
|
893
|
+
if (['-C', '-c', '--git-dir', '--work-tree', '--namespace'].includes(a)) {
|
|
894
|
+
const value = String(args[i + 1] === undefined ? '' : args[i + 1]);
|
|
895
|
+
if (a === '-c' && !sealedGitConfigOk(value)) bad = bad || `-c ${value}`;
|
|
896
|
+
prefix.push(a, value);
|
|
897
|
+
i += 1;
|
|
898
|
+
continue;
|
|
746
899
|
}
|
|
747
|
-
|
|
748
|
-
|
|
900
|
+
if (/^--config-env(=|$)/.test(a) || a.startsWith('--exec-path=')) bad = bad || a;
|
|
901
|
+
if (a.startsWith('-')) { prefix.push(a); continue; }
|
|
902
|
+
return { prefix, bad, sub: a, rest: args.slice(i + 1) };
|
|
749
903
|
}
|
|
904
|
+
return { prefix, bad, sub: '', rest: [] };
|
|
750
905
|
}
|
|
751
906
|
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
if (pushUrls.length !== 1 || pushUrls[0] !== fetchUrls[0]) return hold('origin pushes somewhere other than where it fetches from');
|
|
778
|
-
if (repo.configBool('remote.origin.mirror') === 'true') return hold('origin is set up as a mirror, which pushes every branch and tag');
|
|
779
|
-
const prefix = p.prefix;
|
|
780
|
-
return pass([...prefix, '-c', 'push.followTags=false', '-c', 'remote.origin.mirror=false', '-c', 'push.pushOption=', 'push', '--no-follow-tags',
|
|
781
|
-
...(upstream ? ['--set-upstream'] : []), ...quiet, 'origin', `${head ? 'HEAD' : `refs/heads/${branch}`}:refs/heads/${branch}`]);
|
|
907
|
+
// Does this git command talk to a network remote? Pushes, fetches, pulls,
|
|
908
|
+
// ls-remote, and clones to a remote on disk are local work. When the remote
|
|
909
|
+
// cannot be read, it counts as a network one.
|
|
910
|
+
const GIT_REMOTE_SUBS = new Set(['push', 'send-pack', 'http-push', 'fetch', 'pull', 'ls-remote', 'clone']);
|
|
911
|
+
const GIT_REMOTE_VALUE_FLAGS = new Set(['--depth', '--deepen', '--shallow-since', '--shallow-exclude', '-j', '--jobs', '--upload-pack', '--negotiation-tip', '--refmap', '--filter', '-o', '--server-option', '--push-option', '--receive-pack', '--exec', '-s', '--strategy', '-X', '--strategy-option', '-b', '--branch', '--origin', '--template', '--reference', '--separate-git-dir', '-c', '--config']);
|
|
912
|
+
|
|
913
|
+
function talksToNetwork(sub, rest, repo) {
|
|
914
|
+
if (!GIT_REMOTE_SUBS.has(sub)) return false;
|
|
915
|
+
if (sub === 'http-push') return true;
|
|
916
|
+
if (typeof repo.remoteUrls !== 'function') return true;
|
|
917
|
+
const isPush = sub === 'push' || sub === 'send-pack';
|
|
918
|
+
const repoFlag = rest.find(a => /^--repo=/.test(String(a)));
|
|
919
|
+
const words = positionals(rest, GIT_REMOTE_VALUE_FLAGS);
|
|
920
|
+
const branch = repo.currentBranch();
|
|
921
|
+
const targets = sub === 'fetch' && rest.some(a => a === '--all' || a === '--multiple')
|
|
922
|
+
? (repo.remoteNames ? repo.remoteNames() : ['origin'])
|
|
923
|
+
: [repoFlag ? repoFlag.slice('--repo='.length) : words[0]
|
|
924
|
+
|| (isPush && (repo.config(`branch.${branch}.pushRemote`) || repo.config('remote.pushDefault')))
|
|
925
|
+
|| repo.config(`branch.${branch}.remote`) || 'origin'];
|
|
926
|
+
return targets.some((target) => {
|
|
927
|
+
const urls = repo.remoteUrls(target, { push: isPush });
|
|
928
|
+
if (urls.length) return !urls.every(isLocalUrl);
|
|
929
|
+
// no URL read: a path is local; a remote name git could not read counts as network
|
|
930
|
+
return !/^(file:\/\/|\/|\.\.?(\/|$)|~)/.test(String(target));
|
|
931
|
+
});
|
|
782
932
|
}
|
|
783
933
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
if (words.slice(1).some(w => /[:+*^]/.test(w))) return hold('a fetch refspec can write a local branch');
|
|
796
|
-
// origin's own fetch lines may only fill remote-tracking refs and tags
|
|
797
|
-
for (const spec of repo.configAll('remote.origin.fetch')) {
|
|
798
|
-
const dest = spec.includes(':') ? spec.split(':').pop() : '';
|
|
799
|
-
if (dest && !/^refs\/(remotes|tags)\//.test(dest)) return hold('origin is set up to fetch into local branches');
|
|
934
|
+
function sealedGit(args, ctx, depth = 0) {
|
|
935
|
+
const p = sealedGitPrefix(args);
|
|
936
|
+
if (!p.sub || p.prefix.some(a => GIT_PRINT_AND_EXIT.has(a))) return pass();
|
|
937
|
+
// the lock's own questions leave out options that could break them
|
|
938
|
+
const repo = ctx.forRepo(p.prefix.filter(a => !GIT_PRINT_AND_EXIT.has(a) && !/^--(config-env|exec-path=)/.test(a)));
|
|
939
|
+
// an alias that turns into a push is a push; one that runs a shell could
|
|
940
|
+
// run a git that skips the wrapper
|
|
941
|
+
const alias = isGitBuiltin(p.sub, repo) ? null : repo.alias(p.sub);
|
|
942
|
+
if (alias !== null && alias !== undefined) {
|
|
943
|
+
if (depth >= 5 || alias.startsWith('!')) return hold(`git ${p.sub} is an alias that runs a shell command`);
|
|
944
|
+
return sealedGit([...p.prefix, ...alias.trim().split(/\s+/), ...p.rest], ctx, depth + 1);
|
|
800
945
|
}
|
|
801
|
-
|
|
946
|
+
// local work passes in any folder, whatever config the test suite or
|
|
947
|
+
// engine runs git with; only a command that reaches a network remote is read
|
|
948
|
+
if (!talksToNetwork(p.sub, p.rest, repo)) return pass();
|
|
949
|
+
if (p.bad) return hold(`git ${p.bad} can run a program that skips the wrapper`);
|
|
950
|
+
const envName = gitEnvProblem(typeof ctx.env === 'function' ? ctx.env() : {});
|
|
951
|
+
if (envName) return hold(`git with ${envName} set can run a program that skips the wrapper`);
|
|
952
|
+
// the low-level ways to update a remote are push too
|
|
953
|
+
if (['send-pack', 'http-push'].includes(p.sub)) return hold(`git ${p.sub} updates a remote without git push`);
|
|
954
|
+
if (p.sub !== 'push') return pass();
|
|
955
|
+
return sealedPush(p, repo);
|
|
802
956
|
}
|
|
803
957
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
function
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
let paths = false;
|
|
811
|
-
for (let i = 0; i < rest.length; i += 1) {
|
|
812
|
-
const a = rest[i];
|
|
813
|
-
if (a === '--') { paths = true; break; }
|
|
814
|
-
const inline = a.match(/^(--orphan|--create|--force-create)=(.*)$/);
|
|
815
|
-
if (create.has(a) || inline) {
|
|
816
|
-
const name = inline ? inline[2] : String(rest[i + 1] === undefined ? '' : rest[i + 1]);
|
|
817
|
-
if (!inline) i += 1;
|
|
818
|
-
const why = targetProblem(name, repo);
|
|
819
|
-
if (why) return hold(`git ${sub} ${a}: ${why}`);
|
|
820
|
-
creating = true;
|
|
821
|
-
continue;
|
|
822
|
-
}
|
|
823
|
-
if (SWITCH_FLAGS.has(a) || /^--(conflict|track|pathspec-from-file)=/.test(a)) continue;
|
|
824
|
-
if (a.startsWith('-') && a !== '-') return hold(`git ${sub} ${a} is not an option a handed-off job may use`);
|
|
825
|
-
words.push(a);
|
|
826
|
-
}
|
|
827
|
-
if (creating || !words.length) return pass();
|
|
828
|
-
const target = words[0];
|
|
829
|
-
// a checkout with paths restores files from the named commit; it moves no branch
|
|
830
|
-
if (sub === 'checkout' && (paths || words.length > 1)) return pass();
|
|
831
|
-
const why = targetProblem(target, repo);
|
|
832
|
-
return why ? hold(`git ${sub} ${target}: ${why}`) : pass();
|
|
958
|
+
// a remote that is a folder on this computer: a path, a file:// URL, or a
|
|
959
|
+
// remote whose every push URL is one
|
|
960
|
+
function isLocalUrl(url) {
|
|
961
|
+
const u = String(url || '');
|
|
962
|
+
if (/^file:\/\//.test(u) || /^(\/|\.\.?(\/|$)|~)/.test(u)) return true;
|
|
963
|
+
return !u.includes(':');
|
|
833
964
|
}
|
|
834
965
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
966
|
+
const PUSH_FLAGS_OK = new Set(['-u', '--set-upstream', '-q', '--quiet', '-v', '--verbose', '--progress', '--no-progress', '-f', '--force', '--force-if-includes', '--no-force-if-includes', '--no-verify', '--verify', '-n', '--dry-run', '--porcelain', '--atomic', '--no-atomic', '--thin', '--no-thin', '--no-follow-tags']);
|
|
967
|
+
|
|
968
|
+
// A push to a remote on disk runs as written: tests push tags and master to
|
|
969
|
+
// scratch repos. To a network remote it runs only as branch pushes that are
|
|
970
|
+
// not master, with followTags, the remote's mirror setting, and push options
|
|
971
|
+
// off, and each refspec written out in full, so the remote's own push lines
|
|
972
|
+
// cannot send it anywhere else. Tags, master, mirror, --all, --prune, a
|
|
973
|
+
// matching push (":"), and deleting master are held.
|
|
974
|
+
function sealedPush(p, repo) {
|
|
975
|
+
const words = positionals(p.rest, PUSH_VALUE_FLAGS);
|
|
976
|
+
const repoFlag = p.rest.find(a => /^--repo=/.test(a));
|
|
977
|
+
const named = repoFlag ? repoFlag.slice('--repo='.length) : words[0];
|
|
978
|
+
const remote = named || repo.config(`branch.${repo.currentBranch()}.pushRemote`) || repo.config('remote.pushDefault') || repo.config(`branch.${repo.currentBranch()}.remote`) || 'origin';
|
|
979
|
+
const configured = repo.remoteUrls(remote, { push: true });
|
|
980
|
+
if (configured.length ? configured.every(isLocalUrl) : isLocalUrl(remote)) return pass();
|
|
981
|
+
if (isGitTrue(repo.configBool(`remote.${remote}.mirror`))) return hold(`${remote} is set up as a mirror, which pushes every branch and tag`);
|
|
982
|
+
const kept = [];
|
|
840
983
|
let deleting = false;
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
if (a
|
|
845
|
-
if (LIST_VALUE.has(a)) { listing = true; i += 1; continue; }
|
|
846
|
-
if (/^--(contains|no-contains|merged|no-merged|points-at|format|sort)=/.test(a)) { listing = true; continue; }
|
|
847
|
-
if (a === '-l' || a === '--list' || a === '--show-current') { listing = true; continue; }
|
|
848
|
-
if (LIST.has(a) || CREATE.has(a) || /^--(color|column|abbrev|track)=/.test(a)) continue;
|
|
849
|
-
if (a === '-d' || a === '-D' || a === '--delete') { deleting = true; continue; }
|
|
850
|
-
if (a.startsWith('-')) return hold(`git branch ${a} is not an option a handed-off job may use`);
|
|
851
|
-
words.push(a);
|
|
984
|
+
for (const a of p.rest) {
|
|
985
|
+
if (a === '-d' || a === '--delete') { deleting = true; continue; }
|
|
986
|
+
if (PUSH_FLAGS_OK.has(a) || /^--force-with-lease(=|$)/.test(a)) { kept.push(a); continue; }
|
|
987
|
+
if (a.startsWith('-')) return hold(`git push ${a} to a network remote can move master or tags; ${PUSH_HINT}`);
|
|
852
988
|
}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
989
|
+
const specs = repoFlag ? words : words.slice(1);
|
|
990
|
+
const current = repo.currentBranch();
|
|
991
|
+
const out = [];
|
|
992
|
+
if (!specs.length) {
|
|
993
|
+
// no refspec: the branch's own push target, as one branch
|
|
994
|
+
const dest = String(repo.pushDest() || '').replace(/^[^/]+\//, '');
|
|
995
|
+
if (!dest || deleting) return hold(`git push with no branch named could push more than one; ${PUSH_HINT}`);
|
|
996
|
+
specs.push(`HEAD:${dest}`);
|
|
856
997
|
}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
return hold('git config writes change what git does');
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
function sealedWorktree(rest, repo) {
|
|
873
|
-
const [op = '', ...more] = rest;
|
|
874
|
-
if (op === 'list') return pass();
|
|
875
|
-
if (op !== 'add') return hold(`git worktree ${op} changes another working folder`);
|
|
876
|
-
const words = [];
|
|
877
|
-
let named = false;
|
|
878
|
-
let detached = false;
|
|
879
|
-
for (let i = 0; i < more.length; i += 1) {
|
|
880
|
-
const a = more[i];
|
|
881
|
-
if (a === '-b' || a === '-B') {
|
|
882
|
-
const name = String(more[i + 1] === undefined ? '' : more[i + 1]);
|
|
883
|
-
i += 1;
|
|
884
|
-
const why = targetProblem(name, repo);
|
|
885
|
-
if (why) return hold(`git worktree add ${a}: ${why}`);
|
|
886
|
-
named = true;
|
|
887
|
-
continue;
|
|
998
|
+
for (const spec of specs) {
|
|
999
|
+
const force = spec.startsWith('+') ? '+' : '';
|
|
1000
|
+
const body = spec.replace(/^\+/, '');
|
|
1001
|
+
if (body === ':' || body.includes('*')) return hold(`git push ${spec} pushes many branches at once; ${PUSH_HINT}`);
|
|
1002
|
+
let src;
|
|
1003
|
+
let dst;
|
|
1004
|
+
if (deleting) { src = ''; dst = body; } else if (body.includes(':')) {
|
|
1005
|
+
[src, dst] = [body.slice(0, body.indexOf(':')), body.slice(body.indexOf(':') + 1)];
|
|
1006
|
+
} else if (body === 'HEAD' || body === '@') { src = 'HEAD'; dst = current; } else {
|
|
1007
|
+
if (body.startsWith('refs/tags/') || (repo.isTag(body) && !repo.isBranch(body))) return hold(`git push ${spec} pushes a tag; ${PUSH_HINT}`);
|
|
1008
|
+
dst = body.replace(/^refs\/heads\//, '');
|
|
1009
|
+
src = `refs/heads/${dst}`;
|
|
888
1010
|
}
|
|
889
|
-
|
|
890
|
-
if (
|
|
891
|
-
if (
|
|
892
|
-
|
|
893
|
-
|
|
1011
|
+
const name = String(dst || '').replace(/^refs\/heads\/(?=.)/, '');
|
|
1012
|
+
if (String(dst || '').startsWith('refs/') && !String(dst).startsWith('refs/heads/')) return hold(`git push ${spec} does not go to a branch; ${PUSH_HINT}`);
|
|
1013
|
+
if (isMainRef(name)) return hold(`git push to master lands on master; ${PUSH_HINT}`);
|
|
1014
|
+
const why = targetProblem(name, repo);
|
|
1015
|
+
if (why) return hold(`git push ${spec}: ${why}; ${PUSH_HINT}`);
|
|
1016
|
+
out.push(`${force}${src}:refs/heads/${name}`);
|
|
894
1017
|
}
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1018
|
+
const mirrorOff = /^[A-Za-z0-9._-]+$/.test(remote) ? ['-c', `remote.${remote}.mirror=false`] : [];
|
|
1019
|
+
return pass([...p.prefix, '-c', 'push.followTags=false', ...mirrorOff, '-c', 'push.pushOption=', 'push', '--no-follow-tags', ...kept, remote, ...out]);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// A ref a push writes must be a plain branch name that is not master and
|
|
1023
|
+
// does not resolve to master: no @{-1}, @, HEAD~, ^, refs/.
|
|
1024
|
+
function targetProblem(name, repo) {
|
|
1025
|
+
const n = String(name === undefined ? '' : name);
|
|
1026
|
+
if (n === 'HEAD' || n.startsWith('refs/') || !BRANCH_NAME.test(n)) return `${n || '(no name)'} is not a plain branch name`;
|
|
1027
|
+
if (isMainRef(n)) return `${n} is master`;
|
|
1028
|
+
const full = typeof repo.fullRefName === 'function' ? repo.fullRefName(n) : '';
|
|
1029
|
+
if (full && isMainRef(full)) return `${n} resolves to master`;
|
|
1030
|
+
return null;
|
|
900
1031
|
}
|
|
901
1032
|
|
|
902
1033
|
// gh: reads, plus opening, reading, and commenting on a PR
|
|
@@ -942,35 +1073,19 @@ function sealedPackage(name, args) {
|
|
|
942
1073
|
return PKG_SEALED[name].includes(sub) ? pass() : hold(`${name} ${sub || '(no command)'} is not on the list a handed-off job may run`);
|
|
943
1074
|
}
|
|
944
1075
|
|
|
945
|
-
// atris
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
log: sub => sub !== 'sync',
|
|
959
|
-
youtube: (sub, args) => sub === 'search' && !args.includes('--paid'),
|
|
960
|
-
help: true,
|
|
961
|
-
version: true,
|
|
962
|
-
};
|
|
963
|
-
|
|
964
|
-
function sealedAtris(args, missionId) {
|
|
965
|
-
const [cmd = '', sub = ''] = args;
|
|
966
|
-
if (args.length === 1 && ['--version', '-v', '--help', '-h'].includes(cmd)) return pass();
|
|
967
|
-
const allowed = Object.prototype.hasOwnProperty.call(ATRIS_SEALED, cmd) ? ATRIS_SEALED[cmd] : null;
|
|
968
|
-
if (!allowed) return hold(`atris ${cmd} is not on the list a handed-off job may run`);
|
|
969
|
-
const subOk = allowed === true || ['help', '--help', '-h'].includes(sub)
|
|
970
|
-
|| (typeof allowed === 'function' ? allowed(sub, args) : allowed.includes(sub));
|
|
971
|
-
if (!subOk) return hold(`atris ${cmd} ${sub} is not on the list a handed-off job may run`);
|
|
972
|
-
// a tick of another job would run that job's work without this seal
|
|
973
|
-
if (cmd === 'mission' && sub === 'tick' && positionals(args.slice(2), new Set())[0] !== missionId) return hold('atris mission tick runs only this job');
|
|
1076
|
+
// atris in a woken run: every verb runs except the outward and spend ones,
|
|
1077
|
+
// read the same way a bet reads them (readAtris), plus these by name and
|
|
1078
|
+
// accepting a task, which lands it.
|
|
1079
|
+
const ATRIS_SEALED_HELD = new Set(['spend', 'publish', 'deploy', 'release', 'land', 'merge', 'accept', 'business', 'site', 'pay', 'feed']);
|
|
1080
|
+
const TASK_ACCEPT = new Set(['accept', 'auto-accept-certified', 'auto-accept', 'accept-group']);
|
|
1081
|
+
|
|
1082
|
+
function sealedAtris(args) {
|
|
1083
|
+
const [cmd = '', sub = ''] = positionals(args, new Set(['--account', '--as', '--from', '--to', '--channel']));
|
|
1084
|
+
if (ATRIS_SEALED_HELD.has(cmd)) return hold(`atris ${cmd} goes out, lands, or spends`);
|
|
1085
|
+
if (cmd === 'task' && (TASK_ACCEPT.has(sub) || args.some(a => a === '--accept' || a === '--auto-accept'))) return hold('accepting a task lands it');
|
|
1086
|
+
const read = readAtris(args);
|
|
1087
|
+
if (read.cats.length) return hold(`atris ${cmd} would ${read.cats.map(c => CATEGORY_WORDS[c]).join(' and ')}`);
|
|
1088
|
+
if (read.refuse && ATRIS_REFUSED.has(cmd)) return hold(read.refuse);
|
|
974
1089
|
return pass();
|
|
975
1090
|
}
|
|
976
1091
|
|
|
@@ -979,7 +1094,7 @@ function sealedVerdict(tool, args, ctx, missionId) {
|
|
|
979
1094
|
if (name === 'git') return sealedGit(args, ctx);
|
|
980
1095
|
if (name === 'gh') return sealedGh(args);
|
|
981
1096
|
if (name === 'npm' || name === 'yarn' || name === 'pnpm') return sealedPackage(name, args);
|
|
982
|
-
if (name === 'atris') return sealedAtris(args
|
|
1097
|
+
if (name === 'atris') return sealedAtris(args);
|
|
983
1098
|
return hold(`${name} is not on the list a handed-off job may run`);
|
|
984
1099
|
}
|
|
985
1100
|
|
|
@@ -1065,6 +1180,8 @@ function check({ stateDir, missionId, seal, tool, args = [], gitBin = null, cwd
|
|
|
1065
1180
|
function consume() {}
|
|
1066
1181
|
|
|
1067
1182
|
module.exports = {
|
|
1183
|
+
describeCommand,
|
|
1184
|
+
plainWhat,
|
|
1068
1185
|
HELD_EXIT,
|
|
1069
1186
|
handoffParent,
|
|
1070
1187
|
handoffSeal,
|