@ravshansbox/pi-next-issue 1.1.0 → 2.3.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/README.md CHANGED
@@ -1,55 +1,29 @@
1
1
  # pi-next-issue
2
2
 
3
- A [pi](https://github.com/earendil-works/pi) extension that works through the open GitHub issues of a repository in the background, while you keep using pi.
3
+ A [pi](https://github.com/earendil-works/pi) extension that works through the open GitHub issues of a repository in the background, while you keep using pi. The work is done by the [`next-issue`](../next-issue) worker, which this extension starts and talks to.
4
4
 
5
5
  ```bash
6
6
  pi install npm:@ravshansbox/pi-next-issue
7
7
  ```
8
8
 
9
- It needs `git` and an authenticated [`gh`](https://cli.github.com/). It works with github.com and GitHub Enterprise remotes.
9
+ It needs `git`, `npx` and an authenticated [`gh`](https://cli.github.com/). It works on macOS and Linux.
10
10
 
11
11
  ## Use
12
12
 
13
- Run these in pi, inside a git repository:
13
+ Run this in pi, inside a git repository:
14
14
 
15
- - `/next-issue start` starts a run.
16
- - `/next-issue stop` stops the run. The issue keeps its saved state, and the next run resumes it.
17
- - `/next-issue status` shows where the run is.
15
+ - `/next-issue` starts a run of this repository when none is going, and stops the run when one is going. A stop takes effect after the current round, and the issue keeps its saved state, so the next run resumes it.
18
16
 
19
- The footer shows the issue, the stage, the number of tool calls and the time spent on the issue, for example `next-issue #12 · review 1/3 · 37 tools · 4m12s`. A line above the editor shows the last tool call. In the terminal, both use the dim colour of the theme, like the rest of the footer. A note appears when an issue is merged or needs a human.
17
+ The footer shows the issue, the stage, the number of tool calls and the time spent on the issue, for example `next-issue #12 · review 1 · 37 tools · 4m12s`. After a run ends, it keeps the last summary. A line above the editor shows the last tool call. In the terminal, both use the dim colour of the theme, like the rest of the footer. A note appears when an issue is done, needs a human or fails.
20
18
 
21
- The run does not belong to a session: it goes on after `/new`, `/resume`, a fork or a reload, and the new session shows it. It stops when you quit pi.
19
+ ## Worker
22
20
 
23
- ## What it does
21
+ The extension does no work itself. On `/next-issue` it connects to the `next-issue` worker on the socket file `~/.pi/agent/next-issue.sock`. When no worker answers, it starts one with `npx -y next-issue@latest serve`, in the background, and writes the output of the worker to `~/.pi/agent/next-issue-worker.log`.
24
22
 
25
- Each loop fetches the 200 oldest open issues with the `status:todo` label and none of `status:in-progress`, `status:done`, `status:needs-human` or `status:blocked`, and the open issues with saved state that it can read. It takes the oldest one with saved state and none of `status:done`, `status:needs-human` or `status:blocked`, else the oldest one with the `status:todo` label and none of `status:in-progress`, `status:done`, `status:needs-human` or `status:blocked`. The run ends when no such issue is left, or at the first error. For each issue:
23
+ - Every pi on the machine shares the one worker, and every pi in a repository shows the run of that repository, whoever started it.
24
+ - Opening pi does not start a worker. It only shows a run when a worker is going.
25
+ - The run does not belong to pi: it goes on after `/new`, `/resume`, a fork, a reload or quitting pi.
26
+ - The worker stays when every pi is closed. Stop it with `kill $(cat ~/.pi/agent/next-issue.lock)`.
27
+ - When the extension is newer than the worker, the worker closes once no run is going, and the extension starts the newer worker.
26
28
 
27
- 1. Swap `status:todo` for `status:in-progress`.
28
- 2. Add a git worktree at `.worktrees/<n>` on the `<n>` branch, from the remote default branch.
29
- 3. The implementer agent does the work. The extension commits it, with the subject from a `commit:` line in the reply of the agent, and pushes. When a resumed issue already has a commit ahead of the base, the extension keeps it and skips the implementer.
30
- 4. Open a pull request that closes the issue.
31
- 5. Wait for the checks. On a red build, the fixer agent gets the failed logs, up to `maxCiFixes` times.
32
- 6. The reviewer agent judges the diff and gives a verdict with `blocking` and `minor` findings, which goes on the pull request. Any change the reviewer makes is thrown away.
33
- 7. While review rounds are left, the fixer gets every finding, and the checks and the review run again. In the last round only a blocking finding stops the merge.
34
- 8. Squash-merge the pull request, wait until it is merged, set `status:done`, and remove the worktree, the local branch, the remote branch and the saved state.
35
-
36
- An issue that cannot go on gets `status:needs-human` and a comment with the reason. Its worktree stays for you to look at.
37
-
38
- The implementer and the reviewer read the issue title, the body and the comments. They get only comments by the owner, members and collaborators of the repository. The body is passed as it is, and its author can edit it after you add the label, so check the body before you add the label.
39
-
40
- Each agent is a pi session in the worktree. It uses the model and thinking level from your pi settings, and loads all of your extensions except this one.
41
-
42
- The state of each issue is saved in `.worktrees/<n>.json`. Each loop removes the saved state of an issue that is closed. A run holds `.worktrees/run.lock`, which keeps the process id, so that only one pi at a time works on a repository. A second run fails at once. A lock whose process is no longer alive is taken over. If the operating system gives that process id to another process, the lock stays; remove `.worktrees/run.lock` by hand when no run is going. The extension adds `/.worktrees/` to `.git/info/exclude`.
43
-
44
- ## Config
45
-
46
- An optional `.pi/next-issue.json` in the repository sets these fields:
47
-
48
- | Field | Default | Meaning |
49
- | --------------------- | ---------- | ------------------------------------------------- |
50
- | `remote` | `"origin"` | The git remote to push to. |
51
- | `maxCiFixes` | `3` | Fixes of red checks per issue. |
52
- | `maxReviewRounds` | `3` | Review rounds per issue. |
53
- | `checkTimeoutMinutes` | `30` | The longest wait for the checks, and for a merge. |
54
- | `diffMaxChars` | `60000` | The longest diff that the reviewer gets. |
55
- | `logMaxChars` | `20000` | The longest failed log that the fixer gets. |
29
+ Each run asks the worker to merge the pull requests. See the [`next-issue` README](../next-issue/README.md) for what a run does, its labels, its state and its config.
package/index.ts CHANGED
@@ -1,19 +1,24 @@
1
- import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
- import { loadConfig } from './src/config.ts';
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import {
4
+ type ExtensionAPI,
5
+ type ExtensionContext,
6
+ getAgentDir,
7
+ } from '@earendil-works/pi-coding-agent';
3
8
  import { repoRoot } from './src/git.ts';
4
- import { createPorts } from './src/ports.ts';
5
- import { type Progress, Runner } from './src/runner.ts';
9
+ import { type Progress, spawnWorker, WorkerClient, type WorkerEvent } from './src/worker.ts';
6
10
 
7
- type Launch = (cwd: string) => Promise<Runner>;
8
-
9
- type Holder = { runner?: Runner; launching?: boolean };
11
+ export type Options = {
12
+ client: () => WorkerClient;
13
+ root: (cwd: string) => Promise<string>;
14
+ };
10
15
 
11
16
  const KEY = Symbol.for('pi-next-issue');
12
17
  const STATUS_KEY = 'next-issue';
13
18
 
14
- function holder(): Holder {
15
- const store = globalThis as Record<symbol, Holder | undefined>;
16
- store[KEY] ??= {};
19
+ function shared(create: () => WorkerClient): WorkerClient {
20
+ const store = globalThis as Record<symbol, WorkerClient | undefined>;
21
+ store[KEY] ??= create();
17
22
  return store[KEY];
18
23
  }
19
24
 
@@ -24,19 +29,23 @@ function duration(ms: number): string {
24
29
  }
25
30
 
26
31
  function outcomes(progress: Progress): string {
27
- const merged = progress.handled.filter((item) => item.outcome === 'merged').length;
28
- const human = progress.handled.length - merged;
29
- const parts = [`${merged} merged`];
32
+ const done = progress.handled.filter((item) => item.outcome === 'done').length;
33
+ const human = progress.handled.filter((item) => item.outcome === 'needs-human').length;
34
+ const failed = progress.handled.filter((item) => item.outcome === 'error').length;
35
+ const parts = [`${done} done`];
30
36
  if (human > 0) {
31
37
  parts.push(`${human} needs a human`);
32
38
  }
39
+ if (failed > 0) {
40
+ parts.push(`${failed} failed`);
41
+ }
33
42
  return parts.join(', ');
34
43
  }
35
44
 
45
+ const OUTCOME_NOTES = { done: 'done', 'needs-human': 'needs a human', error: 'failed' } as const;
46
+
36
47
  export function formatStatus(progress: Progress, now: number): string {
37
48
  switch (progress.status) {
38
- case 'idle':
39
- return 'next-issue · starting';
40
49
  case 'running': {
41
50
  if (progress.issue === undefined) {
42
51
  return 'next-issue · looking for issues';
@@ -59,39 +68,58 @@ export function formatStatus(progress: Progress, now: number): string {
59
68
  }
60
69
  }
61
70
 
62
- const launchFromRepo: Launch = async (cwd) => {
63
- const root = await repoRoot(cwd);
64
- const config = await loadConfig(root);
65
- return new Runner(await createPorts(root, config), config);
71
+ function packageVersion(): string {
72
+ const raw = readFileSync(join(import.meta.dirname, 'package.json'), 'utf8');
73
+ return (JSON.parse(raw) as { version: string }).version;
74
+ }
75
+
76
+ const fromMachine: Options = {
77
+ client: () => {
78
+ const agentDir = getAgentDir();
79
+ return new WorkerClient({
80
+ socketPath: join(agentDir, 'next-issue.sock'),
81
+ version: packageVersion(),
82
+ spawnWorker: () => spawnWorker(agentDir),
83
+ });
84
+ },
85
+ root: repoRoot,
66
86
  };
67
87
 
68
- export function createExtension(launch: Launch) {
88
+ export function createExtension(options: Options) {
69
89
  return (pi: ExtensionAPI) => {
70
90
  let ui: ExtensionContext['ui'] | undefined;
71
91
  let coloured = false;
92
+ let root: string | undefined;
72
93
  let timer: ReturnType<typeof setInterval> | undefined;
73
94
  let unsubscribe: (() => void) | undefined;
74
95
  let handled = 0;
75
96
  let status: Progress['status'] | undefined;
76
97
 
98
+ const current = (): Progress | undefined =>
99
+ root === undefined ? undefined : shared(options.client).runs[root];
100
+
77
101
  const render = () => {
78
- const runner = holder().runner;
79
- if (ui === undefined || runner === undefined) {
102
+ const progress = current();
103
+ if (ui === undefined || progress === undefined) {
80
104
  return;
81
105
  }
82
- const progress = runner.progress;
83
- const theme = ui.theme;
84
- const dim = (text: string) => (coloured ? theme.fg('dim', text) : text);
106
+ const dim = (text: string) => (coloured ? ui!.theme.fg('dim', text) : text);
85
107
  ui.setStatus(STATUS_KEY, dim(formatStatus(progress, Date.now())));
86
108
  const active = progress.status === 'running' && progress.lastTool !== undefined;
87
109
  ui.setWidget(STATUS_KEY, active ? [dim(`↳ ${progress.lastTool}`)] : undefined);
88
110
  };
89
111
 
90
- const onChange = (progress: Progress) => {
112
+ const onProgress = (progress: Progress | undefined) => {
113
+ if (progress === undefined) {
114
+ return;
115
+ }
116
+ if (progress.handled.length < handled) {
117
+ handled = 0;
118
+ }
91
119
  for (const item of progress.handled.slice(handled)) {
92
120
  ui?.notify(
93
- `next-issue: #${item.issue} ${item.outcome === 'merged' ? 'merged' : 'needs a human'}`,
94
- item.outcome === 'merged' ? 'info' : 'warning',
121
+ `next-issue: #${item.issue} ${OUTCOME_NOTES[item.outcome]}`,
122
+ item.outcome === 'done' ? 'info' : 'warning',
95
123
  );
96
124
  }
97
125
  handled = progress.handled.length;
@@ -108,102 +136,79 @@ export function createExtension(launch: Launch) {
108
136
  render();
109
137
  };
110
138
 
111
- const follow = (runner: Runner) => {
139
+ const onEvent = (event: WorkerEvent) => {
140
+ if (event.type === 'runs') {
141
+ onProgress(root === undefined ? undefined : event.runs[root]);
142
+ } else if (event.root === root) {
143
+ ui?.notify(`next-issue cannot start: ${event.message}`, 'error');
144
+ }
145
+ };
146
+
147
+ const follow = () => {
112
148
  unsubscribe?.();
113
- handled = runner.progress.handled.length;
114
- status = runner.progress.status;
115
- unsubscribe = runner.subscribe(onChange);
149
+ const progress = current();
150
+ handled = progress?.handled.length ?? 0;
151
+ status = progress?.status;
152
+ unsubscribe = shared(options.client).subscribe(onEvent);
116
153
  render();
117
154
  };
118
155
 
119
- const detach = () => {
120
- unsubscribe?.();
121
- unsubscribe = undefined;
122
- if (timer !== undefined) {
123
- clearInterval(timer);
124
- timer = undefined;
125
- }
126
- ui = undefined;
156
+ const show = (ctx: ExtensionContext) => {
157
+ ui = ctx.ui;
158
+ coloured = ctx.mode === 'tui';
159
+ timer ??= setInterval(render, 1000);
160
+ timer.unref?.();
127
161
  };
128
162
 
129
163
  pi.on('session_start', async (_event, ctx) => {
130
164
  if (!ctx.hasUI) {
131
165
  return;
132
166
  }
133
- ui = ctx.ui;
134
- coloured = ctx.mode === 'tui';
135
- timer ??= setInterval(render, 1000);
136
- timer.unref?.();
137
- const runner = holder().runner;
138
- if (runner !== undefined) {
139
- follow(runner);
167
+ show(ctx);
168
+ root = await options.root(ctx.cwd).catch(() => undefined);
169
+ if (root === undefined) {
170
+ return;
140
171
  }
172
+ follow();
173
+ await shared(options.client).attach();
141
174
  });
142
175
 
143
- pi.on('session_shutdown', async (event) => {
144
- detach();
145
- if (event.reason === 'quit') {
146
- holder().runner?.stop();
176
+ pi.on('session_shutdown', async () => {
177
+ unsubscribe?.();
178
+ unsubscribe = undefined;
179
+ if (timer !== undefined) {
180
+ clearInterval(timer);
181
+ timer = undefined;
147
182
  }
183
+ ui = undefined;
148
184
  });
149
185
 
150
186
  pi.registerCommand('next-issue', {
151
- description: 'Work through the GitHub issues of this repository in the background',
152
- getArgumentCompletions: (prefix) => {
153
- const items = ['start', 'stop', 'status']
154
- .filter((name) => name.startsWith(prefix))
155
- .map((name) => ({ value: name, label: name }));
156
- return items.length > 0 ? items : null;
157
- },
158
- handler: async (args, ctx) => {
159
- const action = args.trim() || 'status';
160
- const current = holder().runner;
161
- const busy =
162
- current?.progress.status === 'running' || current?.progress.status === 'stopping';
163
- if (action === 'start') {
164
- if (busy || holder().launching) {
165
- ctx.ui.notify('next-issue is running already', 'warning');
166
- return;
167
- }
168
- holder().launching = true;
169
- let runner: Runner;
170
- try {
171
- runner = await launch(ctx.cwd);
172
- } catch (error) {
173
- ctx.ui.notify(
174
- `next-issue cannot start: ${error instanceof Error ? error.message : String(error)}`,
175
- 'error',
176
- );
177
- return;
178
- } finally {
179
- holder().launching = false;
180
- }
181
- holder().runner = runner;
187
+ description: 'Start or stop working through the GitHub issues of this repository',
188
+ handler: async (_args, ctx) => {
189
+ try {
190
+ root = await options.root(ctx.cwd);
182
191
  if (ctx.hasUI) {
183
- ui = ctx.ui;
184
- coloured = ctx.mode === 'tui';
185
- follow(runner);
192
+ show(ctx);
193
+ follow();
186
194
  }
187
- void runner.run();
188
- } else if (action === 'stop') {
189
- if (!busy) {
190
- ctx.ui.notify('next-issue is not running', 'info');
191
- return;
195
+ const client = shared(options.client);
196
+ await client.ensure();
197
+ const progress = client.runs[root];
198
+ if (progress?.status === 'running' || progress?.status === 'stopping') {
199
+ client.send({ type: 'stop', root });
200
+ } else {
201
+ client.send({ type: 'start', root, merge: true });
192
202
  }
193
- current?.stop();
194
- } else if (action === 'status') {
203
+ } catch (error) {
195
204
  ctx.ui.notify(
196
- current === undefined
197
- ? 'next-issue has not run yet'
198
- : formatStatus(current.progress, Date.now()),
199
- 'info',
205
+ `next-issue cannot start: ${error instanceof Error ? error.message : String(error)}`,
206
+ 'error',
200
207
  );
201
- } else {
202
- ctx.ui.notify('usage: /next-issue start | stop | status', 'warning');
203
208
  }
204
209
  },
205
210
  });
206
211
  };
207
212
  }
208
213
 
209
- export default createExtension(launchFromRepo);
214
+ export default createExtension(fromMachine);
package/package.json CHANGED
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "name": "@ravshansbox/pi-next-issue",
3
- "version": "1.1.0",
4
- "description": "Pi extension that works through GitHub issues in the background",
3
+ "version": "2.3.0",
4
+ "description": "Pi extension that works through GitHub issues with the next-issue worker",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/ravshansbox/pi-next-issue.git"
9
+ "url": "git+https://github.com/ravshansbox/next-issue.git",
10
+ "directory": "packages/pi-next-issue"
10
11
  },
11
- "homepage": "https://github.com/ravshansbox/pi-next-issue#readme",
12
+ "homepage": "https://github.com/ravshansbox/next-issue/tree/main/packages/pi-next-issue#readme",
12
13
  "bugs": {
13
- "url": "https://github.com/ravshansbox/pi-next-issue/issues"
14
+ "url": "https://github.com/ravshansbox/next-issue/issues"
14
15
  },
15
16
  "keywords": [
16
17
  "pi-package",
@@ -33,11 +34,11 @@
33
34
  "check": "npm run typecheck && npm run lint && npm run format:check && npm test"
34
35
  },
35
36
  "peerDependencies": {
36
- "@earendil-works/pi-coding-agent": "*",
37
- "typebox": "*"
37
+ "@earendil-works/pi-coding-agent": "*"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "^26.6.5",
41
+ "@types/ws": "^8.18.2",
41
42
  "oxfmt": "^0.74.0",
42
43
  "oxlint": "^1.89.0",
43
44
  "typescript": "^7.0.2"
@@ -48,5 +49,8 @@
48
49
  ],
49
50
  "publishConfig": {
50
51
  "access": "public"
52
+ },
53
+ "dependencies": {
54
+ "ws": "^8.22.1"
51
55
  }
52
56
  }
package/src/git.ts CHANGED
@@ -1,126 +1,5 @@
1
- import { appendFile, readFile } from 'node:fs/promises';
2
- import { isAbsolute, join } from 'node:path';
3
- import { must, run } from './exec.ts';
4
-
5
- export const WORKTREE_DIR = '.worktrees';
1
+ import { must } from './exec.ts';
6
2
 
7
3
  export async function repoRoot(cwd: string): Promise<string> {
8
4
  return must('git', ['rev-parse', '--show-toplevel'], { cwd });
9
5
  }
10
-
11
- export function parseRemote(url: string): string {
12
- const remote = url.trim();
13
- const hosted =
14
- /^(?:[\w+.-]+:\/\/)?(?:[^@/]+@)?([^/:]+)(?::\d+)?[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(
15
- remote,
16
- );
17
- if (hosted !== null) {
18
- const [, host, owner, name] = hosted;
19
- return host?.toLowerCase() === 'github.com' ? `${owner}/${name}` : `${host}/${owner}/${name}`;
20
- }
21
- const path = /[/:]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/.exec(remote);
22
- if (path === null) {
23
- throw new Error(`cannot read the owner and name from the git remote ${url}`);
24
- }
25
- return `${path[1]}/${path[2]}`;
26
- }
27
-
28
- export async function remoteSlug(root: string, remote: string): Promise<string> {
29
- return parseRemote(await must('git', ['remote', 'get-url', remote], { cwd: root }));
30
- }
31
-
32
- async function ignoreWorktrees(root: string): Promise<void> {
33
- const common = await must('git', ['rev-parse', '--git-common-dir'], { cwd: root });
34
- const file = join(isAbsolute(common) ? common : join(root, common), 'info', 'exclude');
35
- const current = await readFile(file, 'utf8').catch(() => '');
36
- if (!current.split('\n').some((line) => line.trim() === `/${WORKTREE_DIR}/`)) {
37
- const gap = current.length === 0 || current.endsWith('\n') ? '' : '\n';
38
- await appendFile(file, `${gap}/${WORKTREE_DIR}/\n`);
39
- }
40
- }
41
-
42
- async function findWorktree(root: string, branch: string): Promise<string | undefined> {
43
- const list = await must('git', ['worktree', 'list', '--porcelain'], { cwd: root });
44
- const entry = list
45
- .split('\n\n')
46
- .map((block) => block.split('\n'))
47
- .find((lines) => lines.includes(`branch refs/heads/${branch}`));
48
- return entry?.find((line) => line.startsWith('worktree '))?.slice('worktree '.length);
49
- }
50
-
51
- async function hasRef(root: string, ref: string): Promise<boolean> {
52
- return (await run('git', ['rev-parse', '--verify', '--quiet', ref], { cwd: root })).code === 0;
53
- }
54
-
55
- export async function worktree(
56
- root: string,
57
- remote: string,
58
- base: string,
59
- issue: number,
60
- branch: string,
61
- ): Promise<string> {
62
- await ignoreWorktrees(root);
63
- await must('git', ['fetch', remote], { cwd: root });
64
- await must('git', ['worktree', 'prune'], { cwd: root });
65
- const existing = await findWorktree(root, branch);
66
- if (existing !== undefined) {
67
- return existing;
68
- }
69
- const path = join(root, WORKTREE_DIR, String(issue));
70
- if (await hasRef(root, `refs/heads/${branch}`)) {
71
- await must('git', ['worktree', 'add', path, branch], { cwd: root });
72
- } else {
73
- const upstream = `${remote}/${branch}`;
74
- const start = (await hasRef(root, `refs/remotes/${upstream}`)) ? upstream : `${remote}/${base}`;
75
- await must('git', ['worktree', 'add', '--no-track', '-b', branch, path, start], { cwd: root });
76
- }
77
- return path;
78
- }
79
-
80
- export async function commit(cwd: string, message: string): Promise<boolean> {
81
- if ((await must('git', ['status', '--porcelain'], { cwd })).length === 0) {
82
- return false;
83
- }
84
- await must('git', ['add', '-A'], { cwd });
85
- await must('git', ['commit', '-m', message], { cwd });
86
- return true;
87
- }
88
-
89
- export async function ahead(
90
- cwd: string,
91
- remote: string,
92
- base: string,
93
- ): Promise<string | undefined> {
94
- const range = `${remote}/${base}..HEAD`;
95
- const subjects = await must('git', ['log', '--reverse', '--format=%s', range], { cwd });
96
- return subjects.split('\n')[0] || undefined;
97
- }
98
-
99
- export async function push(cwd: string, remote: string, branch: string): Promise<void> {
100
- await must('git', ['push', '-u', remote, `HEAD:refs/heads/${branch}`], { cwd });
101
- }
102
-
103
- export async function discard(cwd: string, remote: string, branch: string): Promise<void> {
104
- await must('git', ['reset', '--hard', `${remote}/${branch}`], { cwd });
105
- await must('git', ['clean', '-fd'], { cwd });
106
- }
107
-
108
- export async function diff(cwd: string, remote: string, base: string): Promise<string> {
109
- await must('git', ['fetch', remote, base], { cwd });
110
- return must('git', ['diff', `${remote}/${base}...HEAD`], { cwd });
111
- }
112
-
113
- export async function head(cwd: string): Promise<string> {
114
- return must('git', ['rev-parse', 'HEAD'], { cwd });
115
- }
116
-
117
- export async function cleanup(
118
- root: string,
119
- remote: string,
120
- branch: string,
121
- path: string,
122
- ): Promise<void> {
123
- await run('git', ['worktree', 'remove', '--force', path], { cwd: root });
124
- await run('git', ['branch', '-D', branch], { cwd: root });
125
- await run('git', ['push', remote, '--delete', branch], { cwd: root });
126
- }