copilot-operator 0.1.11 → 0.1.13

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.
Files changed (60) hide show
  1. package/dist/src/api/operator.controller.js +1 -1
  2. package/dist/src/api/operator.service.js +98 -8
  3. package/dist/src/config/schema.js +14 -3
  4. package/dist/src/context/desktopMirror.js +73 -13
  5. package/dist/src/context/projectMirror.js +69 -1
  6. package/dist/src/orchestrator/contract.js +56 -1
  7. package/dist/src/orchestrator/taskRunner.js +140 -16
  8. package/dist/src/plan/brief.js +39 -8
  9. package/dist/src/plan/importPlan.js +3 -0
  10. package/dist/src/plan/schema.js +26 -2
  11. package/dist/src/plan/script.js +79 -75
  12. package/dist/src/plan/systemGuide.js +79 -15
  13. package/dist/src/session/compose.js +16 -1
  14. package/dist/src/session/exportRecord.js +8 -1
  15. package/dist/src/session/exports.js +3 -0
  16. package/dist/src/session/handoff.js +41 -3
  17. package/dist/src/session/model.js +9 -0
  18. package/dist/src/session/store.js +11 -7
  19. package/dist/src/session/story.js +74 -18
  20. package/dist/src/transport/chatTransport.js +1 -0
  21. package/dist/src/transport/copilotTransport.js +80 -61
  22. package/dist/src/vcs/git.js +92 -2
  23. package/dist/src/vcs/taskVcs.js +100 -19
  24. package/dist/web/404.html +2 -2
  25. package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +1 -0
  26. package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +1 -0
  27. package/dist/web/_next/static/chunks/867-84c19f047972ffc3.js +1 -0
  28. package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +1 -0
  29. package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +1 -0
  30. package/dist/web/_next/static/chunks/app/page-13572a726de0b7db.js +1 -0
  31. package/dist/web/_next/static/chunks/app/sessions/view/page-34d7988849f9aa76.js +1 -0
  32. package/dist/web/_next/static/css/{2ad2a74b7e07dcc9.css → a0642fdb8adf9edb.css} +1 -1
  33. package/dist/web/appearance.html +2 -2
  34. package/dist/web/appearance.txt +12 -12
  35. package/dist/web/defaults.html +2 -2
  36. package/dist/web/defaults.txt +12 -12
  37. package/dist/web/history.html +2 -2
  38. package/dist/web/history.txt +12 -12
  39. package/dist/web/import.html +2 -2
  40. package/dist/web/import.txt +12 -12
  41. package/dist/web/index.html +2 -2
  42. package/dist/web/index.txt +12 -12
  43. package/dist/web/level1.html +2 -2
  44. package/dist/web/level1.txt +12 -12
  45. package/dist/web/presets.html +2 -2
  46. package/dist/web/presets.txt +12 -12
  47. package/dist/web/sessions/view.html +2 -2
  48. package/dist/web/sessions/view.txt +12 -12
  49. package/dist/web/system.html +2 -2
  50. package/dist/web/system.txt +12 -12
  51. package/package.json +6 -3
  52. package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +0 -1
  53. package/dist/web/_next/static/chunks/549-8f69ef8086802748.js +0 -1
  54. package/dist/web/_next/static/chunks/867-f82c74b7b2ed5c54.js +0 -1
  55. package/dist/web/_next/static/chunks/app/defaults/page-abfa73fb08822e36.js +0 -1
  56. package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +0 -1
  57. package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +0 -1
  58. package/dist/web/_next/static/chunks/app/sessions/view/page-d7e1f83d13c6356b.js +0 -1
  59. /package/dist/web/_next/static/{oyfXbuB_cq0sRZP7ukHlQ → NosxirjkOgV-Boyq8dGPl}/_buildManifest.js +0 -0
  60. /package/dist/web/_next/static/{oyfXbuB_cq0sRZP7ukHlQ → NosxirjkOgV-Boyq8dGPl}/_ssgManifest.js +0 -0
@@ -12,9 +12,42 @@
12
12
  * Nothing here is inferred. An entry is a file, or a section of the task log, or a field of
13
13
  * the task record; the reader decides what it means.
14
14
  */
