@ravshansbox/pi-next-issue 1.2.0 → 2.3.1
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 +12 -36
- package/index.ts +101 -76
- package/package.json +11 -7
- package/src/git.ts +1 -122
- package/src/worker.ts +241 -0
- package/src/agent.ts +0 -105
- package/src/config.ts +0 -69
- package/src/github.ts +0 -294
- package/src/ports.ts +0 -43
- package/src/prompts.ts +0 -109
- package/src/runner.ts +0 -358
- package/src/state.ts +0 -121
package/README.md
CHANGED
|
@@ -1,53 +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
|
|
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
13
|
Run this in pi, inside a git repository:
|
|
14
14
|
|
|
15
|
-
- `/next-issue` starts a run when none is going, and stops the run when one is going
|
|
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.
|
|
16
16
|
|
|
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
|
|
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.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
## Worker
|
|
20
20
|
|
|
21
|
-
|
|
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`.
|
|
22
22
|
|
|
23
|
-
|
|
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.
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
2. Add a git worktree at `.worktrees/<n>` on the `<n>` branch, from the remote default branch.
|
|
27
|
-
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.
|
|
28
|
-
4. Open a pull request that closes the issue.
|
|
29
|
-
5. Wait for the checks. On a red build, the fixer agent gets the failed logs. It gets at most `maxCiFixes` such fixes for the whole issue, and this count includes the checks after review fixes.
|
|
30
|
-
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.
|
|
31
|
-
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.
|
|
32
|
-
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.
|
|
33
|
-
|
|
34
|
-
An issue that cannot go on gets `status:needs-human` and a comment with the reason. Its worktree stays for you to look at.
|
|
35
|
-
|
|
36
|
-
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.
|
|
37
|
-
|
|
38
|
-
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.
|
|
39
|
-
|
|
40
|
-
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`.
|
|
41
|
-
|
|
42
|
-
## Config
|
|
43
|
-
|
|
44
|
-
An optional `.pi/next-issue.json` in the repository sets these fields:
|
|
45
|
-
|
|
46
|
-
| Field | Default | Meaning |
|
|
47
|
-
| --------------------- | ---------- | --------------------------------------------------------------------------------- |
|
|
48
|
-
| `remote` | `"origin"` | The git remote to push to. |
|
|
49
|
-
| `maxCiFixes` | `3` | Fixes of red checks for the whole issue, including the checks after review fixes. |
|
|
50
|
-
| `maxReviewRounds` | `3` | Review rounds per issue. |
|
|
51
|
-
| `checkTimeoutMinutes` | `30` | The longest wait for the checks, and for a merge. |
|
|
52
|
-
| `diffMaxChars` | `60000` | The longest diff that the reviewer gets. |
|
|
53
|
-
| `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
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
15
|
-
const store = globalThis as Record<symbol,
|
|
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
|
|
28
|
-
const human = progress.handled.
|
|
29
|
-
const
|
|
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
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
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(
|
|
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
|
|
79
|
-
if (ui === undefined ||
|
|
102
|
+
const progress = current();
|
|
103
|
+
if (ui === undefined || progress === undefined) {
|
|
80
104
|
return;
|
|
81
105
|
}
|
|
82
|
-
const
|
|
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
|
|
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
|
|
94
|
-
item.outcome === '
|
|
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,82 +136,79 @@ export function createExtension(launch: Launch) {
|
|
|
108
136
|
render();
|
|
109
137
|
};
|
|
110
138
|
|
|
111
|
-
const
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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 (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
187
|
description: 'Start or stop working through the GitHub issues of this repository',
|
|
152
188
|
handler: async (_args, ctx) => {
|
|
153
|
-
const current = holder().runner;
|
|
154
|
-
const busy =
|
|
155
|
-
current?.progress.status === 'running' || current?.progress.status === 'stopping';
|
|
156
|
-
if (busy) {
|
|
157
|
-
current?.stop();
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
if (holder().launching) {
|
|
161
|
-
ctx.ui.notify('next-issue is starting already', 'warning');
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
holder().launching = true;
|
|
165
|
-
let runner: Runner;
|
|
166
189
|
try {
|
|
167
|
-
|
|
190
|
+
root = await options.root(ctx.cwd);
|
|
191
|
+
if (ctx.hasUI) {
|
|
192
|
+
show(ctx);
|
|
193
|
+
follow();
|
|
194
|
+
}
|
|
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 });
|
|
202
|
+
}
|
|
168
203
|
} catch (error) {
|
|
169
204
|
ctx.ui.notify(
|
|
170
205
|
`next-issue cannot start: ${error instanceof Error ? error.message : String(error)}`,
|
|
171
206
|
'error',
|
|
172
207
|
);
|
|
173
|
-
return;
|
|
174
|
-
} finally {
|
|
175
|
-
holder().launching = false;
|
|
176
|
-
}
|
|
177
|
-
holder().runner = runner;
|
|
178
|
-
if (ctx.hasUI) {
|
|
179
|
-
ui = ctx.ui;
|
|
180
|
-
coloured = ctx.mode === 'tui';
|
|
181
|
-
follow(runner);
|
|
182
208
|
}
|
|
183
|
-
void runner.run();
|
|
184
209
|
},
|
|
185
210
|
});
|
|
186
211
|
};
|
|
187
212
|
}
|
|
188
213
|
|
|
189
|
-
export default createExtension(
|
|
214
|
+
export default createExtension(fromMachine);
|
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ravshansbox/pi-next-issue",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Pi extension that works through GitHub issues
|
|
3
|
+
"version": "2.3.1",
|
|
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/
|
|
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/
|
|
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 {
|
|
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
|
-
}
|