copilot-operator 0.1.20 → 0.1.22
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/src/api/operator.controller.js +49 -0
- package/dist/src/api/operator.service.js +179 -7
- package/dist/src/orchestrator/taskRunner.js +14 -2
- package/dist/src/plan/systemGuide.js +119 -15
- package/dist/src/session/exports.js +70 -1
- package/dist/src/session/runLog.js +60 -0
- package/dist/src/vcs/git.js +11 -1
- package/dist/src/vcs/inputs.js +26 -2
- package/dist/src/vcs/prepareFromRemote.js +263 -0
- package/dist/src/vcs/runPreflight.js +286 -0
- package/dist/src/vcs/taskVcs.js +42 -6
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/549-5f90780046c1c24f.js +1 -0
- package/dist/web/_next/static/chunks/867-79f07bebe3084811.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-cdc730adea753446.js +1 -0
- package/dist/web/_next/static/chunks/app/page-0a861b38da0a1c51.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-185755ada66e2930.js +1 -0
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +10 -10
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +10 -10
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +10 -10
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +10 -10
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +10 -10
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +10 -10
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +10 -10
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +10 -10
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +10 -10
- package/package.json +150 -148
- package/dist/web/_next/static/chunks/549-5d7c845fb0a27dbe.js +0 -1
- package/dist/web/_next/static/chunks/867-d9c54f7a2b285b3f.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-fd819bbfa7f33982.js +0 -1
- package/dist/web/_next/static/chunks/app/page-409e37e208dc12e5.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-99f9153fd75078d0.js +0 -1
- /package/dist/web/_next/static/{P0lpNIJvdSO3yuWRpIPQf → 9Ze8aagkvT-WLfD4KCKKS}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{P0lpNIJvdSO3yuWRpIPQf → 9Ze8aagkvT-WLfD4KCKKS}/_ssgManifest.js +0 -0
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { existsSync } from 'node:fs';
|
|
22
22
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
|
+
import { browserAfterPreflight, readRunLog } from './runLog.js';
|
|
24
25
|
import { TASK_FIELD_ON_RERUN } from './store.js';
|
|
25
26
|
import { botVersion } from '../config/version.js';
|
|
26
27
|
import { withoutPersona } from '../plan/importPlan.js';
|
|
@@ -329,7 +330,12 @@ export function exportMachine(cfg) {
|
|
|
329
330
|
node: process.versions.node,
|
|
330
331
|
platform: process.platform,
|
|
331
332
|
cwd: cfg.resolved.cwd,
|
|
332
|
-
|
|
333
|
+
/*
|
|
334
|
+
* The mode in Settings is only what a run is offered first; each run chooses its own, recorded per
|
|
335
|
+
* task as `policy.mode`. Named `defaultModeInSettings` because a report of 2026-10-03 read this field
|
|
336
|
+
* as the run's mode — "unattended" for a run started step by step.
|
|
337
|
+
*/
|
|
338
|
+
limits: { ...cfg.limits, execution: { commandTimeoutSec: cfg.execution.commandTimeoutSec, idleTimeoutSec: cfg.execution.idleTimeoutSec, defaultModeInSettings: cfg.execution.mode } },
|
|
333
339
|
};
|
|
334
340
|
}
|
|
335
341
|
/** The three files an attempt that did not end done leaves in its own run folder. */
|
|
@@ -456,6 +462,31 @@ async function domainTask(session, task, runsDir) {
|
|
|
456
462
|
name: session.name,
|
|
457
463
|
// The commit the session's tasks are cut from, by the name a report looks for.
|
|
458
464
|
...(session.vcsBaseCommit ? { baselineCommit: session.vcsBaseCommit } : {}),
|
|
465
|
+
/*
|
|
466
|
+
* The starting snapshot, in the terms a report asks for: the base it was made on, the approved
|
|
467
|
+
* inputs with their sums, the approval, the sessions that start from it, and whether this one
|
|
468
|
+
* made it or inherited it through its chain.
|
|
469
|
+
*/
|
|
470
|
+
...(session.vcsStart?.kind === 'snapshot' || session.vcsStart?.baseline
|
|
471
|
+
? {
|
|
472
|
+
baseline: {
|
|
473
|
+
commit: session.vcsStart.kind === 'snapshot' ? session.vcsStart.commit : session.vcsStart.baseline?.commit,
|
|
474
|
+
branch: session.vcsStart.kind === 'snapshot' ? session.vcsStart.branch : session.vcsStart.baseline?.branch,
|
|
475
|
+
inherited: session.vcsStart.kind !== 'snapshot',
|
|
476
|
+
...(session.vcsStart.baseline?.fromSession ? { inheritedFrom: session.vcsStart.baseline.fromSession } : {}),
|
|
477
|
+
...(session.vcsStart.snapshot
|
|
478
|
+
? {
|
|
479
|
+
targetBaseCommit: session.vcsStart.snapshot.fromCommit,
|
|
480
|
+
targetBaseBranch: session.vcsStart.snapshot.fromBranch,
|
|
481
|
+
onBaseBranch: !!session.vcsStart.snapshot.onBase,
|
|
482
|
+
approval: { approved: session.vcsStart.snapshot.approved, at: session.vcsStart.snapshot.approvedAt },
|
|
483
|
+
sessions: session.vcsStart.snapshot.sharedWith ?? [{ id: session.id, name: session.name }],
|
|
484
|
+
}
|
|
485
|
+
: {}),
|
|
486
|
+
approvedInputs: (session.vcsStart.inputs?.files ?? []).map((f) => ({ path: f.path, sha256: f.sha256 })),
|
|
487
|
+
},
|
|
488
|
+
}
|
|
489
|
+
: {}),
|
|
459
490
|
...(session.vcsStart
|
|
460
491
|
? {
|
|
461
492
|
start: {
|
|
@@ -594,6 +625,28 @@ export async function buildDomainExport(scope, runsDir) {
|
|
|
594
625
|
// bot: what the runner did
|
|
595
626
|
// ------------------------------------------------------------------------------------------
|
|
596
627
|
const TRANSPORT_TYPES = new Set(['message-sent', 'format-error', 'report-written', 'report-redacted', 'report-send-failed', 'attachment-missing', 'chat-registered', 'download']);
|
|
628
|
+
/**
|
|
629
|
+
* The mode the attempt actually ran in, in the words of the run buttons: from its own policy.json
|
|
630
|
+
* (written when the task began, from the mode of the run as it was then), else the run's record on the
|
|
631
|
+
* session. "Run the rest without asking" can turn a step-by-step run unattended mid-way; a later task
|
|
632
|
+
* says so in its own policy.json.
|
|
633
|
+
*/
|
|
634
|
+
async function policyOf(session, task, runsDir) {
|
|
635
|
+
const named = (m) => (m === 'confirm' ? 'step-by-step' : m === 'unattended' ? 'unattended' : undefined);
|
|
636
|
+
if (task.runId) {
|
|
637
|
+
const raw = await readFile(join(runsDir, task.runId, 'policy.json'), 'utf8').catch(() => null);
|
|
638
|
+
try {
|
|
639
|
+
const mode = raw ? named(JSON.parse(raw).mode) : undefined;
|
|
640
|
+
if (mode)
|
|
641
|
+
return { mode, from: 'policy.json' };
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
/* a damaged file: fall back to the run record */
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const mode = session.runGroup && task.runGroup?.id === session.runGroup.id ? named(session.runGroup.mode) : undefined;
|
|
648
|
+
return mode ? { mode, from: 'run record' } : undefined;
|
|
649
|
+
}
|
|
597
650
|
async function botTask(session, task, runsDir) {
|
|
598
651
|
const events = await readEvents(runsDir, task.runId);
|
|
599
652
|
const steps = stepsOf(events);
|
|
@@ -605,6 +658,9 @@ async function botTask(session, task, runsDir) {
|
|
|
605
658
|
return {
|
|
606
659
|
session: { id: session.id, name: session.name, model: session.model, modelInUse: session.modelInUse, reviewModel: session.review?.model },
|
|
607
660
|
task: { id: task.id, title: task.title, attempt: task.attempt ?? 1, status: task.status, runId: task.runId, runFolder: task.runId ? join(runsDir, task.runId) : undefined, logFile: task.logFile },
|
|
661
|
+
// Which press of a run button this attempt belongs to: its preflight is under `runs` by this id.
|
|
662
|
+
run: task.runGroup ? { id: task.runGroup.id, name: task.runGroup.name } : undefined,
|
|
663
|
+
policy: await policyOf(session, task, runsDir),
|
|
608
664
|
timing: { startedAt: task.startedAt, finishedAt: task.finishedAt, iterations: task.iterations },
|
|
609
665
|
environment: task.environment,
|
|
610
666
|
transport: {
|
|
@@ -654,12 +710,25 @@ export async function buildBotExport(scope, runsDir, machine) {
|
|
|
654
710
|
const tasks = [];
|
|
655
711
|
for (const { session, task } of pairs)
|
|
656
712
|
tasks.push(await botTask(session, task, runsDir));
|
|
713
|
+
/*
|
|
714
|
+
* What each run did before its tasks: the version control preflight and the moment the browser was
|
|
715
|
+
* asked for, in the order they happened (`session/runLog.ts`), with the order checked rather than claimed.
|
|
716
|
+
*/
|
|
717
|
+
const runs = {};
|
|
718
|
+
for (const { task } of pairs) {
|
|
719
|
+
const id = task.runGroup?.id;
|
|
720
|
+
if (!id || id in runs)
|
|
721
|
+
continue;
|
|
722
|
+
const preflight = await readRunLog(runsDir, id);
|
|
723
|
+
runs[id] = { name: task.runGroup?.name, preflight, order: browserAfterPreflight(preflight) };
|
|
724
|
+
}
|
|
657
725
|
return {
|
|
658
726
|
exportedAt: new Date().toISOString(),
|
|
659
727
|
// Which copilot-operator made this, so a report from another machine says it without being asked.
|
|
660
728
|
botVersion: botVersion(),
|
|
661
729
|
about: `copilot-operator, the runner: ${scope.label}. The environment, every transcript event, every step with its exit code, the transport's retries, what was reaped, what the review machinery did. Read the domain export for what the task was about.`,
|
|
662
730
|
machine,
|
|
731
|
+
runs,
|
|
663
732
|
tasks,
|
|
664
733
|
};
|
|
665
734
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a run did before any of its tasks began: the version control preflight, repository by
|
|
3
|
+
* repository, and the moment the browser was asked for.
|
|
4
|
+
*
|
|
5
|
+
* From the bug report of 2026-10-03 (run on 0.1.20): a run refused for uncommitted files had already
|
|
6
|
+
* emitted `task-started`, set up the shell, opened Edge and collected artifacts, and its export could
|
|
7
|
+
* not show in which order any of it happened. The preflight now runs before all of that, and this is
|
|
8
|
+
* its record: one file per run (`<runs>/_runs/<run id>/run.jsonl`), written by the service before the
|
|
9
|
+
* browser opens and read back by the runner export, which can then show that `browser-launch-requested`
|
|
10
|
+
* came after `run-preflight-passed` — or that a refused run never asked for a browser at all.
|
|
11
|
+
*/
|
|
12
|
+
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
/** A run id as a folder name: what the service makes (`r-…`, `b-…`), and nothing that walks out of `_runs`. */
|
|
15
|
+
const safe = (runId) => runId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 80);
|
|
16
|
+
export function runLogPath(runsDir, runId) {
|
|
17
|
+
return join(runsDir, '_runs', safe(runId), 'run.jsonl');
|
|
18
|
+
}
|
|
19
|
+
export async function appendRunLog(runsDir, runId, entries) {
|
|
20
|
+
if (!runId || entries.length === 0)
|
|
21
|
+
return;
|
|
22
|
+
const path = runLogPath(runsDir, runId);
|
|
23
|
+
await mkdir(join(path, '..'), { recursive: true });
|
|
24
|
+
await appendFile(path, entries.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf8');
|
|
25
|
+
}
|
|
26
|
+
export async function readRunLog(runsDir, runId) {
|
|
27
|
+
if (!runId)
|
|
28
|
+
return [];
|
|
29
|
+
try {
|
|
30
|
+
const text = await readFile(runLogPath(runsDir, runId), 'utf8');
|
|
31
|
+
return text
|
|
32
|
+
.split('\n')
|
|
33
|
+
.filter((l) => l.trim())
|
|
34
|
+
.flatMap((l) => {
|
|
35
|
+
try {
|
|
36
|
+
return [JSON.parse(l)];
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Whether the browser was asked for only after the preflight passed, read from the record rather
|
|
49
|
+
* than claimed: the position of each in the file, which is written in the order things happened.
|
|
50
|
+
*/
|
|
51
|
+
export function browserAfterPreflight(entries) {
|
|
52
|
+
const passed = entries.findIndex((e) => e.type === 'run-preflight-passed');
|
|
53
|
+
const browser = entries.findIndex((e) => e.type === 'browser-launch-requested');
|
|
54
|
+
return {
|
|
55
|
+
...(passed >= 0 ? { preflightPassedAt: entries[passed].at } : {}),
|
|
56
|
+
...(browser >= 0 ? { browserLaunchRequestedAt: entries[browser].at } : {}),
|
|
57
|
+
// No browser asked for: nothing to prove. A browser with no passed preflight before it: false.
|
|
58
|
+
browserOnlyAfterPreflight: browser < 0 ? null : passed >= 0 && passed < browser,
|
|
59
|
+
};
|
|
60
|
+
}
|
package/dist/src/vcs/git.js
CHANGED
|
@@ -64,7 +64,17 @@ export async function repoState(dir) {
|
|
|
64
64
|
return { ...empty, problem: 'No repository folder is set.' };
|
|
65
65
|
if (!existsSync(dir))
|
|
66
66
|
return { ...empty, problem: `The folder ${dir} does not exist.` };
|
|
67
|
-
|
|
67
|
+
let inside = await git(dir, ['rev-parse', '--is-inside-work-tree']);
|
|
68
|
+
/*
|
|
69
|
+
* A folder with a .git that git calls "not a repository" is, on Windows, a moment when another git is
|
|
70
|
+
* replacing HEAD or the index (a rename there is not atomic while the file is open): asked again, it is
|
|
71
|
+
* one. Found 2026-10-02 when a run was refused before it started for "not a git repository" while its
|
|
72
|
+
* branch and HEAD read fine. Asked up to three more times before the answer stands.
|
|
73
|
+
*/
|
|
74
|
+
for (let i = 0; i < 3 && (!inside.ok || inside.stdout !== 'true') && existsSync(join(dir, '.git')); i += 1) {
|
|
75
|
+
await new Promise((r) => setTimeout(r, 150 * (i + 1)));
|
|
76
|
+
inside = await git(dir, ['rev-parse', '--is-inside-work-tree']);
|
|
77
|
+
}
|
|
68
78
|
if (!inside.ok || inside.stdout !== 'true') {
|
|
69
79
|
return { ...empty, problem: `${dir} is not a git repository. Run "git init" there, or point version control at one.` };
|
|
70
80
|
}
|
package/dist/src/vcs/inputs.js
CHANGED
|
@@ -25,7 +25,7 @@ import { rm, stat } from 'node:fs/promises';
|
|
|
25
25
|
import { tmpdir } from 'node:os';
|
|
26
26
|
import { join, resolve, sep } from 'node:path';
|
|
27
27
|
import { looksGenerated } from './commitHygiene.js';
|
|
28
|
-
import { branchNameFrom, freeBranchName, git, gitBytes, isValidBranchName, RUNNER_EMAIL } from './git.js';
|
|
28
|
+
import { branchNameFrom, freeBranchName, git, gitBytes, isValidBranchName, RUNNER_EMAIL, workingTreePaths } from './git.js';
|
|
29
29
|
import { inScope } from './scope.js';
|
|
30
30
|
/** The session's input settings with defaults filled in, or null when it names no inputs. */
|
|
31
31
|
export function inputSettings(vcs) {
|
|
@@ -152,7 +152,7 @@ const norm = (dir) => dir.trim().replace(/[\\/]+$/, '').replace(/\//g, '\\').toL
|
|
|
152
152
|
* tree or the index: a temporary index is read from `parent`, the files are set in it, and the
|
|
153
153
|
* tree is committed with `commit-tree`.
|
|
154
154
|
*/
|
|
155
|
-
async function commitOnto(top, parent, files, message, branch) {
|
|
155
|
+
export async function commitOnto(top, parent, files, message, branch) {
|
|
156
156
|
const index = join(tmpdir(), `cop-inputs-index-${process.pid}-${Date.now()}`);
|
|
157
157
|
const env = { ...process.env, GIT_INDEX_FILE: index };
|
|
158
158
|
try {
|
|
@@ -377,6 +377,30 @@ export async function untrackedInputs(dir, patterns) {
|
|
|
377
377
|
return new Set();
|
|
378
378
|
return new Set((await inputFilesInTree(resolve(topR.stdout), patterns)).filter((f) => f.kind !== 'tracked').map((f) => f.path));
|
|
379
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* What is uncommitted in the working tree besides the session's recorded input files as they were
|
|
382
|
+
* approved. An input taken into a starting snapshot on the base branch stays untracked where it is —
|
|
383
|
+
* the operator's checkout is not touched — and is not "a dirty tree" while its content is the one
|
|
384
|
+
* recorded: `inputs` lists those, to be staged before the task's branch is checked out, so the switch
|
|
385
|
+
* keeps them.
|
|
386
|
+
*/
|
|
387
|
+
export async function dirtyBesidesInputs(dir, start) {
|
|
388
|
+
const topR = await git(dir, ['rev-parse', '--show-toplevel']);
|
|
389
|
+
if (!topR.ok || !topR.stdout)
|
|
390
|
+
return { other: [], inputs: [] };
|
|
391
|
+
const top = resolve(topR.stdout);
|
|
392
|
+
const recorded = new Map((start?.inputs?.files ?? []).map((f) => [f.path, f.blob]));
|
|
393
|
+
const other = [];
|
|
394
|
+
const inputs = [];
|
|
395
|
+
for (const p of await workingTreePaths(top)) {
|
|
396
|
+
const want = recorded.get(p);
|
|
397
|
+
if (want && (await blobOfFile(top, p)) === want)
|
|
398
|
+
inputs.push(p);
|
|
399
|
+
else
|
|
400
|
+
other.push(p);
|
|
401
|
+
}
|
|
402
|
+
return { other, inputs };
|
|
403
|
+
}
|
|
380
404
|
/**
|
|
381
405
|
* Puts the input files back as the task's starting commit has them: a changed or removed one from
|
|
382
406
|
* that commit, a new one matching an input pattern removed. Only on the task's own branch, whose
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Prepare the folder from the remote main branch": the project folder put exactly where the team's
|
|
3
|
+
* code is — the remote's main branch, freshly fetched, checked out as the local branch of the same
|
|
4
|
+
* name, with no uncommitted changes — so the operator can add the files a task needs on top of it and
|
|
5
|
+
* hand it to the bot from there.
|
|
6
|
+
*
|
|
7
|
+
* Asked for on 2026-10-03, after a run was refused for uncommitted files on a branch the operator had
|
|
8
|
+
* put them on: a button beside the project in Settings, instead of the command to copy that
|
|
9
|
+
* `syncCommand.ts` hands out. A command to paste was the answer while the only way back to the remote
|
|
10
|
+
* was `reset --hard` and `clean -fd`, which throw work away. This does not throw anything away, which
|
|
11
|
+
* is what makes it a button:
|
|
12
|
+
*
|
|
13
|
+
* 1. Everything not on the remote's main is kept first. Uncommitted changes and new files git does
|
|
14
|
+
* not ignore become one commit on a branch of their own (`<prefix>saved/<when>`), made with a
|
|
15
|
+
* temporary index, so neither the checkout nor the real index moves while it is made — and it is
|
|
16
|
+
* checked to hold every one of them before anything else happens. Commits of the local main
|
|
17
|
+
* branch that the remote does not have get a branch of their own too. The branch the folder was
|
|
18
|
+
* on keeps its commits; no branch is deleted.
|
|
19
|
+
* 2. Only then is the folder moved: the local main branch is set to the remote's main and checked
|
|
20
|
+
* out, and the new files that are now on the saved branch are removed from the folder. Ignored
|
|
21
|
+
* files — node_modules, .env, build output, what a snapshot left out — are not touched.
|
|
22
|
+
* 3. The result is read back: HEAD on the remote's main, nothing uncommitted. Otherwise it says so.
|
|
23
|
+
*
|
|
24
|
+
* The preview fetches (so what it shows is against the remote as it is now) and changes nothing else;
|
|
25
|
+
* the preparation itself refuses when the folder is no longer what the preview showed.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync } from 'node:fs';
|
|
28
|
+
import { rm } from 'node:fs/promises';
|
|
29
|
+
import { tmpdir } from 'node:os';
|
|
30
|
+
import { join, resolve } from 'node:path';
|
|
31
|
+
import { createHash } from 'node:crypto';
|
|
32
|
+
import { branchNameFrom, freeBranchName, git, porcelainPaths, RUNNER_EMAIL } from './git.js';
|
|
33
|
+
const QUIET = { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_ASKPASS: '', SSH_ASKPASS: '' };
|
|
34
|
+
const short = (c) => (c ?? '').slice(0, 8);
|
|
35
|
+
const lines = (s) => s.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
36
|
+
function stamp(now = new Date()) {
|
|
37
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
38
|
+
return `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
|
|
39
|
+
}
|
|
40
|
+
/** A merge, rebase, cherry-pick or revert half done: the folder is the operator's to finish first. */
|
|
41
|
+
function operationInProgress(gitDir) {
|
|
42
|
+
for (const [file, what] of [
|
|
43
|
+
['MERGE_HEAD', 'a merge'],
|
|
44
|
+
['rebase-merge', 'a rebase'],
|
|
45
|
+
['rebase-apply', 'a rebase'],
|
|
46
|
+
['CHERRY_PICK_HEAD', 'a cherry-pick'],
|
|
47
|
+
['REVERT_HEAD', 'a revert'],
|
|
48
|
+
]) {
|
|
49
|
+
if (existsSync(join(gitDir, file)))
|
|
50
|
+
return what;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const empty = (repoDir, problem) => ({
|
|
55
|
+
ok: false,
|
|
56
|
+
problem,
|
|
57
|
+
repoDir,
|
|
58
|
+
changed: [],
|
|
59
|
+
untracked: [],
|
|
60
|
+
mainOnlyCommits: [],
|
|
61
|
+
branchOnlyCommits: [],
|
|
62
|
+
});
|
|
63
|
+
/**
|
|
64
|
+
* What preparing the folder would do. Fetches the remote when `fetch` is true (the preview); reads
|
|
65
|
+
* only when it is false (the preparation, which must compare against what the preview fetched).
|
|
66
|
+
*/
|
|
67
|
+
export async function planPrepare(folder, opts) {
|
|
68
|
+
const inside = await git(folder, ['rev-parse', '--is-inside-work-tree']);
|
|
69
|
+
if (!inside.ok || inside.stdout !== 'true')
|
|
70
|
+
return empty(folder, `${folder} is not a git repository.`);
|
|
71
|
+
// The whole repository, not the folder under it the project may name: a checkout moves all of it.
|
|
72
|
+
const topR = await git(folder, ['rev-parse', '--show-toplevel']);
|
|
73
|
+
const repoDir = topR.ok && topR.stdout ? resolve(topR.stdout) : folder;
|
|
74
|
+
const gitDir = (await git(repoDir, ['rev-parse', '--absolute-git-dir'])).stdout;
|
|
75
|
+
const busy = gitDir ? operationInProgress(gitDir) : null;
|
|
76
|
+
if (busy)
|
|
77
|
+
return empty(repoDir, `The repository is in the middle of ${busy}. Finish or abort it first; nothing was changed.`);
|
|
78
|
+
const headR = await git(repoDir, ['rev-parse', '--verify', '--quiet', 'HEAD']);
|
|
79
|
+
if (!headR.ok || !headR.stdout)
|
|
80
|
+
return empty(repoDir, 'The repository has no commits yet, so there is nothing to keep the changes on top of. Make a first commit, then press it again.');
|
|
81
|
+
const branchR = await git(repoDir, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
82
|
+
const branch = branchR.ok && branchR.stdout ? branchR.stdout : null;
|
|
83
|
+
const remotes = lines((await git(repoDir, ['remote'])).stdout);
|
|
84
|
+
if (remotes.length === 0)
|
|
85
|
+
return empty(repoDir, 'The repository has no remote, so there is no remote main branch to prepare it from.');
|
|
86
|
+
const remote = remotes.includes('origin') ? 'origin' : remotes[0];
|
|
87
|
+
let fetched;
|
|
88
|
+
if (opts.fetch) {
|
|
89
|
+
const f = await git(repoDir, ['-c', 'credential.interactive=false', 'fetch', '--quiet', '--no-tags', '--prune', remote], 120_000, QUIET);
|
|
90
|
+
fetched = f.ok ? { ok: true } : { ok: false, detail: (f.stderr || f.stdout || 'git fetch failed').split(/\r?\n/)[0] };
|
|
91
|
+
// The remote's own idea of its main branch, asked of it now; a remote that will not say keeps the old answer.
|
|
92
|
+
if (f.ok)
|
|
93
|
+
await git(repoDir, ['remote', 'set-head', remote, '--auto'], 60_000, QUIET);
|
|
94
|
+
}
|
|
95
|
+
let target = '';
|
|
96
|
+
const remoteHead = await git(repoDir, ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`]);
|
|
97
|
+
if (remoteHead.ok && remoteHead.stdout)
|
|
98
|
+
target = remoteHead.stdout;
|
|
99
|
+
for (const name of ['main', 'master']) {
|
|
100
|
+
if (target)
|
|
101
|
+
break;
|
|
102
|
+
if ((await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${name}`])).ok)
|
|
103
|
+
target = `${remote}/${name}`;
|
|
104
|
+
}
|
|
105
|
+
if (!target) {
|
|
106
|
+
return {
|
|
107
|
+
...empty(repoDir, fetched && !fetched.ok ? `The remote could not be fetched (${fetched.detail}), and no main branch of ${remote} is known here.` : `No main branch of ${remote} is known here.`),
|
|
108
|
+
...(fetched ? { fetched } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (fetched && !fetched.ok) {
|
|
112
|
+
return { ...empty(repoDir, `The remote could not be fetched, so the folder would be set to an old copy of ${target}: ${fetched.detail}. Nothing was changed.`), fetched };
|
|
113
|
+
}
|
|
114
|
+
const targetCommit = (await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/remotes/${target}^{commit}`])).stdout;
|
|
115
|
+
if (!targetCommit)
|
|
116
|
+
return empty(repoDir, `${target} could not be read.`);
|
|
117
|
+
const localBranch = target.slice(remote.length + 1);
|
|
118
|
+
const localHead = (await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${localBranch}^{commit}`])).stdout || null;
|
|
119
|
+
const status = await git(repoDir, ['status', '--porcelain', '--untracked-files=no']);
|
|
120
|
+
const changed = porcelainPaths(status.stdout);
|
|
121
|
+
const untracked = lines((await git(repoDir, ['ls-files', '--others', '--exclude-standard'])).stdout);
|
|
122
|
+
const mainOnlyCommits = localHead ? lines((await git(repoDir, ['log', '--format=%h %s', `${targetCommit}..${localHead}`])).stdout) : [];
|
|
123
|
+
const branchOnlyCommits = branch && branch !== localBranch ? lines((await git(repoDir, ['log', '--format=%h %s', `${targetCommit}..HEAD`])).stdout) : [];
|
|
124
|
+
const prefix = opts.prefix || 'cop/';
|
|
125
|
+
const when = stamp();
|
|
126
|
+
const dirty = changed.length + untracked.length > 0;
|
|
127
|
+
// The local main's own commits need a branch only when nothing else keeps them: the saved commit sits on top of them when the folder is on main.
|
|
128
|
+
const mainNeedsKeeping = mainOnlyCommits.length > 0 && !(dirty && branch === localBranch);
|
|
129
|
+
const savedBranch = dirty ? await freeBranchName(repoDir, `${branchNameFrom(['saved'], prefix)}/${when}`) : undefined;
|
|
130
|
+
const savedMainBranch = mainNeedsKeeping ? await freeBranchName(repoDir, `${branchNameFrom(['saved'], prefix)}/${when}-${localBranch}`) : undefined;
|
|
131
|
+
const alreadyThere = !dirty && branch === localBranch && headR.stdout === targetCommit;
|
|
132
|
+
const fingerprint = createHash('sha256')
|
|
133
|
+
.update([headR.stdout, branch ?? '', targetCommit, localHead ?? '', status.stdout, untracked.join('\n')].join('\0'))
|
|
134
|
+
.digest('hex')
|
|
135
|
+
.slice(0, 16);
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
repoDir,
|
|
139
|
+
branch,
|
|
140
|
+
head: headR.stdout,
|
|
141
|
+
remote,
|
|
142
|
+
target,
|
|
143
|
+
targetCommit,
|
|
144
|
+
localBranch,
|
|
145
|
+
localHead,
|
|
146
|
+
...(fetched ? { fetched } : {}),
|
|
147
|
+
changed,
|
|
148
|
+
untracked,
|
|
149
|
+
mainOnlyCommits,
|
|
150
|
+
branchOnlyCommits,
|
|
151
|
+
...(savedBranch ? { savedBranch } : {}),
|
|
152
|
+
...(savedMainBranch ? { savedMainBranch } : {}),
|
|
153
|
+
...(alreadyThere ? { alreadyThere } : {}),
|
|
154
|
+
fingerprint,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Every uncommitted change and new file, as one commit on top of HEAD on a branch of its own, made
|
|
159
|
+
* with a temporary index — then checked to hold all of them, so nothing is removed from the folder
|
|
160
|
+
* that is not kept.
|
|
161
|
+
*/
|
|
162
|
+
async function keepUncommitted(dir, head, branch, message) {
|
|
163
|
+
const index = join(tmpdir(), `cop-saved-index-${process.pid}-${Date.now()}`);
|
|
164
|
+
const env = { ...process.env, GIT_INDEX_FILE: index };
|
|
165
|
+
try {
|
|
166
|
+
const read = await git(dir, ['read-tree', head], 60_000, env);
|
|
167
|
+
if (!read.ok)
|
|
168
|
+
return { problem: read.stderr || 'git read-tree failed' };
|
|
169
|
+
const added = await git(dir, ['add', '-A', '--', '.'], 300_000, env);
|
|
170
|
+
if (!added.ok)
|
|
171
|
+
return { problem: `the changes could not be read into git: ${added.stderr || added.stdout}` };
|
|
172
|
+
// Kept means kept: no tracked file differs from the saved index, and no new file is outside it.
|
|
173
|
+
const differs = await git(dir, ['diff-files', '--name-only'], 120_000, env);
|
|
174
|
+
const outside = await git(dir, ['ls-files', '--others', '--exclude-standard'], 120_000, env);
|
|
175
|
+
if (!differs.ok || differs.stdout || !outside.ok || outside.stdout) {
|
|
176
|
+
return { problem: `not every change could be kept (${[differs.stdout, outside.stdout].filter(Boolean).join(', ').slice(0, 300) || differs.stderr || outside.stderr}), so nothing was moved.` };
|
|
177
|
+
}
|
|
178
|
+
const tree = await git(dir, ['write-tree'], 60_000, env);
|
|
179
|
+
if (!tree.ok || !tree.stdout)
|
|
180
|
+
return { problem: tree.stderr || 'git write-tree failed' };
|
|
181
|
+
const commit = await git(dir, ['-c', 'user.name=copilot-operator', '-c', `user.email=${RUNNER_EMAIL}`, 'commit-tree', tree.stdout, '-p', head, '-m', message]);
|
|
182
|
+
if (!commit.ok || !commit.stdout)
|
|
183
|
+
return { problem: commit.stderr || 'git commit-tree failed' };
|
|
184
|
+
const made = await git(dir, ['branch', branch, commit.stdout]);
|
|
185
|
+
if (!made.ok)
|
|
186
|
+
return { problem: made.stderr || `the branch ${branch} could not be made` };
|
|
187
|
+
return { commit: commit.stdout };
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
await rm(index, { force: true });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Does it, after checking the folder is still what the preview showed. */
|
|
194
|
+
export async function prepareFromRemote(folder, fingerprint, opts = {}) {
|
|
195
|
+
const plan = await planPrepare(folder, { fetch: false, prefix: opts.prefix });
|
|
196
|
+
if (!plan.ok)
|
|
197
|
+
return { ok: false, problem: plan.problem ?? 'the folder could not be read.' };
|
|
198
|
+
const repoDir = plan.repoDir;
|
|
199
|
+
if (!fingerprint || plan.fingerprint !== fingerprint) {
|
|
200
|
+
return { ok: false, problem: 'The folder changed since the preview was shown (a commit, a branch, a changed or new file). Look at the preview again; nothing was changed.' };
|
|
201
|
+
}
|
|
202
|
+
const target = plan.target;
|
|
203
|
+
const targetCommit = plan.targetCommit;
|
|
204
|
+
const localBranch = plan.localBranch;
|
|
205
|
+
const head = plan.head;
|
|
206
|
+
if (plan.alreadyThere) {
|
|
207
|
+
return { ok: true, branch: localBranch, commit: targetCommit, target, saved: [], removedFromFolder: 0, result: `The folder is already on ${localBranch} at ${short(targetCommit)}, the same as ${target}, with nothing uncommitted. Nothing was changed.` };
|
|
208
|
+
}
|
|
209
|
+
const saved = [];
|
|
210
|
+
if (plan.savedBranch) {
|
|
211
|
+
const what = `${plan.changed.length} changed and ${plan.untracked.length} new file(s) from ${plan.branch ?? `HEAD ${short(head)}`}`;
|
|
212
|
+
const message = [
|
|
213
|
+
'Keep uncommitted work before preparing the folder from the remote',
|
|
214
|
+
'',
|
|
215
|
+
`Everything that was not committed in the folder on ${plan.branch ?? 'a detached HEAD'} (${short(head)}), kept here before the folder was set to ${target} (${short(targetCommit)}).`,
|
|
216
|
+
'To take a file back: git restore --source <this branch> -- <path>',
|
|
217
|
+
'',
|
|
218
|
+
...[...plan.changed.map((p) => `- changed: ${p}`), ...plan.untracked.map((p) => `- new: ${p}`)].slice(0, 300),
|
|
219
|
+
'',
|
|
220
|
+
'Committed by copilot-operator. Not pushed.',
|
|
221
|
+
'',
|
|
222
|
+
].join('\n');
|
|
223
|
+
const kept = await keepUncommitted(repoDir, head, plan.savedBranch, message);
|
|
224
|
+
if ('problem' in kept)
|
|
225
|
+
return { ok: false, problem: kept.problem };
|
|
226
|
+
saved.push({ branch: plan.savedBranch, commit: kept.commit, what });
|
|
227
|
+
}
|
|
228
|
+
if (plan.savedMainBranch && plan.localHead) {
|
|
229
|
+
const made = await git(repoDir, ['branch', plan.savedMainBranch, plan.localHead]);
|
|
230
|
+
if (!made.ok)
|
|
231
|
+
return { ok: false, problem: `the commits of ${localBranch} the remote does not have could not be kept on ${plan.savedMainBranch}: ${made.stderr}. ${saved.length ? `The uncommitted work is on ${saved[0].branch}; ` : ''}the folder was not moved.` };
|
|
232
|
+
saved.push({ branch: plan.savedMainBranch, commit: plan.localHead, what: `${plan.mainOnlyCommits.length} commit(s) of ${localBranch} that ${target} does not have` });
|
|
233
|
+
}
|
|
234
|
+
// Everything is kept; now the folder. The forced checkout drops tracked changes that are on the saved branch.
|
|
235
|
+
const moved = await git(repoDir, ['checkout', '--quiet', '--force', '-B', localBranch, targetCommit], 300_000);
|
|
236
|
+
if (!moved.ok) {
|
|
237
|
+
return { ok: false, problem: `${localBranch} could not be checked out at ${short(targetCommit)}: ${moved.stderr || moved.stdout}. ${saved.map((s) => `${s.what} are on ${s.branch}.`).join(' ')}` };
|
|
238
|
+
}
|
|
239
|
+
await git(repoDir, ['branch', `--set-upstream-to=${target}`, localBranch]);
|
|
240
|
+
// The new files, kept on the saved branch, out of the folder; ignored files stay (no -x).
|
|
241
|
+
const removed = plan.untracked.length > 0 ? await git(repoDir, ['clean', '-fd', '--quiet'], 300_000) : { ok: true, stdout: '', stderr: '' };
|
|
242
|
+
const nowHead = (await git(repoDir, ['rev-parse', 'HEAD'])).stdout;
|
|
243
|
+
const left = lines((await git(repoDir, ['status', '--porcelain'])).stdout);
|
|
244
|
+
const keptLine = saved.length > 0 ? ` Kept: ${saved.map((s) => `${s.what} on ${s.branch}`).join('; ')}.` : '';
|
|
245
|
+
const branchLine = plan.branchOnlyCommits.length > 0 && plan.branch ? ` ${plan.branch} keeps its ${plan.branchOnlyCommits.length} commit(s); nothing was deleted.` : '';
|
|
246
|
+
if (nowHead !== targetCommit || left.length > 0 || !removed.ok) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
problem: `The folder is on ${localBranch} at ${short(nowHead)}, but not exactly at ${target}: ${left.length > 0 ? `still uncommitted: ${left.slice(0, 10).join(', ')}` : removed.ok ? 'HEAD differs' : removed.stderr}.` +
|
|
250
|
+
keptLine,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
ok: true,
|
|
255
|
+
branch: localBranch,
|
|
256
|
+
commit: targetCommit,
|
|
257
|
+
target,
|
|
258
|
+
saved,
|
|
259
|
+
removedFromFolder: plan.untracked.length,
|
|
260
|
+
result: `The folder is on ${localBranch} at ${short(targetCommit)}, the same as ${target}, with nothing uncommitted.${keptLine}${branchLine} ` +
|
|
261
|
+
'Ignored files (node_modules, .env, build output) were not touched. Add the files the task needs now; the run screen offers a starting snapshot of them.',
|
|
262
|
+
};
|
|
263
|
+
}
|