15
- import { readdir, readFile } from 'node:fs/promises';
15
+ import { readFile, readdir, stat } from 'node:fs/promises';
16
16
  import { join } from 'node:path';
17
17
  const MAX_TEXT = 60_000;
18
+ /*
19
+ * When each thing happened.
20
+ *
21
+ * Every entry of the story is a file the runner wrote at the moment it happened: a reply is saved
22
+ * as it arrives, a step's log is opened as the step starts, a results file is written just before
23
+ * it is sent. So a file's creation time is the entry's time, for runs recorded long before anyone
24
+ * asked for times as much as for new ones — nothing had to be recorded differently. The modification
25
+ * time is the fallback on a file system that keeps no creation time. The opening message has no file
26
+ * of its own and takes its time from the transcript's first "message-sent".
27
+ */
28
+ async function createdAt(path) {
29
+ const info = await stat(path).catch(() => null);
30
+ if (!info)
31
+ return undefined;
32
+ const ms = info.birthtimeMs > 0 ? info.birthtimeMs : info.mtimeMs;
33
+ return new Date(ms).toISOString();
34
+ }
35
+ async function firstSentAt(dir) {
36
+ const text = await readFile(join(dir, 'transcript.jsonl'), 'utf8').catch(() => '');
37
+ for (const line of text.split('\n')) {
38
+ if (!line.includes('"message-sent"'))
39
+ continue;
40
+ try {
41
+ const e = JSON.parse(line);
42
+ if (e.type === 'message-sent' && e.at)
43
+ return e.at;
44
+ }
45
+ catch {
46
+ /* a line cut off by a crash */
47
+ }
48
+ }
49
+ return undefined;
50
+ }
18
51
  function clip(text) {
19
52
  return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}\n… (${text.length - MAX_TEXT} more characters in the run folder)` : text;
20
53
  }
@@ -73,6 +106,7 @@ async function stepsIn(dir) {
73
106
  const iteration = Number(m[1]);
74
107
  const parsed = parseStepLog(await readFile(join(dir, name), 'utf8').catch(() => ''));
75
108
  const entry = {
109
+ at: await createdAt(join(dir, name)),
76
110
  kind: 'step',
77
111
  iteration,
78
112
  id: Number(m[2]),
@@ -103,21 +137,41 @@ async function reviewRound(dir, round) {
103
137
  const read = async (name) => clip(await readFile(join(dir, name), 'utf8').catch(() => ''));
104
138
  const names = (await readdir(dir).catch(() => [])).sort();
105
139
  if (names.includes('00-brief.md'))
106
- entries.push({ kind: 'sent', iteration: 0, label: 'brief', text: await read('00-brief.md') });
140
+ entries.push({ kind: 'sent', iteration: 0, label: 'brief', text: await read('00-brief.md'), at: await createdAt(join(dir, '00-brief.md')) });
107
141
  const steps = await stepsIn(join(dir, 'steps'));
108
- const replies = names.filter((n) => /^\d+-review\.md$/.test(n)).sort();
109
- for (const [i, name] of replies.entries()) {
110
- const text = await read(name);
111
- const parsed = parseReply(text);
112
- entries.push({ kind: 'reply', iteration: i + 1, label: `review ${round}, reply ${i + 1}`, text, ...parsed });
113
- for (const step of steps.get(i + 1) ?? [])
142
+ /*
143
+ * The replies, by what they answer. `00-opening.md` is the answer to the brief — the one that
144
+ * proposes the first steps — and `NN-review.md` the answer to the results of iteration NN (the
145
+ * review saves it after sending them). So after reply k come the steps of iteration k + 1 and
146
+ * their results, then reply k + 1. The story used to read only the `NN-review` files and put each
147
+ * before the steps it came after; the times on the entries are what showed it.
148
+ */
149
+ const replyFor = new Map();
150
+ if (names.includes('00-opening.md'))
151
+ replyFor.set(0, '00-opening.md');
152
+ for (const n of names) {
153
+ const m = n.match(/^(\d+)-review\.md$/);
154
+ if (m)
155
+ replyFor.set(Number(m[1]), n);
156
+ }
157
+ const last = Math.max(0, ...replyFor.keys(), ...steps.keys());
158
+ for (let k = 0; k <= last; k += 1) {
159
+ const name = replyFor.get(k);
160
+ if (name) {
161
+ const text = await read(name);
162
+ entries.push({ kind: 'reply', iteration: k, label: `review ${round}, reply ${k + 1}`, text, ...parseReply(text), at: await createdAt(join(dir, name)) });
163
+ }
164
+ for (const step of steps.get(k + 1) ?? [])
114
165
  entries.push(step);
115
- if (names.includes(`iteration-${i + 1}.txt`))
116
- entries.push({ kind: 'sent', iteration: i + 1, label: `results ${i + 1}`, text: await read(`iteration-${i + 1}.txt`) });
166
+ if (names.includes(`iteration-${k + 1}.txt`)) {
167
+ entries.push({ kind: 'sent', iteration: k + 1, label: `results ${k + 1}`, text: await read(`iteration-${k + 1}.txt`), at: await createdAt(join(dir, `iteration-${k + 1}.txt`)) });
168
+ }
169
+ }
170
+ const replies = [...replyFor.values()];
171
+ if (names.includes('findings-sent.md')) {
172
+ entries.push({ kind: 'sent', iteration: replies.length, label: 'findings sent to the implementer', text: await read('findings-sent.md'), at: await createdAt(join(dir, 'findings-sent.md')) });
117
173
  }
118
- if (names.includes('findings-sent.md'))
119
- entries.push({ kind: 'sent', iteration: replies.length, label: 'findings sent to the implementer', text: await read('findings-sent.md') });
120
- return { kind: 'review', round, entries };
174
+ return { kind: 'review', round, entries, at: entries.find((e) => e.at)?.at };
121
175
  }
122
176
  export async function buildStory(runsDir, runId, task, live) {
123
177
  const dir = join(runsDir, runId);
@@ -126,7 +180,9 @@ export async function buildStory(runsDir, runId, task, live) {
126
180
  const entries = [];
127
181
  const opening = sections.find((s) => s.name === 'OPENING MESSAGE');
128
182
  if (opening)
129
- entries.push({ kind: 'sent', iteration: 0, label: 'opening message', text: clip(opening.text) });
183
+ entries.push({ kind: 'sent', iteration: 0, label: 'opening message', text: clip(opening.text), at: (await firstSentAt(dir)) ?? task.startedAt });
184
+ /** The results file of iteration k, as the report writer names it by default; its creation is when it was sent. */
185
+ const resultsAt = async (k) => await createdAt(join(dir, 'reports', `iteration-${k}.txt`));
130
186
  const steps = await stepsIn(join(dir, 'steps'));
131
187
  const reports = new Map();
132
188
  for (const s of sections) {
@@ -161,7 +217,7 @@ export async function buildStory(runsDir, runId, task, live) {
161
217
  for (const step of steps.get(k) ?? [])
162
218
  entries.push(step);
163
219
  if (reports.has(k))
164
- entries.push({ kind: 'sent', iteration: k, label: `results ${k}`, text: clip(reports.get(k)) });
220
+ entries.push({ kind: 'sent', iteration: k, label: `results ${k}`, text: clip(reports.get(k)), at: await resultsAt(k) });
165
221
  lastIteration = k;
166
222
  }
167
223
  else if (rv) {
@@ -170,14 +226,14 @@ export async function buildStory(runsDir, runId, task, live) {
170
226
  for (const step of steps.get(lastIteration + 1) ?? [])
171
227
  entries.push(step);
172
228
  if (reports.has(lastIteration + 1))
173
- entries.push({ kind: 'sent', iteration: lastIteration + 1, label: `results ${lastIteration + 1}`, text: clip(reports.get(lastIteration + 1)) });
229
+ entries.push({ kind: 'sent', iteration: lastIteration + 1, label: `results ${lastIteration + 1}`, text: clip(reports.get(lastIteration + 1)), at: await resultsAt(lastIteration + 1) });
174
230
  lastIteration += reports.has(lastIteration + 1) ? 1 : 0;
175
231
  if (!placedReviews.has(round)) {
176
232
  entries.push(await reviewRound(join(dir, 'review', String(round)), round));
177
233
  placedReviews.add(round);
178
234
  }
179
235
  }
180
- entries.push({ kind: 'reply', iteration: it ? Number(it[1]) : 0, label, text, ...parsed });
236
+ entries.push({ kind: 'reply', iteration: it ? Number(it[1]) : 0, label, text, ...parsed, at: await createdAt(join(dir, 'replies', name)) });
181
237
  }
182
238
  // Steps and results after the last reply (a task that ended without another reply), and
183
239
  // review rounds nobody replied to yet (one still going, or the last one that passed).
@@ -186,7 +242,7 @@ export async function buildStory(runsDir, runId, task, live) {
186
242
  for (const step of list)
187
243
  entries.push(step);
188
244
  if (reports.has(k))
189
- entries.push({ kind: 'sent', iteration: k, label: `results ${k}`, text: clip(reports.get(k)) });
245
+ entries.push({ kind: 'sent', iteration: k, label: `results ${k}`, text: clip(reports.get(k)), at: await resultsAt(k) });
190
246
  }
191
247
  }
192
248
  for (const round of reviewDirs) {
@@ -20,6 +20,7 @@
20
20
  * running in this process — a check script — can call `setTransportFactory`.
21
21
  */
22
22
  import { CopilotTransport } from './copilotTransport.js';
23
+ export { ReplyTimeoutError, isReplyTimeout } from './copilotTransport.js';
23
24
  const realChat = (opts) => new CopilotTransport(opts);
24
25
  let factory = realChat;
25
26
  /** A chat for these options: the real Copilot window, unless a check has put a scripted one in. */
@@ -18,6 +18,25 @@ import { landed } from './acceptance.js';
18
18
  import { Blocker, Css, Label, Model, Rename, Sidebar, Signal, Surface, TestId, Upload, Url } from './locators.js';
19
19
  import { acquireProfileLock } from './profileLock.js';
20
20
  import { parseChatId } from './chatSession.js';
21
+ /**
22
+ * The chat did not finish a reply within `copilot.replyTimeoutSec`.
23
+ *
24
+ * Its own class because it is a limit from the settings, not a fault: the runner ends the task
25
+ * `limit-reached` on it, so "Continue" carries the task on in the same chat, where the reply may
26
+ * well have finished by then. Any other error from the transport still fails the task.
27
+ */
28
+ export class ReplyTimeoutError extends Error {
29
+ seconds;
30
+ constructor(seconds, message) {
31
+ super(message);
32
+ this.seconds = seconds;
33
+ this.name = 'ReplyTimeoutError';
34
+ }
35
+ }
36
+ /** Recognised by name as well: a copy of this module loaded twice makes `instanceof` miss its own class. */
37
+ export function isReplyTimeout(e) {
38
+ return e instanceof ReplyTimeoutError || (e instanceof Error && e.name === 'ReplyTimeoutError' && typeof e.seconds === 'number');
39
+ }
21
40
  const ORIGIN = 'https://m365.cloud.microsoft';
22
41
  /** Escapes a model name so it can be matched literally inside a regular expression. */
23
42
  function escapeForRegExp(text) {
@@ -57,65 +76,65 @@ export class SendRejectedError extends Error {
57
76
  * impossible — the runner notes it before the click and waits for it to move, so a copy that
58
77
  * did not happen reads as a failure rather than as the previous answer.
59
78
  */
60
- export const CLIPBOARD_GUARD = `(() => {
61
- const state = { text: '', seq: 0 };
62
- try {
63
- Object.defineProperty(window, '__copClipboard', { value: state, enumerable: false });
64
- } catch (e) {
65
- window.__copClipboard = state;
66
- }
67
-
68
- const remember = (text) => {
69
- if (typeof text !== 'string' || text.length === 0) return;
70
- state.text = text;
71
- state.seq += 1;
72
- };
73
-
74
- const clip = navigator.clipboard;
75
- if (clip) {
76
- try {
77
- Object.defineProperty(clip, 'writeText', {
78
- configurable: true,
79
- writable: true,
80
- value: (text) => {
81
- remember(String(text));
82
- return Promise.resolve();
83
- },
84
- });
85
- } catch (e) { /* a frame that will not let its clipboard be replaced reads as a failure later */ }
86
-
87
- try {
88
- Object.defineProperty(clip, 'write', {
89
- configurable: true,
90
- writable: true,
91
- value: async (items) => {
92
- for (const item of items || []) {
93
- try {
94
- if (item && item.types && item.types.indexOf('text/plain') >= 0) {
95
- remember(await (await item.getType('text/plain')).text());
96
- }
97
- } catch (e) { /* one unreadable item is not a reason to drop the rest */ }
98
- }
99
- },
100
- });
101
- } catch (e) { /* as above */ }
102
- }
103
-
104
- // The old way: select text in a hidden field, then execCommand('copy'). The selection is
105
- // exactly the text being copied, so it is taken and the command is never run.
106
- try {
107
- const realExec = document.execCommand.bind(document);
108
- document.execCommand = (command, ...rest) => {
109
- if (String(command).toLowerCase() === 'copy') {
110
- const selected = String(window.getSelection() || '');
111
- if (selected.length > 0) {
112
- remember(selected);
113
- return true;
114
- }
115
- }
116
- return realExec(command, ...rest);
117
- };
118
- } catch (e) { /* as above */ }
79
+ export const CLIPBOARD_GUARD = `(() => {
80
+ const state = { text: '', seq: 0 };
81
+ try {
82
+ Object.defineProperty(window, '__copClipboard', { value: state, enumerable: false });
83
+ } catch (e) {
84
+ window.__copClipboard = state;
85
+ }
86
+
87
+ const remember = (text) => {
88
+ if (typeof text !== 'string' || text.length === 0) return;
89
+ state.text = text;
90
+ state.seq += 1;
91
+ };
92
+
93
+ const clip = navigator.clipboard;
94
+ if (clip) {
95
+ try {
96
+ Object.defineProperty(clip, 'writeText', {
97
+ configurable: true,
98
+ writable: true,
99
+ value: (text) => {
100
+ remember(String(text));
101
+ return Promise.resolve();
102
+ },
103
+ });
104
+ } catch (e) { /* a frame that will not let its clipboard be replaced reads as a failure later */ }
105
+
106
+ try {
107
+ Object.defineProperty(clip, 'write', {
108
+ configurable: true,
109
+ writable: true,
110
+ value: async (items) => {
111
+ for (const item of items || []) {
112
+ try {
113
+ if (item && item.types && item.types.indexOf('text/plain') >= 0) {
114
+ remember(await (await item.getType('text/plain')).text());
115
+ }
116
+ } catch (e) { /* one unreadable item is not a reason to drop the rest */ }
117
+ }
118
+ },
119
+ });
120
+ } catch (e) { /* as above */ }
121
+ }
122
+
123
+ // The old way: select text in a hidden field, then execCommand('copy'). The selection is
124
+ // exactly the text being copied, so it is taken and the command is never run.
125
+ try {
126
+ const realExec = document.execCommand.bind(document);
127
+ document.execCommand = (command, ...rest) => {
128
+ if (String(command).toLowerCase() === 'copy') {
129
+ const selected = String(window.getSelection() || '');
130
+ if (selected.length > 0) {
131
+ remember(selected);
132
+ return true;
133
+ }
134
+ }
135
+ return realExec(command, ...rest);
136
+ };
137
+ } catch (e) { /* as above */ }
119
138
  })();`;
120
139
  export class CopilotTransport {
121
140
  opts;
@@ -271,7 +290,7 @@ export class CopilotTransport {
271
290
  'sign-in': 'The profile is signed out. Run "cop login".',
272
291
  unknown: 'The page is not the Microsoft 365 Copilot chat.',
273
292
  };
274
- throw new Error(`${explain[where] ?? explain.unknown}
293
+ throw new Error(`${explain[where] ?? explain.unknown}
275
294
  Current URL: ${url}`);
276
295
  }
277
296
  /**
@@ -1119,7 +1138,7 @@ Current URL: ${url}`);
1119
1138
  }
1120
1139
  if (!sawNewTurn || !last.finished) {
1121
1140
  await this.dumpFailure(join(this.opts.transportDir, '..', 'failures'), 'reply-timeout').catch(() => undefined);
1122
- throw new Error(`Copilot did not finish a reply within ${Math.round(this.opts.replyTimeoutMs / 1000)}s ` +
1141
+ throw new ReplyTimeoutError(Math.round(this.opts.replyTimeoutMs / 1000), `Copilot did not finish a reply within ${Math.round(this.opts.replyTimeoutMs / 1000)}s ` +
1123
1142
  `(new turn seen: ${sawNewTurn}, still streaming: ${last.streaming}). ` +
1124
1143
  'A screenshot and an HTML dump are in the run folder.');
1125
1144
  }
@@ -44,9 +44,9 @@ function noHooksDir() {
44
44
  return process.env.SystemRoot ? join(process.env.SystemRoot, 'System32', 'copilot-operator-no-hooks') : '/nonexistent';
45
45
  }
46
46
  /** Runs one git command in a directory. Never throws: the caller decides what a failure means. */
47
- export function git(cwd, args, timeoutMs = 60_000) {
47
+ export function git(cwd, args, timeoutMs = 60_000, env) {
48
48
  return new Promise((resolve) => {
49
- execFile('git', [...SAFE_GIT, ...args], { cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
49
+ execFile('git', [...SAFE_GIT, ...args], { cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 8 * 1024 * 1024, ...(env ? { env } : {}) }, (error, stdout, stderr) => {
50
50
  const code = error?.code;
51
51
  resolve({
52
52
  ok: !error,
@@ -155,6 +155,21 @@ export function branchNameFrom(parts, prefix = 'cop/') {
155
155
  */
156
156
  export function plannedBranchName(raw, prefix = 'cop/', attempt = 1) {
157
157
  const trimmed = raw.trim();
158
+ /*
159
+ * A name with its own namespace — `recovery/apz-migration`, `feature/JIRA-123-schemas` — is the
160
+ * team's convention, and it is used as written: the prefix is only for names that have none.
161
+ * Flattening it to `cop/recovery-apz-migration` made a different branch from the one asked for,
162
+ * so a plan continuing a recovery branch worked beside it instead of on it (2026-09-30). Each
163
+ * segment is still made safe, keeping its case; git itself has the last word in `switchTo`.
164
+ */
165
+ if (trimmed.includes('/')) {
166
+ const segments = trimmed
167
+ .split('/')
168
+ .map((seg) => seg.replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^[-.]+|[-.]+$/g, ''))
169
+ .filter(Boolean);
170
+ if (segments.length > 1)
171
+ return `${segments.join('/')}${attempt > 1 ? `-a${attempt}` : ''}`.replace(/\.\.+/g, '.').slice(0, 200);
172
+ }
158
173
  const p = prefix.trim();
159
174
  const body = p && trimmed.toLowerCase().startsWith(p.toLowerCase()) ? trimmed.slice(p.length) : trimmed;
160
175
  return branchNameFrom([body, attempt > 1 ? `a${attempt}` : undefined], prefix);
@@ -163,6 +178,81 @@ export async function isValidBranchName(dir, name) {
163
178
  const r = await git(dir, ['check-ref-format', '--branch', name]);
164
179
  return r.ok;
165
180
  }
181
+ /**
182
+ * Brings a local branch up to date with its remote: `git fetch`, then a fast-forward and nothing else.
183
+ *
184
+ * Asked for on 2026-09-30, so a session's branch is cut from the code as it is on the server and not
185
+ * from whatever this checkout last pulled. Never a reset, a merge commit or a rewrite: a branch with
186
+ * commits of its own (ahead) or one that has gone its own way (diverged) is left as it is, and the
187
+ * outcome says so. The remote is the branch's upstream, or `origin` when it has none; a repository
188
+ * with neither is left alone. git is not allowed to ask for credentials — a prompt would hang the run,
189
+ * and a sign-in window on a machine nobody is watching is worse — so a remote that needs a password
190
+ * git does not already have fails the fetch, which is reported, not fatal.
191
+ */
192
+ export async function updateFromRemote(dir, branch) {
193
+ const local = await git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}^{commit}`]);
194
+ if (!local.ok || !local.stdout)
195
+ return { branch, outcome: 'failed', detail: `there is no local branch ${branch}` };
196
+ const upstream = await git(dir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branch}@{upstream}`]);
197
+ let remote = '';
198
+ let remoteBranch = branch;
199
+ if (upstream.ok && upstream.stdout.includes('/')) {
200
+ remote = upstream.stdout.slice(0, upstream.stdout.indexOf('/'));
201
+ remoteBranch = upstream.stdout.slice(remote.length + 1);
202
+ }
203
+ else {
204
+ const remotes = await git(dir, ['remote']);
205
+ const names = remotes.ok ? remotes.stdout.split(/\r?\n/).map((r) => r.trim()).filter(Boolean) : [];
206
+ if (names.includes('origin'))
207
+ remote = 'origin';
208
+ }
209
+ if (!remote)
210
+ return { branch, outcome: 'no-remote', detail: 'the repository has no remote to update from' };
211
+ const tracking = `${remote}/${remoteBranch}`;
212
+ const quiet = { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_ASKPASS: '', SSH_ASKPASS: '' };
213
+ const fetched = await git(dir, ['-c', 'credential.interactive=false', 'fetch', '--quiet', '--no-tags', remote, `+refs/heads/${remoteBranch}:refs/remotes/${tracking}`], 90_000, quiet);
214
+ if (!fetched.ok) {
215
+ return { branch, remote: tracking, outcome: 'fetch-failed', detail: (fetched.stderr || fetched.stdout || 'git fetch failed').split(/\r?\n/)[0] };
216
+ }
217
+ const theirs = await git(dir, ['rev-parse', '--verify', '--quiet', `refs/remotes/${tracking}^{commit}`]);
218
+ if (!theirs.ok || !theirs.stdout)
219
+ return { branch, remote: tracking, outcome: 'no-remote', detail: `${tracking} does not exist on the remote` };
220
+ const from = local.stdout;
221
+ const to = theirs.stdout;
222
+ if (from === to)
223
+ return { branch, remote: tracking, outcome: 'up-to-date', from, to };
224
+ if (await isAncestor(dir, to, from))
225
+ return { branch, remote: tracking, outcome: 'ahead', from, to, detail: `${branch} has commits ${tracking} does not; it was left as it is` };
226
+ if (!(await isAncestor(dir, from, to))) {
227
+ return { branch, remote: tracking, outcome: 'diverged', from, to, detail: `${branch} and ${tracking} have each gone their own way; ${branch} was left as it is` };
228
+ }
229
+ // Forward only. A branch that is checked out moves with its tree (clean, or the caller would not be
230
+ // here); one that is not is moved by its ref, and only if it still is where it was read.
231
+ const current = await git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']);
232
+ const moved = current.ok && current.stdout === branch
233
+ ? await git(dir, ['merge', '--ff-only', '--quiet', to])
234
+ : await git(dir, ['update-ref', `refs/heads/${branch}`, to, from]);
235
+ if (!moved.ok)
236
+ return { branch, remote: tracking, outcome: 'failed', from, to, detail: (moved.stderr || moved.stdout).split(/\r?\n/)[0] };
237
+ return { branch, remote: tracking, outcome: 'updated', from, to };
238
+ }
239
+ /** One line for the log and the session page. */
240
+ export function describeUpdate(u) {
241
+ const short = (c) => (c ? c.slice(0, 8) : '?');
242
+ switch (u.outcome) {
243
+ case 'updated':
244
+ return `${u.branch} brought up to date with ${u.remote}: ${short(u.from)} → ${short(u.to)}`;
245
+ case 'up-to-date':
246
+ return `${u.branch} was already up to date with ${u.remote}`;
247
+ default:
248
+ return `${u.branch} not updated from the remote: ${u.detail ?? u.outcome}`;
249
+ }
250
+ }
251
+ /** The local branches of a repository, by name; empty when it cannot be read. */
252
+ export async function localBranches(dir) {
253
+ const r = await git(dir, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']);
254
+ return r.ok ? r.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean) : [];
255
+ }
166
256
  export async function branchExists(dir, name) {
167
257
  const r = await git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`]);
168
258
  return r.ok && r.stdout.length > 0;