copilot-operator 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +556 -0
- package/dist/src/api/app.module.js +18 -0
- package/dist/src/api/dataAcl.js +109 -0
- package/dist/src/api/folderPicker.js +110 -0
- package/dist/src/api/main.js +132 -0
- package/dist/src/api/operator.controller.js +975 -0
- package/dist/src/api/operator.service.js +1971 -0
- package/dist/src/api/saveToDesktop.js +157 -0
- package/dist/src/api/security.js +225 -0
- package/dist/src/api/settings.js +57 -0
- package/dist/src/cli.js +531 -0
- package/dist/src/config/layout.js +66 -0
- package/dist/src/config/lockedPolicy.js +176 -0
- package/dist/src/config/schema.js +487 -0
- package/dist/src/context/contextFiles.js +295 -0
- package/dist/src/context/desktopMirror.js +113 -0
- package/dist/src/context/projectMirror.js +364 -0
- package/dist/src/exec/authorizer.js +61 -0
- package/dist/src/exec/checks.js +285 -0
- package/dist/src/exec/confinement.js +224 -0
- package/dist/src/exec/dangerous.js +161 -0
- package/dist/src/exec/environment.js +80 -0
- package/dist/src/exec/isolation.js +166 -0
- package/dist/src/exec/network.js +242 -0
- package/dist/src/exec/policy.js +201 -0
- package/dist/src/exec/policyManifest.js +84 -0
- package/dist/src/exec/processes.js +329 -0
- package/dist/src/exec/programs.js +290 -0
- package/dist/src/exec/redaction.js +93 -0
- package/dist/src/exec/reportFile.js +154 -0
- package/dist/src/exec/runner.js +245 -0
- package/dist/src/exec/scriptFiles.js +81 -0
- package/dist/src/exec/shellExecuteTrap.js +221 -0
- package/dist/src/exec/shells.js +298 -0
- package/dist/src/exec/stepEnv.js +72 -0
- package/dist/src/exec/winps.js +17 -0
- package/dist/src/exec/workDir.js +94 -0
- package/dist/src/log/runLog.js +43 -0
- package/dist/src/orchestrator/derivedChecks.js +95 -0
- package/dist/src/orchestrator/review.js +590 -0
- package/dist/src/orchestrator/taskRunner.js +1582 -0
- package/dist/src/plan/brief.js +1785 -0
- package/dist/src/plan/importPlan.js +202 -0
- package/dist/src/plan/schema.js +483 -0
- package/dist/src/plan/script.js +1012 -0
- package/dist/src/plan/systemGuide.js +1851 -0
- package/dist/src/protocol/parser.js +224 -0
- package/dist/src/protocol/replySchema.js +207 -0
- package/dist/src/protocol/reporter.js +75 -0
- package/dist/src/protocol/reviewSchema.js +205 -0
- package/dist/src/session/compose.js +67 -0
- package/dist/src/session/debugExport.js +165 -0
- package/dist/src/session/events.js +34 -0
- package/dist/src/session/exportRecord.js +261 -0
- package/dist/src/session/exports.js +484 -0
- package/dist/src/session/model.js +22 -0
- package/dist/src/session/retention.js +34 -0
- package/dist/src/session/runName.js +36 -0
- package/dist/src/session/store.js +440 -0
- package/dist/src/session/story.js +198 -0
- package/dist/src/transport/acceptance.js +34 -0
- package/dist/src/transport/chatSession.js +73 -0
- package/dist/src/transport/copilotTransport.js +1208 -0
- package/dist/src/transport/edgeCrash.js +59 -0
- package/dist/src/transport/locators.js +261 -0
- package/dist/src/transport/profileLock.js +102 -0
- package/dist/src/util/pacing.js +89 -0
- package/dist/src/vcs/commitHygiene.js +102 -0
- package/dist/src/vcs/git.js +286 -0
- package/dist/src/vcs/taskVcs.js +343 -0
- package/dist/web/404.html +15 -0
- package/dist/web/_next/static/chunks/12.f1f4d35b52633e8d.js +1 -0
- package/dist/web/_next/static/chunks/131-bae98f790a541679.js +1 -0
- package/dist/web/_next/static/chunks/197-f7c33a0114fc3db3.js +1 -0
- package/dist/web/_next/static/chunks/489.158bbe303a595fba.js +1 -0
- package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +1 -0
- package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +1 -0
- package/dist/web/_next/static/chunks/955-63de06e182d98949.js +1 -0
- package/dist/web/_next/static/chunks/app/_not-found/page-0d0d8e7e62efca12.js +1 -0
- package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-bad4f3e89b485719.js +1 -0
- package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +1 -0
- package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +1 -0
- package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +1 -0
- package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +1 -0
- package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +1 -0
- package/dist/web/_next/static/chunks/c7879cf7-b5ab1053c1d9a2e7.js +1 -0
- package/dist/web/_next/static/chunks/framework-9ac6a346bd42cedd.js +1 -0
- package/dist/web/_next/static/chunks/main-6be4444e2d667e41.js +1 -0
- package/dist/web/_next/static/chunks/main-app-e3a5f713ba31557e.js +1 -0
- package/dist/web/_next/static/chunks/pages/_app-32d20d5a04949d29.js +1 -0
- package/dist/web/_next/static/chunks/pages/_error-abbab53f25ea29b0.js +1 -0
- package/dist/web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/dist/web/_next/static/chunks/webpack-0f31b926d9a9c5a4.js +1 -0
- package/dist/web/_next/static/css/d77735cdc9020a86.css +1 -0
- package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_buildManifest.js +1 -0
- package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_ssgManifest.js +1 -0
- package/dist/web/appearance.html +15 -0
- package/dist/web/appearance.txt +26 -0
- package/dist/web/defaults.html +15 -0
- package/dist/web/defaults.txt +26 -0
- package/dist/web/history.html +15 -0
- package/dist/web/history.txt +26 -0
- package/dist/web/import.html +15 -0
- package/dist/web/import.txt +26 -0
- package/dist/web/index.html +15 -0
- package/dist/web/index.txt +26 -0
- package/dist/web/level1.html +15 -0
- package/dist/web/level1.txt +26 -0
- package/dist/web/presets.html +18 -0
- package/dist/web/presets.txt +26 -0
- package/dist/web/sessions/view.html +15 -0
- package/dist/web/sessions/view.txt +26 -0
- package/dist/web/system.html +15 -0
- package/dist/web/system.txt +26 -0
- package/package.json +111 -0
- package/prompts/level1.md +535 -0
- package/prompts/organisation.bg.md +44 -0
- package/prompts/organisation.en.md +44 -0
- package/prompts/persona.bg.md +17 -0
- package/prompts/persona.en.md +17 -0
- package/prompts/review1.md +277 -0
- package/prompts/work.bg.md +19 -0
- package/prompts/work.en.md +19 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The git the runner does for itself.
|
|
3
|
+
*
|
|
4
|
+
* Every git command in this project runs from here, not from a step written by Copilot. That
|
|
5
|
+
* is deliberate. Branching and committing have to be exact, and exactness is the one thing a
|
|
6
|
+
* language model cannot promise: a step it writes goes through improvisation, through the
|
|
7
|
+
* approval gate, and through the output pipeline that has already been observed to mangle
|
|
8
|
+
* text. A task that is asked to "go back to the code as it was" cannot be built on that.
|
|
9
|
+
*
|
|
10
|
+
* Two rules hold everywhere in this file:
|
|
11
|
+
*
|
|
12
|
+
* - Nothing is destructive. There is no `reset --hard`, no forced checkout, no branch
|
|
13
|
+
* deletion. Going back to an earlier state means branching from the commit that state
|
|
14
|
+
* was at, which adds history instead of removing it.
|
|
15
|
+
* - Nothing runs through a shell. `execFile` takes an argument list, so a branch name or a
|
|
16
|
+
* commit message cannot turn into another command.
|
|
17
|
+
*/
|
|
18
|
+
import { execFile } from 'node:child_process';
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
/**
|
|
22
|
+
* Settings every git the runner starts is given, ahead of the command.
|
|
23
|
+
*
|
|
24
|
+
* The repository is one the chat's steps have been working in, and a repository can carry code that
|
|
25
|
+
* git runs by itself: hooks (`post-checkout`, `prepare-commit-msg`, `post-commit` — `--no-verify`
|
|
26
|
+
* skips only two of them), an fsmonitor program that `status` starts, and clean/smudge filters that
|
|
27
|
+
* `add` and `checkout` run. Found on 2026-09-27: any of these, placed by a step, would have been
|
|
28
|
+
* executed by the runner — as the runner, with no approval and no log. So hooks are pointed at a
|
|
29
|
+
* folder that does not exist, fsmonitor is off, and the repository's own attribute files are not
|
|
30
|
+
* read for filters (`core.attributesFile` cannot switch off `.gitattributes`, but a filter needs a
|
|
31
|
+
* driver in the config, which the steps may no longer write — see `repositoryInternalsRefusal`).
|
|
32
|
+
* `--no-optional-locks` keeps `status` from writing the index behind a step's back.
|
|
33
|
+
*/
|
|
34
|
+
const SAFE_GIT = [
|
|
35
|
+
'-c', `core.hooksPath=${noHooksDir()}`,
|
|
36
|
+
'-c', 'core.fsmonitor=false',
|
|
37
|
+
'-c', 'core.untrackedCache=false',
|
|
38
|
+
'--no-optional-locks',
|
|
39
|
+
];
|
|
40
|
+
/** A folder that does not exist, so git finds no hooks in it. */
|
|
41
|
+
function noHooksDir() {
|
|
42
|
+
return process.env.SystemRoot ? join(process.env.SystemRoot, 'System32', 'copilot-operator-no-hooks') : '/nonexistent';
|
|
43
|
+
}
|
|
44
|
+
/** Runs one git command in a directory. Never throws: the caller decides what a failure means. */
|
|
45
|
+
export function git(cwd, args, timeoutMs = 60_000) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
execFile('git', [...SAFE_GIT, ...args], { cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
48
|
+
const code = error?.code;
|
|
49
|
+
resolve({
|
|
50
|
+
ok: !error,
|
|
51
|
+
stdout: (stdout ?? '').trim(),
|
|
52
|
+
stderr: (stderr ?? '').trim(),
|
|
53
|
+
code: typeof code === 'number' ? code : error ? 1 : 0,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Everything the runner needs to know about a repository before it touches it. */
|
|
59
|
+
export async function repoState(dir) {
|
|
60
|
+
const empty = { isRepo: false, branch: null, head: null, dirty: false, changed: [] };
|
|
61
|
+
if (!dir?.trim())
|
|
62
|
+
return { ...empty, problem: 'No repository folder is set.' };
|
|
63
|
+
if (!existsSync(dir))
|
|
64
|
+
return { ...empty, problem: `The folder ${dir} does not exist.` };
|
|
65
|
+
const inside = await git(dir, ['rev-parse', '--is-inside-work-tree']);
|
|
66
|
+
if (!inside.ok || inside.stdout !== 'true') {
|
|
67
|
+
return { ...empty, problem: `${dir} is not a git repository. Run "git init" there, or point version control at one.` };
|
|
68
|
+
}
|
|
69
|
+
const branch = await git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
70
|
+
const head = await git(dir, ['rev-parse', 'HEAD']);
|
|
71
|
+
const status = await git(dir, ['status', '--porcelain']);
|
|
72
|
+
const changed = porcelainPaths(status.stdout);
|
|
73
|
+
return {
|
|
74
|
+
isRepo: true,
|
|
75
|
+
// A repository with no commits yet has no HEAD to resolve, and that is a real state:
|
|
76
|
+
// a branch can still be made from it, but nothing can be "gone back to".
|
|
77
|
+
branch: branch.ok && branch.stdout !== 'HEAD' ? branch.stdout : null,
|
|
78
|
+
head: head.ok ? head.stdout : null,
|
|
79
|
+
dirty: changed.length > 0,
|
|
80
|
+
changed: changed.slice(0, 40),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Every path that would go into the next commit, file by file.
|
|
85
|
+
*
|
|
86
|
+
* `repoState` reads `git status --porcelain`, which folds a new folder into one line: a task
|
|
87
|
+
* that creates `web/` and installs into it shows as `?? web/`, and nothing under it is named.
|
|
88
|
+
* That is fine for "is the tree dirty" and useless for "what is in it" — the first task of
|
|
89
|
+
* every plan creates a folder, and what it contains is exactly what a commit-hygiene check
|
|
90
|
+
* has to see. `--untracked-files=all` lists each file; renames are reported by their new name.
|
|
91
|
+
*/
|
|
92
|
+
export async function workingTreePaths(dir, limit = 20_000) {
|
|
93
|
+
const status = await git(dir, ['status', '--porcelain', '--untracked-files=all']);
|
|
94
|
+
if (!status.ok || !status.stdout)
|
|
95
|
+
return [];
|
|
96
|
+
return porcelainPaths(status.stdout)
|
|
97
|
+
.map((p) => (p.includes(' -> ') ? p.slice(p.indexOf(' -> ') + 4) : p))
|
|
98
|
+
.slice(0, limit);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The paths out of `git status --porcelain`, whatever happened to the leading space.
|
|
102
|
+
*
|
|
103
|
+
* Porcelain puts two status characters and a space in front of every path, and the first of
|
|
104
|
+
* those characters is a space for a file modified in the working tree but not staged: ` M
|
|
105
|
+
* src/x.ts`. This runner trims every git result, which eats that leading space on the first
|
|
106
|
+
* line only — and cutting a fixed three characters then ate the first letter of the path with
|
|
107
|
+
* it. The operator saw "There are uncommitted changes (ules-engine/docs…)" and, worse, the
|
|
108
|
+
* commit-hygiene check was looking for `node_modules` in a list where the first entry could
|
|
109
|
+
* read `ode_modules`. So the status characters are matched rather than counted.
|
|
110
|
+
*/
|
|
111
|
+
export function porcelainPaths(stdout) {
|
|
112
|
+
if (!stdout)
|
|
113
|
+
return [];
|
|
114
|
+
return stdout
|
|
115
|
+
.split('\n')
|
|
116
|
+
.map((l) => l.replace(/^\s*[A-Z?!ADMRTUC ]{1,2}\s+/, '').trim())
|
|
117
|
+
.filter(Boolean);
|
|
118
|
+
}
|
|
119
|
+
/** Is git usable at all on this machine? */
|
|
120
|
+
export async function gitAvailable() {
|
|
121
|
+
const r = await git(process.cwd(), ['--version'], 10_000);
|
|
122
|
+
return r.ok ? r.stdout : null;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* A branch name git will accept, built from whatever the caller has.
|
|
126
|
+
*
|
|
127
|
+
* git's own rules are stricter than they look: no spaces, no `..`, no leading or trailing
|
|
128
|
+
* dots or slashes, no control characters, and a few reserved sequences. Rather than
|
|
129
|
+
* reimplementing the rule book, this reduces the name to a conservative alphabet and then
|
|
130
|
+
* asks git itself whether the result is legal.
|
|
131
|
+
*/
|
|
132
|
+
export function branchNameFrom(parts, prefix = 'cop/') {
|
|
133
|
+
const body = parts
|
|
134
|
+
.filter((p) => p !== undefined && `${p}`.trim() !== '')
|
|
135
|
+
.map((p) => `${p}`
|
|
136
|
+
.toLowerCase()
|
|
137
|
+
.replace(/[^\p{L}\p{N}._-]+/gu, '-')
|
|
138
|
+
.replace(/^[-.]+|[-.]+$/g, '')
|
|
139
|
+
.slice(0, 40))
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
.join('-');
|
|
142
|
+
const name = `${prefix}${body || 'task'}`.replace(/\.\.+/g, '.').replace(/\/+/g, '/');
|
|
143
|
+
return name.slice(0, 200);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* A branch name somebody chose, made safe without being made unrecognisable.
|
|
147
|
+
*
|
|
148
|
+
* The prefix is stripped if the name already carries it, because a plan written by a model
|
|
149
|
+
* that has been told the prefix is `cop/` will helpfully include it, and `cop/cop-thing` is
|
|
150
|
+
* the kind of small ugliness nobody ever goes back to fix. Everything else goes through the
|
|
151
|
+
* same slug as a derived name: this is a suggestion from outside the system, and it reaches
|
|
152
|
+
* git through exactly one path.
|
|
153
|
+
*/
|
|
154
|
+
export function plannedBranchName(raw, prefix = 'cop/', attempt = 1) {
|
|
155
|
+
const trimmed = raw.trim();
|
|
156
|
+
const p = prefix.trim();
|
|
157
|
+
const body = p && trimmed.toLowerCase().startsWith(p.toLowerCase()) ? trimmed.slice(p.length) : trimmed;
|
|
158
|
+
return branchNameFrom([body, attempt > 1 ? `a${attempt}` : undefined], prefix);
|
|
159
|
+
}
|
|
160
|
+
export async function isValidBranchName(dir, name) {
|
|
161
|
+
const r = await git(dir, ['check-ref-format', '--branch', name]);
|
|
162
|
+
return r.ok;
|
|
163
|
+
}
|
|
164
|
+
export async function branchExists(dir, name) {
|
|
165
|
+
const r = await git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`]);
|
|
166
|
+
return r.ok && r.stdout.length > 0;
|
|
167
|
+
}
|
|
168
|
+
/** A name nothing is using yet, by adding `-2`, `-3` until one is free. */
|
|
169
|
+
export async function freeBranchName(dir, wanted) {
|
|
170
|
+
if (!(await branchExists(dir, wanted)))
|
|
171
|
+
return wanted;
|
|
172
|
+
for (let i = 2; i < 100; i += 1) {
|
|
173
|
+
const candidate = `${wanted}-${i}`;
|
|
174
|
+
if (!(await branchExists(dir, candidate)))
|
|
175
|
+
return candidate;
|
|
176
|
+
}
|
|
177
|
+
return `${wanted}-${Date.now()}`;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Creates a branch and switches to it.
|
|
181
|
+
*
|
|
182
|
+
* `from` is a commit or a branch to start at. Leaving it out starts from where the repository
|
|
183
|
+
* already is. This is how "go back to the code as it was before that task" is done: the task
|
|
184
|
+
* recorded the commit it started at, and a new branch is cut from that commit. The branches
|
|
185
|
+
* that already exist are not touched, so nothing that was done in between is lost.
|
|
186
|
+
*/
|
|
187
|
+
export async function createBranch(dir, name, from) {
|
|
188
|
+
const args = from ? ['checkout', '-b', name, from] : ['checkout', '-b', name];
|
|
189
|
+
return await git(dir, args);
|
|
190
|
+
}
|
|
191
|
+
export async function checkoutExisting(dir, name) {
|
|
192
|
+
return await git(dir, ['checkout', name]);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Commits everything in the working tree, including files git does not track yet.
|
|
196
|
+
*
|
|
197
|
+
* Returns `committed: false` when there was nothing to commit, which is an ordinary outcome:
|
|
198
|
+
* a task that only read things leaves no changes, and that is not a failure.
|
|
199
|
+
*
|
|
200
|
+
* The author is set per command rather than in the repository's configuration, so a machine
|
|
201
|
+
* with no `user.email` still works and the operator's own identity is never written to.
|
|
202
|
+
*/
|
|
203
|
+
export async function commitAll(dir, message, author = 'copilot-operator <copilot-operator@localhost>') {
|
|
204
|
+
const add = await git(dir, ['add', '-A']);
|
|
205
|
+
if (!add.ok)
|
|
206
|
+
return { committed: false, problem: add.stderr || 'git add failed' };
|
|
207
|
+
const staged = await git(dir, ['diff', '--cached', '--name-only']);
|
|
208
|
+
if (staged.stdout.length === 0)
|
|
209
|
+
return { committed: false };
|
|
210
|
+
const commit = await git(dir, [
|
|
211
|
+
'-c',
|
|
212
|
+
`user.name=${author.split('<')[0].trim()}`,
|
|
213
|
+
'-c',
|
|
214
|
+
`user.email=${author.includes('<') ? author.split('<')[1].replace('>', '').trim() : 'copilot-operator@localhost'}`,
|
|
215
|
+
'commit',
|
|
216
|
+
'--no-verify',
|
|
217
|
+
'-m',
|
|
218
|
+
message,
|
|
219
|
+
]);
|
|
220
|
+
if (!commit.ok)
|
|
221
|
+
return { committed: false, problem: commit.stderr || commit.stdout || 'git commit failed' };
|
|
222
|
+
const head = await git(dir, ['rev-parse', 'HEAD']);
|
|
223
|
+
return { committed: true, commit: head.ok ? head.stdout : undefined };
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* What one commit touched: the files, and how many lines went in and out of each.
|
|
227
|
+
*
|
|
228
|
+
* Read once, at the moment of committing, and kept on the task. The alternative is asking git
|
|
229
|
+
* again whenever the UI wants to show it, which fails the day the branch is gone, the folder
|
|
230
|
+
* has moved or the operator is looking at the record of a machine they no longer have.
|
|
231
|
+
*/
|
|
232
|
+
export async function commitFiles(dir, commit) {
|
|
233
|
+
const r = await git(dir, ['show', '--numstat', '--format=', commit]);
|
|
234
|
+
if (!r.ok || !r.stdout)
|
|
235
|
+
return [];
|
|
236
|
+
return r.stdout
|
|
237
|
+
.split('\n')
|
|
238
|
+
.map((line) => line.split('\t'))
|
|
239
|
+
.filter((parts) => parts.length === 3 && parts[2].trim() !== '')
|
|
240
|
+
.map(([added, removed, path]) => ({
|
|
241
|
+
path: path.trim(),
|
|
242
|
+
// A binary file reports "-" for both, which is a real answer and not a zero.
|
|
243
|
+
added: added === '-' ? -1 : Number(added) || 0,
|
|
244
|
+
removed: removed === '-' ? -1 : Number(removed) || 0,
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
/** The subject line of one commit, or empty when it cannot be read. */
|
|
248
|
+
export async function commitSubject(dir, commit) {
|
|
249
|
+
const r = await git(dir, ['log', '-1', '--format=%s', commit]);
|
|
250
|
+
return r.ok ? r.stdout.trim() : '';
|
|
251
|
+
}
|
|
252
|
+
/** One line per commit, newest first, for showing what a task produced. */
|
|
253
|
+
export async function commitsBetween(dir, fromCommit, toRef = 'HEAD') {
|
|
254
|
+
const r = await git(dir, ['log', '--oneline', `${fromCommit}..${toRef}`]);
|
|
255
|
+
return r.ok && r.stdout ? r.stdout.split('\n') : [];
|
|
256
|
+
}
|
|
257
|
+
/** True when the folder looks like a repository worth offering in the UI. */
|
|
258
|
+
export function looksLikeRepo(dir) {
|
|
259
|
+
return !!dir?.trim() && existsSync(join(dir, '.git'));
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Why version control cannot be turned on for a folder, or null when it can.
|
|
263
|
+
*
|
|
264
|
+
* Checked before the choice is saved rather than when a run reaches it. Version control that
|
|
265
|
+
* is on but inactive is the worst of both: the operator believes there is a way back, the
|
|
266
|
+
* runner quietly branches nothing, and the first anyone knows of it is a task card an hour
|
|
267
|
+
* later. Making the repository a precondition of the switch means the switch tells the truth.
|
|
268
|
+
*
|
|
269
|
+
* An empty folder is not an error here. It means nothing has been chosen yet, and the panel
|
|
270
|
+
* says that in its own words.
|
|
271
|
+
*/
|
|
272
|
+
export function repoUnusableReason(dir) {
|
|
273
|
+
const d = (dir ?? '').trim();
|
|
274
|
+
if (!d)
|
|
275
|
+
return null;
|
|
276
|
+
if (!existsSync(d)) {
|
|
277
|
+
return (`The folder ${d} does not exist, so there is no repository to work in. ` +
|
|
278
|
+
'Create the project first, make it a git repository, and then point version control at it.');
|
|
279
|
+
}
|
|
280
|
+
if (!existsSync(join(d, '.git'))) {
|
|
281
|
+
return (`${d} is not a git repository: it has no .git folder. ` +
|
|
282
|
+
'Version control cannot make a branch or a commit there. Run "git init" in that folder, ' +
|
|
283
|
+
'or choose a folder that is already a repository.');
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { findSuspicious } from './commitHygiene.js';
|
|
2
|
+
import { branchNameFrom, commitAll, commitFiles, commitsBetween, commitSubject, createBranch, checkoutExisting, freeBranchName, isValidBranchName, plannedBranchName, repoState } from './git.js';
|
|
3
|
+
/** Which repository a session works in: its own setting, else the project it mirrors. */
|
|
4
|
+
export function repoDirOf(session) {
|
|
5
|
+
return (session.vcs?.repoDir?.trim() || session.mirror.rootDir?.trim() || '').trim();
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Puts the repository on the right branch for a task and reports where it starts from.
|
|
9
|
+
*
|
|
10
|
+
* A problem is never fatal. Version control failing is a reason to tell the operator and carry
|
|
11
|
+
* on without it, not a reason to throw away a queue of work: the task itself may not even
|
|
12
|
+
* touch the repository.
|
|
13
|
+
*/
|
|
14
|
+
export async function prepareForTask(session, task, bus, saveSession) {
|
|
15
|
+
const settings = session.vcs;
|
|
16
|
+
if (!settings?.enabled)
|
|
17
|
+
return { vcs: {}, note: '' };
|
|
18
|
+
const dir = repoDirOf(session);
|
|
19
|
+
const state = await repoState(dir);
|
|
20
|
+
if (!state.isRepo) {
|
|
21
|
+
const problem = state.problem ?? 'The repository could not be read.';
|
|
22
|
+
bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-unavailable', level: 'warn',
|
|
23
|
+
message: `version control is on but inactive: ${problem}` });
|
|
24
|
+
return { vcs: { problem }, note: '' };
|
|
25
|
+
}
|
|
26
|
+
// A dirty tree is the operator's own work in progress. Committing it under the bot's name
|
|
27
|
+
// or moving it to another branch would both be decisions that are not ours to make.
|
|
28
|
+
if (state.dirty) {
|
|
29
|
+
const problem = `the repository has uncommitted changes (${state.changed.slice(0, 5).join(', ')}` +
|
|
30
|
+
`${state.changed.length > 5 ? `, and ${state.changed.length - 5} more` : ''}). ` +
|
|
31
|
+
'Commit or stash them, so the task starts from a known state.';
|
|
32
|
+
bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-dirty', level: 'warn',
|
|
33
|
+
message: `version control is on but inactive: ${problem}` });
|
|
34
|
+
return { vcs: { problem }, note: '' };
|
|
35
|
+
}
|
|
36
|
+
// The session's base is fixed the first time it runs: every per-task branch is cut from it,
|
|
37
|
+
// which is what makes the tasks independent of each other rather than of the calendar.
|
|
38
|
+
let base = session.vcsBaseCommit;
|
|
39
|
+
if (!base && state.head) {
|
|
40
|
+
base = state.head;
|
|
41
|
+
await saveSession((s) => {
|
|
42
|
+
s.vcsBaseCommit = base;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const attempt = task.attempt ?? 1;
|
|
46
|
+
const prefix = settings.branchPrefix || 'cop/';
|
|
47
|
+
if (settings.branchMode === 'per-session') {
|
|
48
|
+
const planned = settings.branchName?.trim();
|
|
49
|
+
const wanted = planned
|
|
50
|
+
? plannedBranchName(planned, prefix)
|
|
51
|
+
: branchNameFrom([session.name, session.id.slice(0, 13)], prefix);
|
|
52
|
+
return await switchTo(session, task, dir, wanted, base, bus, { reuseExisting: true });
|
|
53
|
+
}
|
|
54
|
+
// A name the task carries wins over one derived from its title: whoever wrote the plan knew
|
|
55
|
+
// what the task was for, and a title is only ever a label for the list.
|
|
56
|
+
const plannedBranch = task.vcsPlan?.branch?.trim();
|
|
57
|
+
const wanted = plannedBranch
|
|
58
|
+
? plannedBranchName(plannedBranch, prefix, attempt)
|
|
59
|
+
: branchNameFrom([session.name, task.title, attempt > 1 ? `a${attempt}` : undefined], prefix);
|
|
60
|
+
// A re-run starts from where that task started the first time, not from where the previous
|
|
61
|
+
// attempt ended. That is the whole point of recording the base commit.
|
|
62
|
+
const from = firstAttemptBase(task) ?? base;
|
|
63
|
+
return await switchTo(session, task, dir, wanted, from, bus, { reuseExisting: false });
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Which branch has the complete work of a session, when one does.
|
|
67
|
+
*
|
|
68
|
+
* Answered from the session's own record rather than from git, because the question is asked
|
|
69
|
+
* after the run, from a page, and often about a repository whose HEAD is somewhere else: in
|
|
70
|
+
* per-task mode HEAD is left on whichever task ran last, which for a nine-task plan was an
|
|
71
|
+
* audit branch that did not carry the README written one task earlier.
|
|
72
|
+
*/
|
|
73
|
+
export function sessionBranches(session) {
|
|
74
|
+
const mode = session.vcs?.branchMode ?? 'per-task';
|
|
75
|
+
const branches = session.tasks
|
|
76
|
+
.filter((t) => !!t.vcs?.branch)
|
|
77
|
+
.map((t) => ({ title: t.title, branch: t.vcs?.branch, commit: t.vcs?.commit, status: t.status }));
|
|
78
|
+
if (mode === 'per-session') {
|
|
79
|
+
const planned = session.vcs?.branchName?.trim();
|
|
80
|
+
const complete = branches[branches.length - 1]?.branch ?? (planned ? plannedBranchName(planned, session.vcs?.branchPrefix || 'cop/') : undefined);
|
|
81
|
+
return { mode, complete, branches };
|
|
82
|
+
}
|
|
83
|
+
return { mode, branches };
|
|
84
|
+
}
|
|
85
|
+
/** The commit the task's first attempt started from, if it has one. */
|
|
86
|
+
function firstAttemptBase(task) {
|
|
87
|
+
return earliestBase(task);
|
|
88
|
+
}
|
|
89
|
+
async function switchTo(session, task, dir, wantedName, from, bus, opts) {
|
|
90
|
+
if (!(await isValidBranchName(dir, wantedName))) {
|
|
91
|
+
const problem = `"${wantedName}" is not a name git accepts.`;
|
|
92
|
+
bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-problem', level: 'warn', message: problem });
|
|
93
|
+
return { vcs: { problem }, note: '' };
|
|
94
|
+
}
|
|
95
|
+
const state = await repoState(dir);
|
|
96
|
+
let name = wantedName;
|
|
97
|
+
let result;
|
|
98
|
+
if (opts.reuseExisting && state.branch === wantedName) {
|
|
99
|
+
result = { ok: true, stdout: '', stderr: '', code: 0 };
|
|
100
|
+
}
|
|
101
|
+
else if (opts.reuseExisting) {
|
|
102
|
+
const existing = await checkoutExisting(dir, wantedName);
|
|
103
|
+
result = existing.ok ? existing : await createBranch(dir, wantedName, from);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
name = await freeBranchName(dir, wantedName);
|
|
107
|
+
result = await createBranch(dir, name, from);
|
|
108
|
+
}
|
|
109
|
+
if (!result.ok) {
|
|
110
|
+
const problem = result.stderr || result.stdout || 'the branch could not be created';
|
|
111
|
+
bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-problem', level: 'warn',
|
|
112
|
+
message: `version control is on but inactive: ${problem}` });
|
|
113
|
+
return { vcs: { problem }, note: '' };
|
|
114
|
+
}
|
|
115
|
+
const after = await repoState(dir);
|
|
116
|
+
const vcs = { branch: after.branch ?? name, baseCommit: after.head ?? from };
|
|
117
|
+
bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-branch', level: 'info',
|
|
118
|
+
message: `working on branch ${vcs.branch}${from ? ` (from ${from.slice(0, 8)})` : ''}`, data: { ...vcs, repoDir: dir } });
|
|
119
|
+
// What the task stands on, and what it does not: the base commit by name, and the other
|
|
120
|
+
// tasks of this session that already ran, with the branch each of them worked on.
|
|
121
|
+
const subject = vcs.baseCommit ? await commitSubject(dir, vcs.baseCommit) : '';
|
|
122
|
+
const mode = session.vcs?.branchMode ?? 'per-task';
|
|
123
|
+
// On this branch (per-session: their work is here) or on others (per-task: it is not). A
|
|
124
|
+
// session whose mode was switched mid-way has both, and each kind is reported by where it is.
|
|
125
|
+
const earlier = session.tasks
|
|
126
|
+
.filter((t) => t.id !== task.id && !!t.vcs?.branch && t.status !== 'queued')
|
|
127
|
+
.filter((t) => (mode === 'per-session' ? t.vcs?.branch === vcs.branch : t.vcs?.branch !== vcs.branch))
|
|
128
|
+
.map((t) => ({ title: t.title, branch: t.vcs?.branch }));
|
|
129
|
+
const note = noteFor(dir, vcs, {
|
|
130
|
+
mode,
|
|
131
|
+
base: vcs.baseCommit ? { commit: vcs.baseCommit, subject } : undefined,
|
|
132
|
+
earlier,
|
|
133
|
+
});
|
|
134
|
+
return { vcs, note };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* What level 1 tells Copilot when version control is on.
|
|
138
|
+
*
|
|
139
|
+
* Copilot has to know the repository is being managed, or it will helpfully create its own
|
|
140
|
+
* branch, commit half-way through, or switch away in the middle of a task. It is written as a
|
|
141
|
+
* plain instruction with the reason attached, because a rule without a reason is the kind a
|
|
142
|
+
* model talks itself out of.
|
|
143
|
+
*/
|
|
144
|
+
export function noteFor(repoDir, vcs, ctx = { mode: 'per-task', earlier: [] }) {
|
|
145
|
+
if (!vcs.branch)
|
|
146
|
+
return '';
|
|
147
|
+
const base = ctx.base
|
|
148
|
+
? `\`${ctx.base.commit.slice(0, 8)}\`${ctx.base.subject ? ` ("${ctx.base.subject}")` : ''}`
|
|
149
|
+
: 'the current commit';
|
|
150
|
+
const named = ctx.earlier.map((e) => `"${e.title}"`).join(', ');
|
|
151
|
+
/*
|
|
152
|
+
* Which earlier work is in the tree and which is not, said outright. In per-task mode every
|
|
153
|
+
* branch is cut from the same commit, so an audit task that ran after a README task was
|
|
154
|
+
* looking at a tree without the README — and had no way to know, because until this note
|
|
155
|
+
* was written it was told only the branch's name.
|
|
156
|
+
*/
|
|
157
|
+
const standing = ctx.mode === 'per-session'
|
|
158
|
+
? ctx.earlier.length > 0
|
|
159
|
+
? `This branch already carries the work of ${ctx.earlier.length} earlier task(s) of this session — ${named} — so their files are in your working tree.`
|
|
160
|
+
: 'This is the first task on this branch.'
|
|
161
|
+
: ctx.earlier.length > 0
|
|
162
|
+
? `Every task of this session gets its own branch from that same commit, so the work of the earlier tasks is NOT in your working tree: ` +
|
|
163
|
+
`${ctx.earlier.map((e) => `"${e.title}" is on \`${e.branch}\``).join(', ')}. If this task depends on what one of them produced, say so in the summary rather than looking for files that are not here.`
|
|
164
|
+
: 'Every task of this session gets its own branch from that same commit.';
|
|
165
|
+
return [
|
|
166
|
+
'## Version control',
|
|
167
|
+
'',
|
|
168
|
+
`The runner has already put ${repoDir} on the branch \`${vcs.branch}\`, created for this task from ${base},`,
|
|
169
|
+
'and it will commit whatever you change when the task finishes.',
|
|
170
|
+
'',
|
|
171
|
+
standing,
|
|
172
|
+
'',
|
|
173
|
+
'- Do not create branches, switch branches, commit, stash, reset or revert. That is the',
|
|
174
|
+
" runner's job, and two of us doing it would leave the repository in a state neither of us",
|
|
175
|
+
' expects.',
|
|
176
|
+
'- Do not push anything. Pushing is the operator\'s decision, made by hand, afterwards.',
|
|
177
|
+
'- Read-only git is fine: `git status`, `git diff`, `git log` tell you where you are.',
|
|
178
|
+
'- Change files as the task requires. You do not need to preserve the old ones by copying',
|
|
179
|
+
' them aside: the previous state is already a commit, and it can be returned to.',
|
|
180
|
+
].join('\n');
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Commits what the task changed, after it has ended.
|
|
184
|
+
*
|
|
185
|
+
* Runs whatever the outcome was. A failed task that left changes behind is exactly the case
|
|
186
|
+
* where having them on a branch, rather than loose in the tree, is worth the most.
|
|
187
|
+
*/
|
|
188
|
+
export async function commitTaskResult(session, task, outcome, bus) {
|
|
189
|
+
const settings = session.vcs;
|
|
190
|
+
const current = task.vcs ?? {};
|
|
191
|
+
if (!settings?.enabled || !settings.commitOnFinish || !current.branch)
|
|
192
|
+
return current;
|
|
193
|
+
const dir = repoDirOf(session);
|
|
194
|
+
const message = commitMessage(task, outcome);
|
|
195
|
+
const result = await commitAll(dir, message);
|
|
196
|
+
if (result.problem) {
|
|
197
|
+
bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-problem', level: 'warn',
|
|
198
|
+
message: `nothing was committed: ${result.problem}` });
|
|
199
|
+
return { ...current, problem: result.problem };
|
|
200
|
+
}
|
|
201
|
+
if (!result.committed) {
|
|
202
|
+
bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-nothing', level: 'info',
|
|
203
|
+
message: `nothing to commit on ${current.branch}: the task changed no files` });
|
|
204
|
+
return current;
|
|
205
|
+
}
|
|
206
|
+
const commits = current.baseCommit ? await commitsBetween(dir, current.baseCommit) : [];
|
|
207
|
+
const files = result.commit ? await commitFiles(dir, result.commit) : [];
|
|
208
|
+
bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-commit', level: 'info',
|
|
209
|
+
message: `committed on ${current.branch} as ${result.commit?.slice(0, 8)}: ` +
|
|
210
|
+
`${files.length} file(s) — push it yourself when you are ready`,
|
|
211
|
+
data: { branch: current.branch, commit: result.commit, files: files.length } });
|
|
212
|
+
// What went in that should not have. Read from the commit itself rather than from the check
|
|
213
|
+
// that pointed it out, so the record is what happened and not what was noticed.
|
|
214
|
+
const suspicious = findSuspicious(files.map((f) => f.path));
|
|
215
|
+
if (suspicious.length > 0) {
|
|
216
|
+
bus.publish({ sessionId: session.id, taskId: task.id, type: 'vcs-suspicious', level: 'warn',
|
|
217
|
+
message: `${suspicious.length} committed file(s) look like tool output or secrets: ` +
|
|
218
|
+
suspicious.map((s) => `${s.path} (${s.reason})`).join('; '),
|
|
219
|
+
data: { commit: result.commit, suspicious } });
|
|
220
|
+
}
|
|
221
|
+
return { ...current, commit: result.commit, commits, files, ...(suspicious.length > 0 ? { suspicious } : {}) };
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* A commit message that says what was asked and how it ended, in git's own shape.
|
|
225
|
+
*
|
|
226
|
+
* A task can carry its own message, which is what an imported plan sets: the model that wrote
|
|
227
|
+
* the plan knew what the change was for before it was made, and that reads better in a log
|
|
228
|
+
* than a title typed into a form. Its first line is the subject and the rest, if there is any,
|
|
229
|
+
* becomes the first paragraph of the body. Everything the runner adds afterwards — the
|
|
230
|
+
* summary, how it ended, the trailer — is added either way, because none of it can be known
|
|
231
|
+
* in advance.
|
|
232
|
+
*/
|
|
233
|
+
export function commitMessage(task, outcome) {
|
|
234
|
+
const planned = task.vcsPlan?.commitMessage?.trim();
|
|
235
|
+
const [plannedSubject = '', ...plannedRest] = (planned ?? '').split('\n');
|
|
236
|
+
const subject = (planned ? plannedSubject : task.title).replace(/\s+/g, ' ').trim().slice(0, 72) || 'copilot-operator task';
|
|
237
|
+
const paragraphs = [
|
|
238
|
+
plannedRest.join('\n').trim() || undefined,
|
|
239
|
+
outcome.summary?.trim(),
|
|
240
|
+
// What the model could not do as the task said. In the commit because that is where the
|
|
241
|
+
// next person looks for why a file is not what the plan describes.
|
|
242
|
+
outcome.deviations && outcome.deviations.length > 0
|
|
243
|
+
? `Not as the task said:\n${outcome.deviations.map((d) => `- ${d.instruction}\n did: ${d.did}\n because: ${d.why}`).join('\n')}`
|
|
244
|
+
: undefined,
|
|
245
|
+
outcome.reason
|
|
246
|
+
? `Ended ${outcome.status}: ${outcome.reason}`
|
|
247
|
+
: outcome.status !== 'done'
|
|
248
|
+
? `Ended ${outcome.status}.`
|
|
249
|
+
: undefined,
|
|
250
|
+
[`Task: ${task.title}`, `Attempt: ${task.attempt ?? 1}`, 'Committed by copilot-operator. Not pushed.'].join('\n'),
|
|
251
|
+
].filter((p) => !!p && p.trim().length > 0);
|
|
252
|
+
return `${subject}\n\n${paragraphs.join('\n\n')}\n`;
|
|
253
|
+
}
|
|
254
|
+
/** The earliest starting point recorded for this task, across all of its attempts. */
|
|
255
|
+
function earliestBase(task) {
|
|
256
|
+
const fromAttempts = (task.attempts ?? []).map((a) => a.vcs?.baseCommit).find(Boolean);
|
|
257
|
+
return fromAttempts ?? task.vcs?.baseCommit;
|
|
258
|
+
}
|
|
259
|
+
/** What restoring this task would do, asked before anything is done. */
|
|
260
|
+
export async function restorePreview(session, task) {
|
|
261
|
+
const repoDir = repoDirOf(session);
|
|
262
|
+
const empty = { ok: false, repoDir, leftBehind: [] };
|
|
263
|
+
if (!session.vcs?.enabled)
|
|
264
|
+
return { ...empty, problem: 'Version control is off for this session.' };
|
|
265
|
+
const base = earliestBase(task);
|
|
266
|
+
if (!base) {
|
|
267
|
+
return {
|
|
268
|
+
...empty,
|
|
269
|
+
problem: 'This task never recorded where it started, so there is nothing to go back to. That happens when ' +
|
|
270
|
+
'version control was off or inactive at the time it ran.',
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const state = await repoState(repoDir);
|
|
274
|
+
if (!state.isRepo)
|
|
275
|
+
return { ...empty, problem: state.problem ?? 'The repository could not be read.' };
|
|
276
|
+
if (state.dirty) {
|
|
277
|
+
return {
|
|
278
|
+
...empty,
|
|
279
|
+
baseCommit: base,
|
|
280
|
+
currentBranch: state.branch ?? undefined,
|
|
281
|
+
problem: `There are uncommitted changes (${state.changed.slice(0, 3).join(', ')}${state.changed.length > 3 ? '…' : ''}). ` +
|
|
282
|
+
'Switching branches now would carry them along or lose them, and neither is this tool\'s decision to make. ' +
|
|
283
|
+
'Commit or stash them first.',
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const prefix = session.vcs.branchPrefix || 'cop/';
|
|
287
|
+
return {
|
|
288
|
+
ok: true,
|
|
289
|
+
repoDir,
|
|
290
|
+
baseCommit: base,
|
|
291
|
+
currentBranch: state.branch ?? undefined,
|
|
292
|
+
leftBehind: await commitsBetween(repoDir, base, 'HEAD'),
|
|
293
|
+
keptOn: state.branch ?? undefined,
|
|
294
|
+
branchName: await freeBranchName(repoDir, plannedBranchName(`restore-${task.title}`, prefix)),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/** Does it: a new branch at that commit, checked out. Nothing else changes. */
|
|
298
|
+
export async function restoreToBase(session, task, bus) {
|
|
299
|
+
const preview = await restorePreview(session, task);
|
|
300
|
+
if (!preview.ok || !preview.baseCommit || !preview.branchName) {
|
|
301
|
+
return { ok: false, problem: preview.problem ?? 'The restore could not be prepared.' };
|
|
302
|
+
}
|
|
303
|
+
const result = await createBranch(preview.repoDir, preview.branchName, preview.baseCommit);
|
|
304
|
+
if (!result.ok) {
|
|
305
|
+
return { ok: false, problem: result.stderr || result.stdout || 'the branch could not be created' };
|
|
306
|
+
}
|
|
307
|
+
bus.publish({
|
|
308
|
+
sessionId: session.id,
|
|
309
|
+
taskId: task.id,
|
|
310
|
+
type: 'vcs-restored',
|
|
311
|
+
level: 'info',
|
|
312
|
+
message: `restored the code as it was before "${task.title}": ${preview.repoDir} is on ${preview.branchName} at ` +
|
|
313
|
+
`${preview.baseCommit.slice(0, 8)}` +
|
|
314
|
+
(preview.leftBehind.length > 0 ? `; ${preview.leftBehind.length} later commit(s) stay on ${preview.keptOn}` : ''),
|
|
315
|
+
data: { branch: preview.branchName, commit: preview.baseCommit, leftBehind: preview.leftBehind.length },
|
|
316
|
+
});
|
|
317
|
+
return {
|
|
318
|
+
ok: true,
|
|
319
|
+
branch: preview.branchName,
|
|
320
|
+
commit: preview.baseCommit,
|
|
321
|
+
leftBehind: preview.leftBehind,
|
|
322
|
+
keptOn: preview.keptOn,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
/** What the UI shows before a run: is version control going to work here? */
|
|
326
|
+
export async function vcsPreflight(session) {
|
|
327
|
+
const settings = session.vcs;
|
|
328
|
+
if (!settings?.enabled)
|
|
329
|
+
return { ok: false, repoDir: '', problem: 'off' };
|
|
330
|
+
const repoDir = repoDirOf(session);
|
|
331
|
+
const state = await repoState(repoDir);
|
|
332
|
+
if (!state.isRepo)
|
|
333
|
+
return { ok: false, repoDir, problem: state.problem };
|
|
334
|
+
if (state.dirty) {
|
|
335
|
+
return {
|
|
336
|
+
ok: false,
|
|
337
|
+
repoDir,
|
|
338
|
+
branch: state.branch ?? undefined,
|
|
339
|
+
problem: `There are uncommitted changes (${state.changed.slice(0, 3).join(', ')}${state.changed.length > 3 ? '…' : ''}). Commit or stash them first.`,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return { ok: true, repoDir, branch: state.branch ?? undefined };
|
|
343
|
+
}
|