@bakers-build/addon-herdr 0.0.0-stage → 0.1.0-next.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/src/agent-host.d.ts +45 -0
- package/dist/src/agent-host.js +9 -0
- package/dist/src/agents.d.ts +3 -0
- package/dist/src/agents.js +132 -0
- package/dist/src/config.d.ts +77 -0
- package/dist/src/config.js +29 -0
- package/dist/src/frozen-marker.d.ts +8 -0
- package/dist/src/frozen-marker.js +26 -0
- package/dist/src/fs.d.ts +22 -0
- package/dist/src/fs.js +58 -0
- package/dist/src/index.d.ts +219 -0
- package/dist/src/index.js +809 -0
- package/dist/src/launch-script.d.ts +34 -0
- package/dist/src/launch-script.js +98 -0
- package/dist/src/pane-layout.d.ts +45 -0
- package/dist/src/pane-layout.js +78 -0
- package/dist/src/panes.d.ts +31 -0
- package/dist/src/panes.js +87 -0
- package/dist/src/preset.d.ts +6 -0
- package/dist/src/preset.js +18 -0
- package/dist/src/process-host.d.ts +6 -0
- package/dist/src/process-host.js +46 -0
- package/dist/src/root-anchor.d.ts +13 -0
- package/dist/src/root-anchor.js +12 -0
- package/dist/src/workspace-list.d.ts +19 -0
- package/dist/src/workspace-list.js +29 -0
- package/dist/src/workspace.d.ts +185 -0
- package/dist/src/workspace.js +415 -0
- package/package.json +27 -4
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { BbWorkspace, EnsuredWorkspace, WorkspacePlugin } from '@bakers-build/conductor/contract';
|
|
2
|
+
import type { ConductorLogger } from '@bakers-build/core';
|
|
3
|
+
import type { AgentLaunchHost } from './agent-host.js';
|
|
4
|
+
import { type PaneSpec } from './config.js';
|
|
5
|
+
import { type RootGitExec } from './root-anchor.js';
|
|
6
|
+
export type HerdrExec = (args: string[]) => Promise<string>;
|
|
7
|
+
export type GitExec = (args: string[]) => Promise<string>;
|
|
8
|
+
/**
|
|
9
|
+
* Layout applied to the workspace's initial (root) pane on a FRESH worktree open
|
|
10
|
+
* (BB-139): `command` launches a tool in the main pane (e.g. `spiceedit`) so
|
|
11
|
+
* the human lands in an editor instead of a bare shell; `panes[]` are optional
|
|
12
|
+
* splits off it. Same shape as the executor's per-state layout minus `tabLabel`
|
|
13
|
+
* — there is only ONE open layout, not a per-ticket-state map.
|
|
14
|
+
*
|
|
15
|
+
* BB-234: `{identifier}`, `{branch}` and `{worktreePath}` expand in `command`
|
|
16
|
+
* **and** in every `panes[].command`. The root command is rendered here, by
|
|
17
|
+
* `ensure`, before the finished layout reaches `applyPaneLayout` — that shared
|
|
18
|
+
* helper types its root command VERBATIM on purpose, because its other caller
|
|
19
|
+
* (the executor) pre-assembles `KEY='value' <agent>` env prefixes whose values
|
|
20
|
+
* may legitimately contain braces (spec D2).
|
|
21
|
+
*
|
|
22
|
+
* BB-374: `tabLabel` / `panelLabel` are chrome strings applied after the
|
|
23
|
+
* layout via `herdr tab rename` / `herdr pane rename` ("panel label" = pane
|
|
24
|
+
* label). Defaults ship when keys are absent on an active layout; `open: {}`
|
|
25
|
+
* remains the full opt-out (no command, no splits, no labels). The overview
|
|
26
|
+
* **tab** default is `{identifier} · {title}` with `{title}` capped at
|
|
27
|
+
* {@link OPEN_TAB_TITLE_MAX} so the always-visible tab bar stays readable;
|
|
28
|
+
* the pane/panel label still gets the full title.
|
|
29
|
+
*
|
|
30
|
+
* Unset → {@link DEFAULT_OPEN_LAYOUT}, the ticket deep link. `{}` → the bare
|
|
31
|
+
* empty tab (`applyPaneLayout` issues no calls for an empty layout).
|
|
32
|
+
*/
|
|
33
|
+
export interface OpenLayout {
|
|
34
|
+
command?: string;
|
|
35
|
+
panes?: PaneSpec[];
|
|
36
|
+
/**
|
|
37
|
+
* Overview tab label template. Default: `{identifier} · {title}` (title
|
|
38
|
+
* truncated to {@link OPEN_TAB_TITLE_MAX} when rendering).
|
|
39
|
+
*/
|
|
40
|
+
tabLabel?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Overview root-pane label template (`herdr pane rename`). Default:
|
|
43
|
+
* `{identifier} · {title}` (full title).
|
|
44
|
+
*/
|
|
45
|
+
panelLabel?: string;
|
|
46
|
+
}
|
|
47
|
+
/** Max `{title}` length substituted into the overview **tab** label (BB-374). */
|
|
48
|
+
export declare const OPEN_TAB_TITLE_MAX = 30;
|
|
49
|
+
/** Cap overview-tab `{title}` and append `...` when truncated. */
|
|
50
|
+
export declare function truncateOpenTabTitle(title: string): string;
|
|
51
|
+
/** Default overview tab chrome (BB-374) — always-visible tab bar identity. */
|
|
52
|
+
export declare const DEFAULT_OPEN_TAB_LABEL = "{identifier} \u00B7 {title}";
|
|
53
|
+
/** Default overview pane/panel label template (BB-374). */
|
|
54
|
+
export declare const DEFAULT_OPEN_PANEL_LABEL = "{identifier} \u00B7 {title}";
|
|
55
|
+
/**
|
|
56
|
+
* The shipped default open layout (BB-234): a fresh worktree's initial pane
|
|
57
|
+
* renders the ticket it belongs to instead of an anonymous shell prompt.
|
|
58
|
+
*
|
|
59
|
+
* It lives in **code, not config**, because a default each repo has to opt into
|
|
60
|
+
* would leave the defect in place for every other consumer of this addon; the
|
|
61
|
+
* addon already ships opinionated defaults of this kind (`BUILTIN_DEFAULTS` in
|
|
62
|
+
* `./config.js`), and `bakers ui` is a command of the very host that ships it.
|
|
63
|
+
*
|
|
64
|
+
* `open: {}` is the documented opt-out back to the pre-BB-234 bare tab, so
|
|
65
|
+
* the default is not a one-way door.
|
|
66
|
+
*/
|
|
67
|
+
export declare const DEFAULT_OPEN_LAYOUT: OpenLayout;
|
|
68
|
+
export interface HerdrWorkspaceOptions {
|
|
69
|
+
/** Main clone (a git repo): herdr `--cwd` target + worktree base. */
|
|
70
|
+
root: string;
|
|
71
|
+
/** Relative dir (under root) for new worktrees. Default: `.bakers-worktrees`. */
|
|
72
|
+
worktreeDir?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Base ref new worktree branches are created from. Default: the remote
|
|
75
|
+
* tracking branch of the repo's checked-out branch (e.g. `origin/develop`),
|
|
76
|
+
* so runs start from the pushed remote state, not the host's local branch.
|
|
77
|
+
*/
|
|
78
|
+
baseBranch?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Startup layout for the initial pane, applied on a FRESH worktree open only
|
|
81
|
+
* (BB-139). Unset → {@link DEFAULT_OPEN_LAYOUT} (`bakers ui --ticket <ID>`,
|
|
82
|
+
* BB-234); `{}` → the bare empty tab is left unchanged.
|
|
83
|
+
*/
|
|
84
|
+
open?: OpenLayout;
|
|
85
|
+
/** Overrideable herdr exec fn — defaults to the real `herdr` binary. */
|
|
86
|
+
execHerdr?: HerdrExec;
|
|
87
|
+
/** Overrideable git exec fn — defaults to the real `git` binary. */
|
|
88
|
+
execGit?: GitExec;
|
|
89
|
+
/** Optional logger for the best-effort open-layout path (defaults to noop). */
|
|
90
|
+
logger?: ConductorLogger;
|
|
91
|
+
}
|
|
92
|
+
export declare class HerdrWorkspace implements BbWorkspace {
|
|
93
|
+
private readonly options;
|
|
94
|
+
private readonly execHerdr;
|
|
95
|
+
private readonly execGit;
|
|
96
|
+
private readonly root;
|
|
97
|
+
private readonly worktreeDir;
|
|
98
|
+
private readonly open;
|
|
99
|
+
private readonly logger;
|
|
100
|
+
/**
|
|
101
|
+
* Root panes captured by a FRESH `ensure` create, keyed by worktree path,
|
|
102
|
+
* awaiting {@link applyOpenLayout} (BB-237). One-shot: consumed on apply.
|
|
103
|
+
* Optional `tabId` powers the BB-374 overview tab rename.
|
|
104
|
+
*/
|
|
105
|
+
private readonly pendingRootPanes;
|
|
106
|
+
/** Herdr-backed intent-level agent host for `bakers ui` (BB-190). */
|
|
107
|
+
readonly agentHost: AgentLaunchHost;
|
|
108
|
+
constructor(options: HerdrWorkspaceOptions);
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the base ref for a new worktree branch. Prefers the configured
|
|
111
|
+
* `baseBranch`; otherwise the remote tracking branch of the repo's HEAD
|
|
112
|
+
* (e.g. `origin/develop`). Best-effort: a repo with no upstream returns
|
|
113
|
+
* `undefined`, leaving herdr to fall back to the parent workspace HEAD.
|
|
114
|
+
*/
|
|
115
|
+
private resolveBaseRef;
|
|
116
|
+
/**
|
|
117
|
+
* Fetch the base ref's remote branch so the new worktree starts from the
|
|
118
|
+
* latest pushed state. Best-effort: offline / unknown remote is non-fatal.
|
|
119
|
+
*/
|
|
120
|
+
private fetchBaseRef;
|
|
121
|
+
/**
|
|
122
|
+
* Verify a base-ref candidate resolves on the remote (after fetching it).
|
|
123
|
+
* Returns the ref when it exists, else undefined so the caller falls back.
|
|
124
|
+
*/
|
|
125
|
+
private verifyRemoteRef;
|
|
126
|
+
/**
|
|
127
|
+
* Ensure the branch's worktree + herdr workspace exist.
|
|
128
|
+
*
|
|
129
|
+
* `_identifier` is unused HERE: the worktree is located by branch, and since
|
|
130
|
+
* BB-237 the open layout — the one thing that consumed the identifier, to
|
|
131
|
+
* render `{identifier}` (BB-234) — is applied by {@link applyOpenLayout},
|
|
132
|
+
* which takes it on its own `ctx`. The parameter stays because it is the
|
|
133
|
+
* `BbWorkspace.ensure` contract position (the git workspace does use it).
|
|
134
|
+
* It is still taken as-passed rather than parsed back out of `branch`, which
|
|
135
|
+
* remains the reason the contract carries it at all.
|
|
136
|
+
*/
|
|
137
|
+
ensure(_identifier: string, branch?: string, baseRefOverride?: string): Promise<EnsuredWorkspace>;
|
|
138
|
+
/**
|
|
139
|
+
* Apply the open layout to the root pane captured by the most recent
|
|
140
|
+
* {@link ensure} create for `path` — by default (BB-234) the ticket's
|
|
141
|
+
* `bakers ui` deep link, so the tab shows the ticket instead of a bare shell.
|
|
142
|
+
* The pane's cwd is the worktree checkout, passed explicitly on the splits
|
|
143
|
+
* (the root pane's cwd is already the checkout).
|
|
144
|
+
*
|
|
145
|
+
* BB-374 then renames the overview tab (`{identifier} · {title}` with title
|
|
146
|
+
* capped at {@link OPEN_TAB_TITLE_MAX}) and root pane (full
|
|
147
|
+
* `{identifier} · {title}`) — fresh-create only, same gate as the layout.
|
|
148
|
+
*
|
|
149
|
+
* NO stored pane ⇒ silent no-op. That is the reused-worktree case: `ensure`
|
|
150
|
+
* took the reuse branch, there is no fresh root pane, and re-applying would
|
|
151
|
+
* start a duplicate editor process on reattach (BB-139). The capture is
|
|
152
|
+
* one-shot — consumed here — so a repeat call cannot double-apply and the map
|
|
153
|
+
* cannot grow without bound.
|
|
154
|
+
*
|
|
155
|
+
* Best-effort — mirrors the `fetchBaseRef` pattern: a broken layout is logged
|
|
156
|
+
* and swallowed so a startup-command failure can NEVER wedge dispatch.
|
|
157
|
+
*/
|
|
158
|
+
applyOpenLayout(path: string, ctx: {
|
|
159
|
+
identifier: string;
|
|
160
|
+
branch: string;
|
|
161
|
+
title: string;
|
|
162
|
+
}): Promise<void>;
|
|
163
|
+
/**
|
|
164
|
+
* Best-effort overview tab + pane renames (BB-374). Skipped for the
|
|
165
|
+
* `open: {}` opt-out. A missing create-time tab id falls back to `pane get`.
|
|
166
|
+
*/
|
|
167
|
+
private applyOverviewLabels;
|
|
168
|
+
/** `pane get <id>` → `result.pane.tab_id` when present. */
|
|
169
|
+
private resolveTabIdForPane;
|
|
170
|
+
}
|
|
171
|
+
export declare function herdrWorkspace(opts?: {
|
|
172
|
+
/**
|
|
173
|
+
* Explicit repo-root override; wins over the git-derived anchor. Otherwise
|
|
174
|
+
* the main clone is derived from the git common dir (BB-177).
|
|
175
|
+
*/
|
|
176
|
+
root?: string;
|
|
177
|
+
worktreeDir?: string;
|
|
178
|
+
baseBranch?: string;
|
|
179
|
+
open?: OpenLayout;
|
|
180
|
+
/** Overrideable git exec for root derivation (tests). */
|
|
181
|
+
execGit?: RootGitExec;
|
|
182
|
+
/** Overrideable herdr exec (tests) — sibling of `execGit`. */
|
|
183
|
+
execHerdr?: HerdrExec;
|
|
184
|
+
}): WorkspacePlugin;
|
|
185
|
+
export default herdrWorkspace;
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { loadInstructions } from '@bakers-build/addon-workspace-git';
|
|
3
|
+
import { exec } from '@bakers-build/core';
|
|
4
|
+
import { herdrAgentHost } from './agents.js';
|
|
5
|
+
import { renderTemplate } from './config.js';
|
|
6
|
+
import { applyPaneLayout, isRecord, parseJson } from './pane-layout.js';
|
|
7
|
+
import { deriveHerdrRoot } from './root-anchor.js';
|
|
8
|
+
/** Max `{title}` length substituted into the overview **tab** label (BB-374). */
|
|
9
|
+
export const OPEN_TAB_TITLE_MAX = 30;
|
|
10
|
+
/** Cap overview-tab `{title}` and append `...` when truncated. */
|
|
11
|
+
export function truncateOpenTabTitle(title) {
|
|
12
|
+
if (title.length <= OPEN_TAB_TITLE_MAX) {
|
|
13
|
+
return title;
|
|
14
|
+
}
|
|
15
|
+
return `${title.slice(0, OPEN_TAB_TITLE_MAX)}...`;
|
|
16
|
+
}
|
|
17
|
+
/** Default overview tab chrome (BB-374) — always-visible tab bar identity. */
|
|
18
|
+
export const DEFAULT_OPEN_TAB_LABEL = '{identifier} · {title}';
|
|
19
|
+
/** Default overview pane/panel label template (BB-374). */
|
|
20
|
+
export const DEFAULT_OPEN_PANEL_LABEL = '{identifier} · {title}';
|
|
21
|
+
/**
|
|
22
|
+
* The shipped default open layout (BB-234): a fresh worktree's initial pane
|
|
23
|
+
* renders the ticket it belongs to instead of an anonymous shell prompt.
|
|
24
|
+
*
|
|
25
|
+
* It lives in **code, not config**, because a default each repo has to opt into
|
|
26
|
+
* would leave the defect in place for every other consumer of this addon; the
|
|
27
|
+
* addon already ships opinionated defaults of this kind (`BUILTIN_DEFAULTS` in
|
|
28
|
+
* `./config.js`), and `bakers ui` is a command of the very host that ships it.
|
|
29
|
+
*
|
|
30
|
+
* `open: {}` is the documented opt-out back to the pre-BB-234 bare tab, so
|
|
31
|
+
* the default is not a one-way door.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_OPEN_LAYOUT = {
|
|
34
|
+
command: 'bakers ui --ticket {identifier}',
|
|
35
|
+
tabLabel: DEFAULT_OPEN_TAB_LABEL,
|
|
36
|
+
panelLabel: DEFAULT_OPEN_PANEL_LABEL,
|
|
37
|
+
};
|
|
38
|
+
const defaultExec = (args) => exec('herdr', args);
|
|
39
|
+
const defaultGitExec = (args) => exec('git', args);
|
|
40
|
+
const noopLogger = {
|
|
41
|
+
debug() { },
|
|
42
|
+
info() { },
|
|
43
|
+
warn() { },
|
|
44
|
+
error() { },
|
|
45
|
+
};
|
|
46
|
+
/** Parse `herdr worktree list --json` → array of on-disk worktrees. */
|
|
47
|
+
function parseWorktreeList(output) {
|
|
48
|
+
const parsed = parseJson(output, 'worktree list');
|
|
49
|
+
const worktrees = isRecord(parsed)
|
|
50
|
+
? parsed.result?.worktrees
|
|
51
|
+
: undefined;
|
|
52
|
+
if (!Array.isArray(worktrees)) {
|
|
53
|
+
throw new Error('herdr worktree list returned invalid schema');
|
|
54
|
+
}
|
|
55
|
+
return worktrees
|
|
56
|
+
.filter(isRecord)
|
|
57
|
+
.filter((w) => typeof w.branch === 'string' && typeof w.path === 'string')
|
|
58
|
+
.map((w) => ({
|
|
59
|
+
branch: w.branch,
|
|
60
|
+
path: w.path,
|
|
61
|
+
...(typeof w.open_workspace_id === 'string'
|
|
62
|
+
? { open_workspace_id: w.open_workspace_id }
|
|
63
|
+
: {}),
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
/** Parse `worktree create`/`worktree open` → the created/opened worktree path. */
|
|
67
|
+
function parseWorktreePath(output, command) {
|
|
68
|
+
const parsed = parseJson(output, command);
|
|
69
|
+
const path = isRecord(parsed)
|
|
70
|
+
? parsed.result?.worktree
|
|
71
|
+
?.path
|
|
72
|
+
: undefined;
|
|
73
|
+
if (typeof path !== 'string') {
|
|
74
|
+
throw new Error(`herdr ${command} returned invalid schema`);
|
|
75
|
+
}
|
|
76
|
+
return path;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Parse `worktree create` → root pane id (+ optional tab id for BB-374
|
|
80
|
+
* overview tab rename). Sibling of {@link parseWorktreePath}. Returns null when
|
|
81
|
+
* the create JSON carries no pane id, so the open-layout caller can skip
|
|
82
|
+
* silently rather than throw (best-effort).
|
|
83
|
+
*/
|
|
84
|
+
function parseWorktreeRootPane(output) {
|
|
85
|
+
const parsed = parseJson(output, 'worktree create');
|
|
86
|
+
if (!isRecord(parsed) || !isRecord(parsed.result))
|
|
87
|
+
return null;
|
|
88
|
+
const result = parsed.result;
|
|
89
|
+
const paneId = result.root_pane?.pane_id;
|
|
90
|
+
if (typeof paneId !== 'string' || !paneId)
|
|
91
|
+
return null;
|
|
92
|
+
const tabIdRaw = result.root_pane?.tab_id ?? result.tab?.tab_id;
|
|
93
|
+
const tabId = typeof tabIdRaw === 'string' && tabIdRaw ? tabIdRaw : undefined;
|
|
94
|
+
return tabId !== undefined ? { paneId, tabId } : { paneId };
|
|
95
|
+
}
|
|
96
|
+
/** Git-safe branch validation: safe ref chars only, no leading dot, no `..`. */
|
|
97
|
+
function validateBranch(branch) {
|
|
98
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(branch) ||
|
|
99
|
+
branch.startsWith('.') ||
|
|
100
|
+
branch.includes('..')) {
|
|
101
|
+
throw new Error(`invalid workspace branch: ${branch}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export class HerdrWorkspace {
|
|
105
|
+
options;
|
|
106
|
+
execHerdr;
|
|
107
|
+
execGit;
|
|
108
|
+
root;
|
|
109
|
+
worktreeDir;
|
|
110
|
+
open;
|
|
111
|
+
logger;
|
|
112
|
+
/**
|
|
113
|
+
* Root panes captured by a FRESH `ensure` create, keyed by worktree path,
|
|
114
|
+
* awaiting {@link applyOpenLayout} (BB-237). One-shot: consumed on apply.
|
|
115
|
+
* Optional `tabId` powers the BB-374 overview tab rename.
|
|
116
|
+
*/
|
|
117
|
+
pendingRootPanes = new Map();
|
|
118
|
+
/** Herdr-backed intent-level agent host for `bakers ui` (BB-190). */
|
|
119
|
+
agentHost;
|
|
120
|
+
constructor(options) {
|
|
121
|
+
this.options = options;
|
|
122
|
+
this.execHerdr = options.execHerdr ?? defaultExec;
|
|
123
|
+
this.execGit = options.execGit ?? defaultGitExec;
|
|
124
|
+
this.root = resolve(options.root);
|
|
125
|
+
this.worktreeDir = options.worktreeDir ?? '.bakers-worktrees';
|
|
126
|
+
// `??`, not `||`: an explicit `open: {}` must survive as the opt-out.
|
|
127
|
+
this.open = options.open ?? DEFAULT_OPEN_LAYOUT;
|
|
128
|
+
this.logger = options.logger ?? noopLogger;
|
|
129
|
+
this.agentHost = herdrAgentHost(this.execHerdr);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the base ref for a new worktree branch. Prefers the configured
|
|
133
|
+
* `baseBranch`; otherwise the remote tracking branch of the repo's HEAD
|
|
134
|
+
* (e.g. `origin/develop`). Best-effort: a repo with no upstream returns
|
|
135
|
+
* `undefined`, leaving herdr to fall back to the parent workspace HEAD.
|
|
136
|
+
*/
|
|
137
|
+
async resolveBaseRef() {
|
|
138
|
+
if (this.options.baseBranch) {
|
|
139
|
+
return this.options.baseBranch;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const ref = (await this.execGit([
|
|
143
|
+
'-C',
|
|
144
|
+
this.root,
|
|
145
|
+
'rev-parse',
|
|
146
|
+
'--abbrev-ref',
|
|
147
|
+
'--symbolic-full-name',
|
|
148
|
+
'@{u}',
|
|
149
|
+
])).trim();
|
|
150
|
+
return ref || undefined;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Fetch the base ref's remote branch so the new worktree starts from the
|
|
158
|
+
* latest pushed state. Best-effort: offline / unknown remote is non-fatal.
|
|
159
|
+
*/
|
|
160
|
+
async fetchBaseRef(baseRef) {
|
|
161
|
+
const slash = baseRef.indexOf('/');
|
|
162
|
+
if (slash <= 0) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const remote = baseRef.slice(0, slash);
|
|
166
|
+
const branch = baseRef.slice(slash + 1);
|
|
167
|
+
try {
|
|
168
|
+
await this.execGit(['-C', this.root, 'fetch', remote, branch]);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Non-fatal: use whatever the local tracking ref already points at.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Verify a base-ref candidate resolves on the remote (after fetching it).
|
|
176
|
+
* Returns the ref when it exists, else undefined so the caller falls back.
|
|
177
|
+
*/
|
|
178
|
+
async verifyRemoteRef(ref) {
|
|
179
|
+
await this.fetchBaseRef(ref);
|
|
180
|
+
try {
|
|
181
|
+
await this.execGit([
|
|
182
|
+
'-C',
|
|
183
|
+
this.root,
|
|
184
|
+
'rev-parse',
|
|
185
|
+
'--verify',
|
|
186
|
+
'--quiet',
|
|
187
|
+
`${ref}^{commit}`,
|
|
188
|
+
]);
|
|
189
|
+
return ref;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Ensure the branch's worktree + herdr workspace exist.
|
|
197
|
+
*
|
|
198
|
+
* `_identifier` is unused HERE: the worktree is located by branch, and since
|
|
199
|
+
* BB-237 the open layout — the one thing that consumed the identifier, to
|
|
200
|
+
* render `{identifier}` (BB-234) — is applied by {@link applyOpenLayout},
|
|
201
|
+
* which takes it on its own `ctx`. The parameter stays because it is the
|
|
202
|
+
* `BbWorkspace.ensure` contract position (the git workspace does use it).
|
|
203
|
+
* It is still taken as-passed rather than parsed back out of `branch`, which
|
|
204
|
+
* remains the reason the contract carries it at all.
|
|
205
|
+
*/
|
|
206
|
+
async ensure(_identifier, branch, baseRefOverride) {
|
|
207
|
+
if (branch === undefined) {
|
|
208
|
+
throw new Error('herdr workspace requires a branch name');
|
|
209
|
+
}
|
|
210
|
+
validateBranch(branch);
|
|
211
|
+
const worktrees = parseWorktreeList(await this.execHerdr(['worktree', 'list', '--cwd', this.root, '--json']));
|
|
212
|
+
const existing = worktrees.find((w) => w.branch === branch);
|
|
213
|
+
if (existing) {
|
|
214
|
+
// Reuse: an existing worktree is never re-created, so created=false — the
|
|
215
|
+
// core skips after_create only when a success is recorded for it. The open layout is NOT
|
|
216
|
+
// re-applied on reuse (BB-139): no duplicate editor process on reattach.
|
|
217
|
+
let path = existing.path;
|
|
218
|
+
if (!existing.open_workspace_id) {
|
|
219
|
+
// On-disk worktree with no open herdr workspace — open it.
|
|
220
|
+
path = parseWorktreePath(await this.execHerdr([
|
|
221
|
+
'worktree',
|
|
222
|
+
'open',
|
|
223
|
+
'--cwd',
|
|
224
|
+
this.root,
|
|
225
|
+
'--branch',
|
|
226
|
+
branch,
|
|
227
|
+
'--no-focus',
|
|
228
|
+
'--json',
|
|
229
|
+
]), 'worktree open');
|
|
230
|
+
}
|
|
231
|
+
return { path, instructions: loadInstructions(path), created: false };
|
|
232
|
+
}
|
|
233
|
+
// Fresh: create the git worktree + herdr workspace in one call. Base the
|
|
234
|
+
// new branch on the (freshly fetched) remote tracking branch, not the
|
|
235
|
+
// host's local checked-out branch. A ticket passes its `origin/<base_branch>`
|
|
236
|
+
// as the override; use it only when it resolves on the remote, else fall
|
|
237
|
+
// back to the default base.
|
|
238
|
+
let baseRef;
|
|
239
|
+
if (baseRefOverride !== undefined) {
|
|
240
|
+
baseRef = await this.verifyRemoteRef(baseRefOverride);
|
|
241
|
+
}
|
|
242
|
+
if (baseRef === undefined) {
|
|
243
|
+
baseRef = await this.resolveBaseRef();
|
|
244
|
+
if (baseRef !== undefined) {
|
|
245
|
+
await this.fetchBaseRef(baseRef);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const createOutput = await this.execHerdr([
|
|
249
|
+
'worktree',
|
|
250
|
+
'create',
|
|
251
|
+
'--cwd',
|
|
252
|
+
this.root,
|
|
253
|
+
'--branch',
|
|
254
|
+
branch,
|
|
255
|
+
...(baseRef !== undefined ? ['--base', baseRef] : []),
|
|
256
|
+
'--label',
|
|
257
|
+
branch,
|
|
258
|
+
// ABSOLUTE, never relative: herdr canonicalizes a relative `--path`
|
|
259
|
+
// against its own `current_dir()`, which fails with
|
|
260
|
+
// `Os { code: 2, kind: NotFound }` whenever the CALLER's cwd has been
|
|
261
|
+
// deleted — e.g. a conductor still polling from a reaped worktree. That
|
|
262
|
+
// killed every dispatch with an unattributable herdr exit 1. `resolve`
|
|
263
|
+
// (not `join`) so an absolute `worktreeDir` override still wins.
|
|
264
|
+
'--path',
|
|
265
|
+
resolve(this.root, this.worktreeDir, branch),
|
|
266
|
+
'--no-focus',
|
|
267
|
+
'--json',
|
|
268
|
+
]);
|
|
269
|
+
const path = parseWorktreePath(createOutput, 'worktree create');
|
|
270
|
+
// BB-237: CAPTURE the fresh root pane here, but do NOT lay it out yet.
|
|
271
|
+
// The layout's startup command names the bakers CLI, which the `after_create`
|
|
272
|
+
// hook builds — and that hook runs after `ensure()` returns. Applying the
|
|
273
|
+
// layout here fired the command before its binary existed, which is why the
|
|
274
|
+
// pane command needed a sentinel wait loop. The conductor now calls
|
|
275
|
+
// `applyOpenLayout` after the hook; a create with no root pane in its output
|
|
276
|
+
// stores nothing, so the layout is a no-op exactly as before.
|
|
277
|
+
const pending = parseWorktreeRootPane(createOutput);
|
|
278
|
+
if (pending) {
|
|
279
|
+
this.pendingRootPanes.set(path, pending);
|
|
280
|
+
}
|
|
281
|
+
// The `after_create` hook is run by the executor (BB-84), not here — the
|
|
282
|
+
// workspace only reports that it created a fresh worktree.
|
|
283
|
+
return { path, instructions: loadInstructions(path), created: true };
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Apply the open layout to the root pane captured by the most recent
|
|
287
|
+
* {@link ensure} create for `path` — by default (BB-234) the ticket's
|
|
288
|
+
* `bakers ui` deep link, so the tab shows the ticket instead of a bare shell.
|
|
289
|
+
* The pane's cwd is the worktree checkout, passed explicitly on the splits
|
|
290
|
+
* (the root pane's cwd is already the checkout).
|
|
291
|
+
*
|
|
292
|
+
* BB-374 then renames the overview tab (`{identifier} · {title}` with title
|
|
293
|
+
* capped at {@link OPEN_TAB_TITLE_MAX}) and root pane (full
|
|
294
|
+
* `{identifier} · {title}`) — fresh-create only, same gate as the layout.
|
|
295
|
+
*
|
|
296
|
+
* NO stored pane ⇒ silent no-op. That is the reused-worktree case: `ensure`
|
|
297
|
+
* took the reuse branch, there is no fresh root pane, and re-applying would
|
|
298
|
+
* start a duplicate editor process on reattach (BB-139). The capture is
|
|
299
|
+
* one-shot — consumed here — so a repeat call cannot double-apply and the map
|
|
300
|
+
* cannot grow without bound.
|
|
301
|
+
*
|
|
302
|
+
* Best-effort — mirrors the `fetchBaseRef` pattern: a broken layout is logged
|
|
303
|
+
* and swallowed so a startup-command failure can NEVER wedge dispatch.
|
|
304
|
+
*/
|
|
305
|
+
async applyOpenLayout(path, ctx) {
|
|
306
|
+
const pending = this.pendingRootPanes.get(path);
|
|
307
|
+
if (!pending) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
this.pendingRootPanes.delete(path);
|
|
311
|
+
const { paneId: rootPaneId } = pending;
|
|
312
|
+
try {
|
|
313
|
+
// Render the ROOT command here (spec D2). `applyPaneLayout` types its
|
|
314
|
+
// root command verbatim for the executor's brace-bearing env-prefixed
|
|
315
|
+
// agent command, so templating it there would corrupt that; the split
|
|
316
|
+
// commands it already renders from the same `vars`.
|
|
317
|
+
const vars = {
|
|
318
|
+
identifier: ctx.identifier,
|
|
319
|
+
branch: ctx.branch,
|
|
320
|
+
title: ctx.title,
|
|
321
|
+
worktreePath: path,
|
|
322
|
+
};
|
|
323
|
+
const layout = {
|
|
324
|
+
...this.open,
|
|
325
|
+
...(this.open.command !== undefined
|
|
326
|
+
? { command: renderTemplate(this.open.command, vars) }
|
|
327
|
+
: {}),
|
|
328
|
+
};
|
|
329
|
+
await applyPaneLayout(this.execHerdr, rootPaneId, layout, path, vars);
|
|
330
|
+
await this.applyOverviewLabels(rootPaneId, pending.tabId, vars);
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
this.logger.warn({ branch: ctx.branch, worktree: path, err: String(err) }, 'herdr open-layout failed (best-effort, ignored)');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Best-effort overview tab + pane renames (BB-374). Skipped for the
|
|
338
|
+
* `open: {}` opt-out. A missing create-time tab id falls back to `pane get`.
|
|
339
|
+
*/
|
|
340
|
+
async applyOverviewLabels(rootPaneId, createTabId, vars) {
|
|
341
|
+
const emptyOptOut = this.open.command === undefined &&
|
|
342
|
+
!this.open.panes?.length &&
|
|
343
|
+
this.open.tabLabel === undefined &&
|
|
344
|
+
this.open.panelLabel === undefined;
|
|
345
|
+
if (emptyOptOut) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// Tab bar is always visible; cap title length and mark truncation with
|
|
349
|
+
// `...`. Pane/panel label keeps the full title for pane borders.
|
|
350
|
+
const tabVars = {
|
|
351
|
+
...vars,
|
|
352
|
+
title: truncateOpenTabTitle(vars.title ?? ''),
|
|
353
|
+
};
|
|
354
|
+
const tabLabel = renderTemplate(this.open.tabLabel ?? DEFAULT_OPEN_TAB_LABEL, tabVars);
|
|
355
|
+
const panelLabel = renderTemplate(this.open.panelLabel ?? DEFAULT_OPEN_PANEL_LABEL, vars);
|
|
356
|
+
let tabId = createTabId;
|
|
357
|
+
if (!tabId) {
|
|
358
|
+
try {
|
|
359
|
+
tabId = (await this.resolveTabIdForPane(rootPaneId)) ?? undefined;
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
this.logger.warn({ paneId: rootPaneId, err: String(err) }, 'herdr overview tab id lookup failed (best-effort, ignored)');
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (tabId) {
|
|
366
|
+
try {
|
|
367
|
+
await this.execHerdr(['tab', 'rename', tabId, tabLabel]);
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
this.logger.warn({ tabId, err: String(err) }, 'herdr overview tab rename failed (best-effort, ignored)');
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
await this.execHerdr(['pane', 'rename', rootPaneId, panelLabel]);
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
this.logger.warn({ paneId: rootPaneId, err: String(err) }, 'herdr overview pane rename failed (best-effort, ignored)');
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/** `pane get <id>` → `result.pane.tab_id` when present. */
|
|
381
|
+
async resolveTabIdForPane(paneId) {
|
|
382
|
+
const output = await this.execHerdr(['pane', 'get', paneId, '--json']);
|
|
383
|
+
const parsed = parseJson(output, 'pane get');
|
|
384
|
+
const tabId = isRecord(parsed)
|
|
385
|
+
? parsed.result?.pane
|
|
386
|
+
?.tab_id
|
|
387
|
+
: undefined;
|
|
388
|
+
return typeof tabId === 'string' && tabId ? tabId : null;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
export function herdrWorkspace(opts = {}) {
|
|
392
|
+
return {
|
|
393
|
+
kind: 'workspace',
|
|
394
|
+
id: 'herdr',
|
|
395
|
+
requiredModules: [],
|
|
396
|
+
// BB-234: `deps` carries the conductor logger, so the best-effort
|
|
397
|
+
// open-layout failure is actually logged instead of vanishing into the noop
|
|
398
|
+
// logger. Optional per the contract — absent, the noop default stays.
|
|
399
|
+
async createWorkspace(config, deps) {
|
|
400
|
+
const root = await deriveHerdrRoot(config.config_path, {
|
|
401
|
+
...(opts.root !== undefined ? { override: opts.root } : {}),
|
|
402
|
+
...(opts.execGit ? { execGit: opts.execGit } : {}),
|
|
403
|
+
});
|
|
404
|
+
return new HerdrWorkspace({
|
|
405
|
+
root,
|
|
406
|
+
...(opts.worktreeDir ? { worktreeDir: opts.worktreeDir } : {}),
|
|
407
|
+
...(opts.baseBranch ? { baseBranch: opts.baseBranch } : {}),
|
|
408
|
+
...(opts.open ? { open: opts.open } : {}),
|
|
409
|
+
...(opts.execHerdr ? { execHerdr: opts.execHerdr } : {}),
|
|
410
|
+
...(deps?.logger ? { logger: deps.logger } : {}),
|
|
411
|
+
});
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
export default herdrWorkspace;
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakers-build/addon-herdr",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-next.38",
|
|
4
|
+
"description": "bakers.build herdr integration: spawn executor + per-ticket worktree workspace.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/src/index.js",
|
|
9
|
+
"./preset": "./dist/src/preset.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist/src"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://git.key-tec.de/bakers-build/bakers-build.git",
|
|
20
|
+
"directory": "bakers-cli/addons/herdr"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@bakers-build/addon-workspace-git": "^0.1.0-next.38"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@bakers-build/conductor": "^0.1.0-next.38",
|
|
27
|
+
"@bakers-build/core": "^0.1.0-next.38"
|
|
28
|
+
}
|
|
29
|
+
}
|