jackherd 1.26.1 → 1.26.3
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/CHANGELOG.md +13 -0
- package/README.md +18 -18
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -0
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -8
- package/kit/workflow/skills/orchestrate/references/lane-session.md +1 -1
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +8 -0
- package/src/brief/lane-facts.ts +30 -18
- package/src/config/schema.ts +2 -0
- package/src/context/command.ts +3 -1
- package/src/lane/close-check.ts +51 -15
- package/src/lane/close-command.ts +41 -8
- package/src/lane/close-survey.ts +9 -0
- package/src/lane/close.ts +54 -6
- package/src/lane/send-options.ts +3 -2
- package/src/lane/send.ts +8 -1
- package/src/lane/subagent-view.ts +21 -0
- package/src/lane-prompt/command.ts +4 -1
- package/src/plans/active-tasks.ts +4 -2
- package/src/plans/activity-read.ts +3 -2
- package/src/plans/command.ts +3 -1
- package/src/plans/coordinator-row.ts +7 -2
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/lane-discovery.ts +42 -3
- package/src/plans/lane-worktrees.ts +56 -0
- package/src/plans/sent-files.ts +10 -5
- package/src/plans/settings.ts +12 -1
- package/src/plans/snapshot.ts +23 -8
- package/src/plans/subagents-read.ts +17 -2
- package/src/plans/subagents.ts +3 -2
- package/src/plans/unlisted-lanes.ts +13 -0
- package/src/resume/command.ts +1 -1
- package/src/resume/coordinator-read.ts +12 -12
- package/src/resume/decision.ts +3 -0
- package/src/resume/record.ts +24 -5
- package/src/sync/command.ts +16 -2
- package/src/sync/downgrade.ts +9 -0
- package/src/sync/if-changed.ts +6 -7
- package/src/update/command.ts +17 -0
- package/src/update/registry-wait.ts +44 -0
- package/src/update/update.ts +23 -0
- package/src/version/registry.ts +8 -0
- package/src/watch/command.ts +28 -14
- package/src/watch/obsolete-proposals.ts +8 -6
- package/src/watch/obsolete-records.ts +3 -1
- package/src/watch/running-subagents.ts +3 -2
- package/src/watch/task-signal-read.ts +4 -1
- package/src/watch/unlinked-artifacts-read.ts +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
What changed for people who use jackherd, newest first.
|
|
4
4
|
|
|
5
|
+
## 1.26.3
|
|
6
|
+
|
|
7
|
+
- **`jh lane close` no longer refuses a lane for files a project deletes on every close.** `lanes.disposable` in `.jackherd/config.json` lists ignored paths in `.gitignore` syntax (scratch folders, stored test logins, generated assets) that go with the worktree without a refusal; any other ignored file still refuses.
|
|
8
|
+
- **`jh lane close` archives the lane's handover files instead of refusing.** Before the worktree is removed, `.claude/handover/` is copied to `.claude/handover/closed/<lane>-<date>/` in the main checkout; when the copy fails, nothing is removed. The orchestrate skill's `close-lane.md` says so. Run `jh sync` to get the text.
|
|
9
|
+
- **`jh lane close` deletes the lane's branch on origin when nothing was pushed after the merge.** The branch goes only while it still points to the merged pull request's head, as the last step so a failed push leaves the rest done; otherwise one line says why it stays. `jh lane close <lane> --branch <branch>` finishes it for a lane closed before.
|
|
10
|
+
|
|
11
|
+
## 1.26.2
|
|
12
|
+
|
|
13
|
+
- **A board lane is placed by its agent's worktree.** The herd and the watcher find a lane by the worktree its agent runs in, not only by the lane's name.
|
|
14
|
+
- **`jh sync` refuses a sync that would turn the repo's kit files back.** When the installed jackherd is older than the version in the repo's lock, the sync stops and names both versions instead of overwriting newer kit files. Run `jh update` first.
|
|
15
|
+
- **`jh update` waits for the registry after a publish.** A 404 right after a release no longer fails the update; it retries until the registry has the version.
|
|
16
|
+
- **`jh lane send` refuses while a background agent's view is open.** The text no longer lands in the subagent view instead of the lane's session.
|
|
17
|
+
|
|
5
18
|
## 1.26.1
|
|
6
19
|
|
|
7
20
|
- **A lane whose agent runs in a worktree of another name is no longer silently unwatched.** The watcher finds a lane by its worktree directory's name, so a board lane whose agent runs in a worktree taken over from another session never sent an event. The watcher now prints `lane <name> not watched: its agent runs in worktree <dir>, not <name>` once when it appears, and `jh resume --coordinator` lists it too. The orchestrate skill's `lane-session.md` says to name the lane after the worktree directory, and to rename its row and agent when the line appears. Run `jh sync` to get the text.
|
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<p>
|
|
2
|
-
<img alt="jackherd: a black and white Shih Tzu in a turquoise bandana, three lanes running from him to three agent nodes" src="https://unpkg.com/jackherd@1.26.
|
|
2
|
+
<img alt="jackherd: a black and white Shih Tzu in a turquoise bandana, three lanes running from him to three agent nodes" src="https://unpkg.com/jackherd@1.26.3/assets/branding/jackherd-logo-outline.svg" width="340">
|
|
3
3
|
<br>
|
|
4
4
|
</p>
|
|
5
5
|
|
|
6
|
-
[](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd?activeTab=dependencies) [](https://unpkg.com/jackherd/INSTALL.md) [](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.
|
|
6
|
+
[](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd?activeTab=dependencies) [](https://unpkg.com/jackherd/INSTALL.md) [](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.3/assets/icons/space.svg">
|
|
7
7
|
|
|
8
8
|
# Jack herds your coding agents.
|
|
9
9
|
|
|
@@ -15,29 +15,29 @@ For developers who'd rather decide than supervise.
|
|
|
15
15
|
curl -fsSL https://unpkg.com/jackherd/install.sh | sh
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Then `/jh-start` in a repository, and Jack offers your first plan. → [Quickstart](#quickstart)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.
|
|
18
|
+
Then `/jh-start` in a repository, and Jack offers your first plan. → [Quickstart](#quickstart)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.3/assets/icons/space.svg">
|
|
19
19
|
|
|
20
20
|
<p>
|
|
21
|
-
<img src="https://unpkg.com/jackherd@1.26.
|
|
21
|
+
<img src="https://unpkg.com/jackherd@1.26.3/docs/workflow-dark.svg" width="720" alt="jackherd in herdr: a lane hits a question, Jack asks you as a choice, you pick, and the lane works on while the herd view counts its progress.">
|
|
22
22
|
<br>
|
|
23
|
-
<sub>Jack and his lanes in <a href="https://herdr.dev/">herdr</a>, a terminal for coding agents (recommended). Without it, one Claude Code session builds with subagents.</sub><br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.
|
|
23
|
+
<sub>Jack and his lanes in <a href="https://herdr.dev/">herdr</a>, a terminal for coding agents (recommended). Without it, one Claude Code session builds with subagents.</sub><br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.3/assets/icons/space.svg">
|
|
24
24
|
</p>
|
|
25
25
|
|
|
26
|
-
<p><img src="https://unpkg.com/jackherd@1.26.
|
|
26
|
+
<p><img src="https://unpkg.com/jackherd@1.26.3/assets/icons/lanes.svg" width="24" height="34" align="middle" alt=""> <b>Parallel where it pays.</b><br>Phases that don't collide run side by side, each lane on its own branch and worktree. Jack keeps the merge order and has the others rebase.</p>
|
|
27
27
|
|
|
28
|
-
<p><img src="https://unpkg.com/jackherd@1.26.
|
|
28
|
+
<p><img src="https://unpkg.com/jackherd@1.26.3/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Commits, pushes and merges wait for your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours. The guards catch mistakes, not intent; a hard boundary takes branch protection.</p>
|
|
29
29
|
|
|
30
|
-
<p><img src="https://unpkg.com/jackherd@1.26.
|
|
30
|
+
<p><img src="https://unpkg.com/jackherd@1.26.3/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A lane gets stuck, a check goes red, a decision needs you: Jack steps in and brings you only what's yours to decide. The herd view shows where every lane stands.</p>
|
|
31
31
|
|
|
32
|
-
<p><img src="https://unpkg.com/jackherd@1.26.
|
|
32
|
+
<p><img src="https://unpkg.com/jackherd@1.26.3/assets/icons/skills.svg" width="24" height="34" align="middle" alt=""> <b>Works with your skills.</b><br>Keep your own skills: a lane builds with whichever one you pick. Plans written elsewhere run too, once Jack fills in what they're missing.</p>
|
|
33
33
|
|
|
34
|
-
<p><img src="https://unpkg.com/jackherd@1.26.
|
|
34
|
+
<p><img src="https://unpkg.com/jackherd@1.26.3/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments on a lane's pull request go back to that lane, which fixes them with a test. <code>/jh-follow</code> brings in anything a CLI or an API can read as JSON: tickets, error logs, status pages.</p>
|
|
35
35
|
|
|
36
|
-
<p><img src="https://unpkg.com/jackherd@1.26.
|
|
36
|
+
<p><img src="https://unpkg.com/jackherd@1.26.3/assets/icons/model.svg" width="24" height="34" align="middle" alt=""> <b>The right model for each phase.</b><br>Every phase names its model: the stronger one where a mistake is expensive (data, security, a migration), the cheaper one for the rest. Lanes start on it, and a hook refuses to start a phase's subagent on a weaker one.</p>
|
|
37
37
|
|
|
38
|
-
<p><img src="https://unpkg.com/jackherd@1.26.
|
|
38
|
+
<p><img src="https://unpkg.com/jackherd@1.26.3/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>A crash or a full context doesn't cost you the work.</b><br>Plan, board and a hand-over file hold the state, not the chat: the next session picks up where the last one stopped.</p>
|
|
39
39
|
|
|
40
|
-
<p><img src="https://unpkg.com/jackherd@1.26.
|
|
40
|
+
<p><img src="https://unpkg.com/jackherd@1.26.3/assets/icons/plan.svg" width="24" height="34" align="middle" alt=""> <b>Lives in your repository.</b><br>Plans, board and every decision are Markdown, reviewed like code. <code>jh init</code> gives your team the same setup.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.3/assets/icons/space.svg"></p>
|
|
41
41
|
|
|
42
42
|
## Quickstart
|
|
43
43
|
|
|
@@ -68,22 +68,22 @@ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quicksta
|
|
|
68
68
|
/jh-orchestrate docs/plans/board-<name>/board.md
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
Linux and macOS are supported (Windows through WSL); the requirements are in [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md). By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.
|
|
71
|
+
Linux and macOS are supported (Windows through WSL); the requirements are in [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md). By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.3/assets/icons/space.svg">
|
|
72
72
|
|
|
73
73
|
## Follow anything you can query
|
|
74
74
|
|
|
75
75
|
Tickets, error logs, status pages: if a command or an API call can read it as JSON with your login, Jack can watch it. Recipes cover Jira, Linear, GitHub issues and Projects, Sentry, Google Calendar and status pages; for anything else, `/jh-follow` writes the command with you. One yes, and every change shows up like this:
|
|
76
76
|
|
|
77
|
-
<img src="https://unpkg.com/jackherd@1.26.
|
|
77
|
+
<img src="https://unpkg.com/jackherd@1.26.3/docs/follow-dark.svg" width="720" alt="A source (ticket, error log or status page) has a new or changed entry; jh watch reports it to Jack; Jack puts a row into Your turn, prefixed with the source's label; its [ask] button makes Jack ask you, and then a lane works it.">
|
|
78
78
|
|
|
79
|
-
The herd sums up your sources in one `Sources` line: new entries per source, or its last run, red while it fails; opened, a click opens an entry. One that needs you lands in Your turn under the source's label, [ask] has Jack look at it, and a ticket shows on the lane that works it.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.
|
|
79
|
+
The herd sums up your sources in one `Sources` line: new entries per source, or its last run, red while it fails; opened, a click opens an entry. One that needs you lands in Your turn under the source's label, [ask] has Jack look at it, and a ticket shows on the lane that works it.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.26.3/assets/icons/space.svg">
|
|
80
80
|
|
|
81
81
|
## In detail
|
|
82
82
|
|
|
83
83
|
<details>
|
|
84
84
|
<summary><b>What Jack does on his own, and what waits for you</b></summary>
|
|
85
85
|
|
|
86
|
-
<img src="https://unpkg.com/jackherd@1.26.
|
|
86
|
+
<img src="https://unpkg.com/jackherd@1.26.3/docs/lanes-dark.svg" alt="Jack prompts two lanes and asks you for decisions and approvals; the lanes send questions back as text; jh watch reads lane state, pull requests and checks and wakes Jack.">
|
|
87
87
|
|
|
88
88
|
| On his own | After your approval | Never |
|
|
89
89
|
| --- | --- | --- |
|
|
@@ -118,7 +118,7 @@ The guards catch mistakes, not intent; for that, use branch protection. The hook
|
|
|
118
118
|
|
|
119
119
|
A **plan** is Markdown with phases, checkbox tasks, a model and a review gate per phase, and under each phase heading one sentence on what is different afterwards and for whom. A **board** lists the lanes of one or more plans: which phases each lane builds, on which model, after what. A **lane** is one agent session in its own git worktree (a second checkout of your repository), and **Jack** is the session in the main checkout that runs `/jh-orchestrate` on a board.
|
|
120
120
|
|
|
121
|
-
<img src="https://unpkg.com/jackherd@1.26.
|
|
121
|
+
<img src="https://unpkg.com/jackherd@1.26.3/docs/skills-dark.svg" alt="Your own plan or an issue is adopted (Jack adds what is missing, you see the diff) and goes to /jh-orchestrate; /jh-grill hands decisions to /jh-plan; /jh-plan feeds /jh-orchestrate or a single /jh-implement, which your own skill can replace; /jh-implement ends in /jh-pre-commit, which runs the reviewers; /jh-diagnose hands a red command to /jh-implement.">
|
|
122
122
|
|
|
123
123
|
| Skill | Does |
|
|
124
124
|
| --- | --- |
|
package/dist/config.d.ts
CHANGED
|
@@ -122,6 +122,7 @@ declare const jackherdConfigSchema: z.ZodObject<{
|
|
|
122
122
|
}, z.core.$strict>>;
|
|
123
123
|
lanes: z.ZodPrefault<z.ZodObject<{
|
|
124
124
|
copy: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
125
|
+
disposable: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
125
126
|
lockFile: z.ZodOptional<z.ZodString>;
|
|
126
127
|
minFreeGb: z.ZodOptional<z.ZodNumber>;
|
|
127
128
|
basePort: z.ZodOptional<z.ZodNumber>;
|
package/dist/config.js
CHANGED
|
@@ -747,6 +747,8 @@ var jackherdConfigSchema = z3.strictObject({
|
|
|
747
747
|
lanes: z3.strictObject({
|
|
748
748
|
/** Untracked files copied into every new worktree; empty means the files `.worktreeinclude` lists. */
|
|
749
749
|
copy: z3.array(laneCopyPath).default([]),
|
|
750
|
+
/** Ignored paths `jh lane close` deletes with a worktree without asking, in `.gitignore` syntax (`apps/web/tmp`); a pattern without a slash matches at any depth. Everything else ignored still refuses the close. */
|
|
751
|
+
disposable: z3.array(laneCopyPath).default([]),
|
|
750
752
|
/** The dependency lock file a rebase takes from the base branch and regenerates, never merges by hand; detected from the repository when left out. */
|
|
751
753
|
lockFile: lockFilePath.optional(),
|
|
752
754
|
/** Free gigabytes the disk must hold before `jh lane space` lets a new lane install its dependencies; 5 when left out. */
|
package/install.sh
CHANGED
|
@@ -9,7 +9,7 @@ set -eu
|
|
|
9
9
|
|
|
10
10
|
# The default is the version this file ships with. The release bump changes it together with
|
|
11
11
|
# package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
|
|
12
|
-
JACKHERD_VERSION="${JACKHERD_VERSION:-1.26.
|
|
12
|
+
JACKHERD_VERSION="${JACKHERD_VERSION:-1.26.3}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
|
@@ -4,16 +4,16 @@ Read when a lane's status line says `plan finished` and names `jh lane close <la
|
|
|
4
4
|
or when the user names a lane to close. Run both commands from the main checkout.
|
|
5
5
|
|
|
6
6
|
1. `jh lane close <lane> --dry-run` checks and lists the steps; it changes nothing. It proves nothing is lost: a
|
|
7
|
-
merged PR whose tree equals the branch (`git diff <branch-head> <merge-commit>` empty
|
|
8
|
-
|
|
7
|
+
merged PR whose tree equals the branch (`git diff <branch-head> <merge-commit>` empty), no file that removing the worktree would delete (dependencies, build output and `lanes.disposable` paths go with it; `.claude/handover/` is
|
|
8
|
+
copied to `.claude/handover/closed/` first), no agent at work in it. A refusal changes
|
|
9
9
|
nothing: resolve each reason it prints (a named file goes to the user first, a working agent is waited for, a lock
|
|
10
|
-
is lifted once its session ended
|
|
11
|
-
A worktree on no branch closes when its head is on the base branch and only staged plan changes
|
|
10
|
+
is lifted once its session ended) and repeat until it exits 0.
|
|
11
|
+
A worktree on no branch closes when its head is on the base branch and only staged plan changes remain.
|
|
12
12
|
Handed-over service: [`backend.md`](backend.md).
|
|
13
|
-
2. `jh lane close <lane>` Run it without asking the user; its checks are the safeguard. It repeats the step 1 checks
|
|
13
|
+
2. `jh lane close <lane>` Run it without asking the user; its checks are the safeguard. It repeats the step 1 checks and runs the listed steps, one line each. After a failure,
|
|
14
14
|
run it again: with the worktree gone and the branch left, `jh lane close <lane> --branch <branch>` finishes the
|
|
15
|
-
branch and the board; a lane closed in full says so
|
|
15
|
+
branch and the board; a lane closed in full says so.
|
|
16
16
|
3. The command leaves the rows marked `{{kit.word.laneClosed}} <date>` and the `{{kit.heading.history}}` row as a
|
|
17
17
|
change in the main checkout's board, which the herd shows at once. It reaches `{{kit.baseBranch}}` bundled (§ 4), the
|
|
18
|
-
marked rows in the same documentation PR as the history entry. Done when the board shows the marks and the history row.
|
|
19
|
-
|
|
18
|
+
marked rows in the same documentation PR as the history entry. Done when the board shows the marks and the history row. The command deletes the branch on origin itself when it still
|
|
19
|
+
holds the merged head; otherwise it says why it stays.
|
|
@@ -25,4 +25,4 @@ A lane started before this skill, or a session to resume:
|
|
|
25
25
|
`herdr worktree open --path <worktree> --label "<lane> :<port>" --no-focus`, then in its pane
|
|
26
26
|
`claude --resume <session-id>`. Move a lane only between phases, when it is `done` or `idle`.
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
The watcher places a lane by its agent's cwd; `lane <name> not watched` means ambiguous: keep one `<name>` agent, in its own worktree.
|
package/package.json
CHANGED
|
@@ -147,6 +147,14 @@
|
|
|
147
147
|
"pattern": "^(?![/-])(?!(?:.*\\/)?\\.\\.(?:\\/|$))[^'\\x00-\\x1f\\x7f]+$"
|
|
148
148
|
}
|
|
149
149
|
},
|
|
150
|
+
"disposable": {
|
|
151
|
+
"default": [],
|
|
152
|
+
"type": "array",
|
|
153
|
+
"items": {
|
|
154
|
+
"type": "string",
|
|
155
|
+
"pattern": "^(?![/-])(?!(?:.*\\/)?\\.\\.(?:\\/|$))[^'\\x00-\\x1f\\x7f]+$"
|
|
156
|
+
}
|
|
157
|
+
},
|
|
150
158
|
"lockFile": {
|
|
151
159
|
"type": "string",
|
|
152
160
|
"pattern": "^(?![/-])(?!(?:.*\\/)?\\.\\.(?:\\/|$))[A-Za-z0-9._/-]+$"
|
package/src/brief/lane-facts.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { dirname, join, relative } from 'node:path'
|
|
3
3
|
import { parseSprintLanes, resolvePlanPath, type SprintLane } from '../plan-format'
|
|
4
|
+
import { settingsPlacingLanes } from '../plans/lane-worktrees'
|
|
4
5
|
import { type QuestionRecord } from '../plans/question-records'
|
|
5
6
|
import { type SentFileRecord, sentLaneOf } from '../plans/sent-files'
|
|
6
|
-
import { type PlansSettings } from '../plans/settings'
|
|
7
|
-
import {
|
|
7
|
+
import { type PlansSettings, worktreeOf } from '../plans/settings'
|
|
8
|
+
import { type LaneWorktrees, laneOfCwd } from '../plans/unlisted-lanes'
|
|
8
9
|
import { readWaitingRecords } from '../plans/waiting-records'
|
|
9
10
|
import { isStillWaiting } from '../plans/waiting-window'
|
|
10
11
|
import type { LaneFacts } from './lane-brief'
|
|
@@ -14,8 +15,8 @@ const planHrefOf = ({ steps }: SprintLane): string | null =>
|
|
|
14
15
|
steps.filter((step) => step.planHref !== null).at(-1)?.planHref ?? null
|
|
15
16
|
|
|
16
17
|
/** The plan file as the lane's worktree has it (it ticks and writes the head there), else the main checkout's copy. */
|
|
17
|
-
const planTextOf = (root: string,
|
|
18
|
-
const candidates = [join(
|
|
18
|
+
const planTextOf = (root: string, worktree: string, planPath: string): string | null => {
|
|
19
|
+
const candidates = [join(worktree, planPath), join(root, planPath)]
|
|
19
20
|
const found = candidates.find((path) => existsSync(path))
|
|
20
21
|
return found === undefined ? null : readFileSync(found, 'utf8')
|
|
21
22
|
}
|
|
@@ -25,10 +26,11 @@ const openQuestionsOf = (
|
|
|
25
26
|
lane: string,
|
|
26
27
|
worktreeDirectory: string,
|
|
27
28
|
now: number,
|
|
29
|
+
placed: LaneWorktrees,
|
|
28
30
|
): number =>
|
|
29
31
|
records.filter(
|
|
30
32
|
({ closedAt, askedAt, cwd }) =>
|
|
31
|
-
isStillWaiting(closedAt, askedAt, now) &&
|
|
33
|
+
isStillWaiting(closedAt, askedAt, now) && laneOfCwd(cwd, worktreeDirectory, placed) === lane,
|
|
32
34
|
).length
|
|
33
35
|
|
|
34
36
|
const openSendsOf = (
|
|
@@ -36,9 +38,11 @@ const openSendsOf = (
|
|
|
36
38
|
lane: string,
|
|
37
39
|
worktreeDirectory: string,
|
|
38
40
|
now: number,
|
|
41
|
+
placed: LaneWorktrees,
|
|
39
42
|
): number =>
|
|
40
43
|
records.filter(
|
|
41
|
-
(record) =>
|
|
44
|
+
(record) =>
|
|
45
|
+
isStillWaiting(record.closedAt, record.sentAt, now) && sentLaneOf(record, worktreeDirectory, placed) === lane,
|
|
42
46
|
).length
|
|
43
47
|
|
|
44
48
|
/**
|
|
@@ -49,16 +53,24 @@ export const laneFactsOf = (root: string, settings: PlansSettings, boardFile: st
|
|
|
49
53
|
const sprintDirectory = dirname(relative(root, boardFile))
|
|
50
54
|
const worktreeDirectory = join(root, settings.worktreeDirectory)
|
|
51
55
|
const records = readWaitingRecords(root)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
const lanes = parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.boardLanguage).filter(
|
|
57
|
+
({ closed }) => !closed,
|
|
58
|
+
)
|
|
59
|
+
const placed = settingsPlacingLanes(
|
|
60
|
+
root,
|
|
61
|
+
settings,
|
|
62
|
+
lanes.map(({ name }) => name),
|
|
63
|
+
)
|
|
64
|
+
return lanes.map((lane) => {
|
|
65
|
+
const href = planHrefOf(lane)
|
|
66
|
+
return {
|
|
67
|
+
name: lane.name,
|
|
68
|
+
plan:
|
|
69
|
+
href === null
|
|
70
|
+
? null
|
|
71
|
+
: planTextOf(root, worktreeOf(root, placed, lane.name), resolvePlanPath(sprintDirectory, href)),
|
|
72
|
+
openQuestions: openQuestionsOf(records.questions, lane.name, worktreeDirectory, now, placed.laneWorktrees),
|
|
73
|
+
sentFiles: openSendsOf(records.sent, lane.name, worktreeDirectory, now, placed.laneWorktrees),
|
|
74
|
+
}
|
|
75
|
+
})
|
|
64
76
|
}
|
package/src/config/schema.ts
CHANGED
|
@@ -603,6 +603,8 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
603
603
|
.strictObject({
|
|
604
604
|
/** Untracked files copied into every new worktree; empty means the files `.worktreeinclude` lists. */
|
|
605
605
|
copy: z.array(laneCopyPath).default([]),
|
|
606
|
+
/** Ignored paths `jh lane close` deletes with a worktree without asking, in `.gitignore` syntax (`apps/web/tmp`); a pattern without a slash matches at any depth. Everything else ignored still refuses the close. */
|
|
607
|
+
disposable: z.array(laneCopyPath).default([]),
|
|
606
608
|
/** The dependency lock file a rebase takes from the base branch and regenerates, never merges by hand; detected from the repository when left out. */
|
|
607
609
|
lockFile: lockFilePath.optional(),
|
|
608
610
|
/** Free gigabytes the disk must hold before `jh lane space` lets a new lane install its dependencies; 5 when left out. */
|
package/src/context/command.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { homedir } from 'node:os'
|
|
|
2
2
|
import { basename, join } from 'node:path'
|
|
3
3
|
import { CLAUDE_DIRECTORY } from '../claude/paths'
|
|
4
4
|
import { mainCheckout, worktreeRoot } from '../plans/git'
|
|
5
|
+
import { settingsPlacingLanes } from '../plans/lane-worktrees'
|
|
5
6
|
import { type PlansSettings, readSettings, worktreeOf } from '../plans/settings'
|
|
6
7
|
import { type LaneAgent, laneStatusSourceFor } from '../terminal'
|
|
7
8
|
import { AUTO_COMPACT_ENV, autoCompactWindow, compactsBeforeAlertNote, readClaudeSettings } from './auto-compact'
|
|
@@ -96,7 +97,8 @@ export const run = async (args: string[]): Promise<number> => {
|
|
|
96
97
|
const options = parseContextOptions(args)
|
|
97
98
|
const root = mainCheckout(process.cwd())
|
|
98
99
|
const worktree = worktreeRoot(process.cwd())
|
|
99
|
-
const
|
|
100
|
+
const read = readSettings([worktree, root])
|
|
101
|
+
const settings = options.lane ? settingsPlacingLanes(root, read, [options.lane]) : read
|
|
100
102
|
if (options.usage) {
|
|
101
103
|
printUsage(options, settings, root, worktree)
|
|
102
104
|
return 0
|
package/src/lane/close-check.ts
CHANGED
|
@@ -27,8 +27,9 @@ export type StatusEntry = { code: string; path: string }
|
|
|
27
27
|
/**
|
|
28
28
|
* What git, GitHub and the terminal answered about one lane, gathered before anything is removed. `treeDiff` lists the
|
|
29
29
|
* paths in which the branch head differs from the merge commit; `status` holds the worktree's status with ignored
|
|
30
|
-
* files (`statusEntriesOf`); `setupFiles` the patterns of the lane setup's copy list; `
|
|
31
|
-
*
|
|
30
|
+
* files (`statusEntriesOf`); `setupFiles` the patterns of the lane setup's copy list; `disposablePaths` the patterns
|
|
31
|
+
* of ignored paths the project lets a close delete (`lanes.disposable`); `handoverEntries` every entry of the
|
|
32
|
+
* worktree's handover directory, which git shows only as the directory and which the close archives.
|
|
32
33
|
*/
|
|
33
34
|
export type CloseFacts = {
|
|
34
35
|
lane: string
|
|
@@ -42,7 +43,10 @@ export type CloseFacts = {
|
|
|
42
43
|
treeDiff: readonly string[]
|
|
43
44
|
status: readonly StatusEntry[]
|
|
44
45
|
setupFiles: readonly string[]
|
|
46
|
+
disposablePaths: readonly string[]
|
|
45
47
|
handoverEntries: readonly string[]
|
|
48
|
+
/** The commit the branch points to on origin, read live; null when the branch is gone there or none is named. */
|
|
49
|
+
remoteHead: string | null
|
|
46
50
|
/** Whether the head of a worktree on no branch is an ancestor of the base branch on the remote. */
|
|
47
51
|
headOnBase: boolean
|
|
48
52
|
/** The staged paths of a worktree on no branch that the base branch on the remote holds with the same content. */
|
|
@@ -74,6 +78,7 @@ const BUILD_OUTPUT = [
|
|
|
74
78
|
/** Build output only as a directory entry: a package's `bin/` that an ignore rule hides as a whole. */
|
|
75
79
|
const BUILD_OUTPUT_DIRECTORY_ENTRIES = [new Bun.Glob('packages/*/bin')]
|
|
76
80
|
const HANDOVER = claudePath(HANDOVER_DIRECTORY)
|
|
81
|
+
const SHORT_COMMIT_LENGTH = 9
|
|
77
82
|
|
|
78
83
|
/** A change that is staged and nothing more: the worktree file equals the index. */
|
|
79
84
|
const isStagedOnly = (code: string): boolean =>
|
|
@@ -81,22 +86,27 @@ const isStagedOnly = (code: string): boolean =>
|
|
|
81
86
|
|
|
82
87
|
const withoutSlashes = (path: string): string => path.replace(/^\/+|\/+$/g, '')
|
|
83
88
|
|
|
84
|
-
|
|
89
|
+
/** The handover directory or anything in it: the close archives it, so removing the worktree loses none of it. */
|
|
90
|
+
const isHandoverPath = (path: string): boolean => {
|
|
91
|
+
const target = withoutSlashes(path)
|
|
92
|
+
return target === HANDOVER || target.startsWith(`${HANDOVER}/`)
|
|
93
|
+
}
|
|
85
94
|
|
|
86
95
|
/** The patterns of a `.gitignore`-style list that name files; comments, blanks and negations name none. */
|
|
87
96
|
const patternsOf = (lines: readonly string[]): string[] =>
|
|
88
97
|
lines.map((line) => line.trim()).filter((line) => line !== '' && !line.startsWith('#') && !line.startsWith('!'))
|
|
89
98
|
|
|
90
99
|
/**
|
|
91
|
-
* Whether `path` (relative to the worktree, a directory with its trailing slash) is
|
|
92
|
-
* pattern with an inner slash is anchored at the root, one without matches
|
|
100
|
+
* Whether `path` (relative to the worktree, a directory with its trailing slash) is named by a `.gitignore`-style
|
|
101
|
+
* list, or lies in a directory it names: a pattern with an inner slash is anchored at the root, one without matches
|
|
102
|
+
* the name at any depth.
|
|
93
103
|
*/
|
|
94
|
-
export const
|
|
104
|
+
export const matchesPatterns = (path: string, patterns: readonly string[]): boolean => {
|
|
95
105
|
const target = withoutSlashes(path)
|
|
96
|
-
return patternsOf(
|
|
106
|
+
return patternsOf(patterns).some((pattern) => {
|
|
97
107
|
const bare = withoutSlashes(pattern)
|
|
98
108
|
const glob = bare.includes('/') ? bare : `**/${bare}`
|
|
99
|
-
return
|
|
109
|
+
return [glob, bare, `${glob}/**`].some((candidate) => new Bun.Glob(candidate).match(target))
|
|
100
110
|
})
|
|
101
111
|
}
|
|
102
112
|
|
|
@@ -134,25 +144,51 @@ export const isBuildOutput = (path: string): boolean => {
|
|
|
134
144
|
|
|
135
145
|
/**
|
|
136
146
|
* The paths removing the worktree would delete for good: every tracked change and untracked file, and every ignored
|
|
137
|
-
* path except dependencies (`node_modules` at any depth), the setup files,
|
|
147
|
+
* path except dependencies (`node_modules` at any depth), the setup files, the project's disposable paths and build
|
|
148
|
+
* output. The handover directory is no loss either: the close archives it first.
|
|
138
149
|
*/
|
|
139
150
|
export const lostPathsOf = (
|
|
140
151
|
status: readonly StatusEntry[],
|
|
141
152
|
setupFiles: readonly string[],
|
|
142
153
|
baseHeldPaths: readonly string[] = [],
|
|
154
|
+
disposablePaths: readonly string[] = [],
|
|
143
155
|
): string[] => {
|
|
144
156
|
const kept = (path: string) =>
|
|
145
|
-
isDependencyPath(path) ||
|
|
157
|
+
isDependencyPath(path) ||
|
|
158
|
+
matchesPatterns(path, setupFiles) ||
|
|
159
|
+
matchesPatterns(path, disposablePaths) ||
|
|
160
|
+
isBuildOutput(path)
|
|
146
161
|
const held = ({ code, path }: StatusEntry) => isStagedOnly(code) && baseHeldPaths.includes(path)
|
|
147
162
|
return status
|
|
163
|
+
.filter(({ path }) => !isHandoverPath(path))
|
|
148
164
|
.filter((entry) => (entry.code !== IGNORED_CODE || !kept(entry.path)) && !held(entry))
|
|
149
165
|
.map(({ path }) => path)
|
|
150
166
|
}
|
|
151
167
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
168
|
+
const deletedPathsOf = ({ status, setupFiles, baseHeldPaths, disposablePaths }: CloseFacts): string[] =>
|
|
169
|
+
lostPathsOf(status, setupFiles, baseHeldPaths, disposablePaths)
|
|
170
|
+
|
|
171
|
+
/** What closing does with the lane's branch on origin: delete it at the head the check saw, leave it with the reason, or nothing to do. */
|
|
172
|
+
export type RemoteBranchPlan =
|
|
173
|
+
| { kind: 'delete'; branch: string; head: string }
|
|
174
|
+
| { kind: 'keep'; branch: string; reason: string }
|
|
175
|
+
| { kind: 'none' }
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The branch on origin goes only when it points to the head of the merged pull request, so nothing pushed after the
|
|
179
|
+
* merge is lost; otherwise it stays and the plan says why.
|
|
180
|
+
*/
|
|
181
|
+
export const remoteBranchPlanOf = ({ branch, pullRequest, remoteHead }: CloseFacts): RemoteBranchPlan => {
|
|
182
|
+
if (branch === null || remoteHead === null) return { kind: 'none' }
|
|
183
|
+
if (pullRequest === null) return { kind: 'keep', branch, reason: 'it has no merged pull request' }
|
|
184
|
+
if (remoteHead !== pullRequest.headCommit)
|
|
185
|
+
return {
|
|
186
|
+
kind: 'keep',
|
|
187
|
+
branch,
|
|
188
|
+
reason: `origin has ${remoteHead.slice(0, SHORT_COMMIT_LENGTH)}, not the merged head ${pullRequest.headCommit.slice(0, SHORT_COMMIT_LENGTH)} of #${pullRequest.number}`,
|
|
189
|
+
}
|
|
190
|
+
return { kind: 'delete', branch, head: remoteHead }
|
|
191
|
+
}
|
|
156
192
|
|
|
157
193
|
/** A lane the board marks cleaned up already may still have its worktree left over; only a lane it never named is refused. */
|
|
158
194
|
const boardRefusals = ({ lane, boardLane }: CloseFacts): string[] =>
|
|
@@ -183,7 +219,7 @@ const lockRefusals = ({ lane, locked }: CloseFacts): string[] =>
|
|
|
183
219
|
* Every reason not to close the lane, all at once; empty when nothing would be lost. Closing needs an open board row,
|
|
184
220
|
* the worktree on a branch whose newest merged pull request left the same tree on the base (a squash merge leaves the
|
|
185
221
|
* branch unmerged in git's eyes, so the trees are compared), nothing in the worktree beyond dependencies, the setup
|
|
186
|
-
* files and build output (`isBuildOutput`), an
|
|
222
|
+
* files, the project's disposable paths and build output (`isBuildOutput`), an agent that is gone or rests, and no lock
|
|
187
223
|
* on the worktree (a Claude Code session may still hold it).
|
|
188
224
|
*/
|
|
189
225
|
export const closeRefusals = (facts: CloseFacts): string[] => {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
1
|
+
import { cpSync, existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { join, relative, resolve } from 'node:path'
|
|
3
3
|
import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
|
|
4
4
|
import { TERMINAL_ADAPTER } from '../config'
|
|
5
5
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
6
6
|
import { boardFor } from '../plans/board-pick'
|
|
7
7
|
import { commitOf, mainCheckout, worktreeRoot } from '../plans/git'
|
|
8
|
+
import { settingsPlacingBoardLanes } from '../plans/lane-worktrees'
|
|
8
9
|
import { type PlansSettings, readSettings, worktreeOf } from '../plans/settings'
|
|
9
10
|
import { asksForHelp } from '../preview/options'
|
|
10
11
|
import { laneStatusSourceFor } from '../terminal'
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
baseHeldPathsOf,
|
|
21
22
|
mergedPullRequestOf,
|
|
22
23
|
openWorkspaceOf,
|
|
24
|
+
remoteHeadOf,
|
|
23
25
|
stagedPlanPathsOf,
|
|
24
26
|
type WorktreeEntry,
|
|
25
27
|
worktreeEntryOf,
|
|
@@ -179,19 +181,29 @@ const workspaceOf = (context: CloseContext): string | null =>
|
|
|
179
181
|
const absentWorktreeFactsOf = (context: CloseContext, branch: string | null): Promise<WorktreeFacts> | WorktreeFacts =>
|
|
180
182
|
branch === null ? ABSENT_WORKTREE : branchFactsOf(context, branch)
|
|
181
183
|
|
|
184
|
+
/** The commit the branch points to on origin, asked live; null when there is no branch to ask for or origin lists none. */
|
|
185
|
+
const remoteHeadFor = (context: CloseContext, branch: string | null): string | null =>
|
|
186
|
+
branch === null
|
|
187
|
+
? null
|
|
188
|
+
: remoteHeadOf(runGit(context.root, ['ls-remote', '--heads', 'origin', `${BRANCH_REF}${branch}`]), branch)
|
|
189
|
+
|
|
182
190
|
/** What git, GitHub and the terminal say about the lane now; throws when one of them cannot be asked. */
|
|
183
191
|
const surveyOf = async (lane: string, branch: string | null, context: CloseContext): Promise<CloseSurvey> => {
|
|
184
192
|
const entry = worktreeEntryOf(runGit(context.root, ['worktree', 'list', '--porcelain']), context.worktree)
|
|
185
193
|
const present = entry !== null && existsSync(context.worktree)
|
|
186
|
-
const facts: CloseFacts = {
|
|
194
|
+
const facts: Omit<CloseFacts, 'remoteHead'> = {
|
|
187
195
|
lane,
|
|
188
196
|
boardLane: boardLaneOf(readFileSync(context.boardFile, 'utf8'), lane, context.settings.boardLanguage),
|
|
189
197
|
setupFiles: setupFilesOf(context.root),
|
|
198
|
+
disposablePaths: context.settings.disposablePaths,
|
|
190
199
|
...agentFactsOf(laneStatusSourceFor(context.settings.adapter).listAgents(), lane, context.worktree),
|
|
191
200
|
runsInside: realPath(worktreeRoot(context.cwd)) === realPath(context.worktree),
|
|
192
201
|
...(present ? await worktreeFactsOf(context, entry) : await absentWorktreeFactsOf(context, branch)),
|
|
193
202
|
}
|
|
194
|
-
return {
|
|
203
|
+
return {
|
|
204
|
+
facts: { ...facts, remoteHead: remoteHeadFor(context, facts.branch) },
|
|
205
|
+
workspace: present ? workspaceOf(context) : null,
|
|
206
|
+
}
|
|
195
207
|
}
|
|
196
208
|
|
|
197
209
|
const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms))
|
|
@@ -211,7 +223,14 @@ const exitAgentIn =
|
|
|
211
223
|
throw new Error(`the agent in pane ${pane} still runs after ${EXIT_WAIT_MS / 1000} s`)
|
|
212
224
|
}
|
|
213
225
|
|
|
214
|
-
/**
|
|
226
|
+
/** Copies the handover directory of the worktree below the main checkout; throws when the copy fails, so nothing is removed after it. */
|
|
227
|
+
const archiveHandoverIn =
|
|
228
|
+
(context: CloseContext) =>
|
|
229
|
+
(worktree: string, destination: string): void => {
|
|
230
|
+
cpSync(join(worktree, claudePath(HANDOVER_DIRECTORY)), join(context.root, destination), { recursive: true })
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Git removes the worktree and refuses on any change itself (ignored files go with it, never needing `--force`); herdr then only closes the emptied workspace. */
|
|
215
234
|
const removeWorktreeIn =
|
|
216
235
|
(context: CloseContext) =>
|
|
217
236
|
(worktree: string, workspace: string | null): void => {
|
|
@@ -220,6 +239,17 @@ const removeWorktreeIn =
|
|
|
220
239
|
}
|
|
221
240
|
|
|
222
241
|
/** Deletes the branch only while it points to `head`; git refuses when it moved since the check. */
|
|
242
|
+
const deleteRemoteBranchIn =
|
|
243
|
+
(context: CloseContext) =>
|
|
244
|
+
(branch: string, head: string): void => {
|
|
245
|
+
runGit(context.root, [
|
|
246
|
+
'push',
|
|
247
|
+
'origin',
|
|
248
|
+
`--force-with-lease=${BRANCH_REF}${branch}:${head}`,
|
|
249
|
+
`:${BRANCH_REF}${branch}`,
|
|
250
|
+
])
|
|
251
|
+
}
|
|
252
|
+
|
|
223
253
|
const deleteBranchIn =
|
|
224
254
|
(context: CloseContext) =>
|
|
225
255
|
(branch: string, head: string): void => {
|
|
@@ -229,7 +259,9 @@ const deleteBranchIn =
|
|
|
229
259
|
const portsFor = (lane: string, branch: string | null, context: CloseContext): ClosePorts => ({
|
|
230
260
|
survey: () => surveyOf(lane, branch, context),
|
|
231
261
|
exitAgent: exitAgentIn(context),
|
|
262
|
+
archiveHandover: archiveHandoverIn(context),
|
|
232
263
|
removeWorktree: removeWorktreeIn(context),
|
|
264
|
+
deleteRemoteBranch: deleteRemoteBranchIn(context),
|
|
233
265
|
deleteBranch: deleteBranchIn(context),
|
|
234
266
|
readBoard: () => readFileSync(context.boardFile, 'utf8'),
|
|
235
267
|
writeBoard: (text) => writeFileSync(context.boardFile, text),
|
|
@@ -240,16 +272,17 @@ const portsFor = (lane: string, branch: string | null, context: CloseContext): C
|
|
|
240
272
|
/** The main checkout, its settings, the lane's worktree from those settings, and the board, with the pick's notice. */
|
|
241
273
|
const contextOf = (options: CloseOptions, cwd: string): { context: CloseContext; notice: string | null } => {
|
|
242
274
|
const root = mainCheckout(cwd)
|
|
243
|
-
const settings = readSettings([
|
|
275
|
+
const settings = readSettings([worktreeRoot(cwd), root])
|
|
244
276
|
const boardPath = options.boardPath === null ? null : resolve(cwd, options.boardPath)
|
|
245
277
|
const board = boardFor(boardPath, [root], settings, () => [options.lane])
|
|
246
|
-
const
|
|
247
|
-
|
|
278
|
+
const placed = settingsPlacingBoardLanes(root, settings, board.file, options.lane)
|
|
279
|
+
const worktree = worktreeOf(root, placed, options.lane)
|
|
280
|
+
return { context: { root, cwd, settings: placed, worktree, boardFile: board.file }, notice: board.notice }
|
|
248
281
|
}
|
|
249
282
|
|
|
250
283
|
/**
|
|
251
284
|
* `jh lane close <lane> [--dry-run] [--board <board.md>]`: closes a lane in one run once nothing would be lost, see
|
|
252
|
-
* `closeLane`. The worktree's path comes from the
|
|
285
|
+
* `closeLane`. The worktree's path comes from the lane's name, or from the one existing directory below the worktree directory that the lane's agent runs in when no directory carries the name; never from a reply. Exit 0 closed or
|
|
253
286
|
* dry run, 1 refused or failed.
|
|
254
287
|
*/
|
|
255
288
|
export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
|
package/src/lane/close-survey.ts
CHANGED
|
@@ -62,6 +62,15 @@ export const mergedPullRequestOf = (
|
|
|
62
62
|
return merged.sort((left, right) => right.number - left.number)[0] ?? null
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** The commit `branch` points to in the output of `git ls-remote --heads origin`; null when the remote lists no such branch. */
|
|
66
|
+
export const remoteHeadOf = (lsRemote: string, branch: string): string | null => {
|
|
67
|
+
const entry = lsRemote
|
|
68
|
+
.split('\n')
|
|
69
|
+
.map((line) => line.split('\t'))
|
|
70
|
+
.find(([, ref]) => ref === `${BRANCH_REF}${branch}`)
|
|
71
|
+
return entry !== undefined && isObjectId(entry[0]) ? entry[0] : null
|
|
72
|
+
}
|
|
73
|
+
|
|
65
74
|
const isInside = (cwd: string | null, worktree: string): boolean =>
|
|
66
75
|
cwd !== null && (cwd === worktree || cwd.startsWith(`${worktree}${sep}`))
|
|
67
76
|
|