mintree 0.4.6 → 0.4.9
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 +32 -4
- package/dist/commands/dashboard.js +34 -15
- package/dist/commands/worktree/create.js +1 -1
- package/dist/lib/branch.d.ts +33 -4
- package/dist/lib/branch.js +91 -10
- package/dist/lib/providers/linear.js +13 -2
- package/dist/lib/providers/types.d.ts +1 -0
- package/dist/lib/worktreeCreate.js +29 -4
- package/dist/lib/worktreeRemove.d.ts +4 -0
- package/dist/lib/worktreeRemove.js +8 -9
- package/package.json +4 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Martin Mineo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">mintree</h1>
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>Issue-driven Git worktrees + Claude Code sessions</strong>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://www.npmjs.com/package/mintree"><img src="https://img.shields.io/npm/v/mintree.svg" alt="npm version"></a>
|
|
9
|
+
<a href="https://www.npmjs.com/package/mintree"><img src="https://img.shields.io/npm/dm/mintree.svg" alt="npm downloads"></a>
|
|
10
|
+
<a href="https://github.com/minex-labs/mintree/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/mintree.svg" alt="license"></a>
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
<p align="center">
|
|
14
|
+
Pick an issue, spin up an isolated worktree, and work it with Claude — for repos with an opinionated SDD+TDD flow.
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
---
|
|
4
18
|
|
|
5
19
|
mintree wraps the steps you do manually every time a feature begins:
|
|
6
20
|
|
|
@@ -145,6 +159,9 @@ Same building blocks, scriptable from any shell:
|
|
|
145
159
|
mintree worktree create feat/100-validar-patente
|
|
146
160
|
mintree worktree create feat/FE-123-validar-patente --work --prompt "empezar FE-123"
|
|
147
161
|
|
|
162
|
+
# On a Linear repo you can pass the issue's own Linear branch name
|
|
163
|
+
mintree worktree create martinmineo/val-68-landing-publica --work
|
|
164
|
+
|
|
148
165
|
# Resume Claude in the worktree you're currently inside
|
|
149
166
|
# (the worktree dir is the bare issue id)
|
|
150
167
|
cd .mintree/worktrees/FE-123
|
|
@@ -164,7 +181,7 @@ mintree worktree clean # sweep worktrees whose PR is mer
|
|
|
164
181
|
|
|
165
182
|
## Branch convention
|
|
166
183
|
|
|
167
|
-
mintree enforces:
|
|
184
|
+
By default mintree enforces:
|
|
168
185
|
|
|
169
186
|
```
|
|
170
187
|
<type>/<issue>-<kebab-desc>
|
|
@@ -185,6 +202,17 @@ Examples: `feat/42-validacion-patente`, `fix/55-selfie-upload-timeout`, `feat/FE
|
|
|
185
202
|
|
|
186
203
|
When the dashboard's `w` overlay opens, it suggests a kebab description capped at 5 words. If your repo has a `docs/conventions/git-workflow.md`, `CONTRIBUTING.md`, or `.claude/skills/` directory, mintree mentions it on the overlay so you can verify the suggestion against your project's rules — then edit the description to match.
|
|
187
204
|
|
|
205
|
+
### Linear repos: branches come from Linear
|
|
206
|
+
|
|
207
|
+
Many Linear-tracked repos follow the branch name Linear suggests — `<user>/<team>-<n>-<desc>`, e.g. `martinmineo/val-68-landing-publica` — rather than the `<type>/<issue>-<desc>` convention above. That value is the issue's `branchName` (a.k.a. gitBranchName), and it depends on the branch-name prefix configured in your Linear workspace.
|
|
208
|
+
|
|
209
|
+
So **when `provider` is `linear` and the issue has a `branchName`, mintree uses it verbatim** instead of synthesising a `<type>/<issue>-<desc>` branch:
|
|
210
|
+
|
|
211
|
+
- In the dashboard's `w` overlay, the "new branch" mode shows the Linear branch (read-only, labelled `from Linear`) and skips the type/description fields.
|
|
212
|
+
- From the CLI, you can pass the Linear branch directly: `mintree worktree create martinmineo/val-68-landing-publica`. mintree finds the Linear identifier (`val-68`) by matching it against your configured `linear.teams[].key`, and normalises it to the canonical `VAL-68`.
|
|
213
|
+
|
|
214
|
+
The worktree directory is still the **bare, upper-case issue id** (`VAL-68`) regardless of the branch name, matching the GitHub case. The `<type>/<issue>-<desc>` convention is still accepted on Linear repos too — it's used as a fallback when an issue has no `branchName`. GitHub repos are unaffected: they always use the convention and reject Linear-style branches.
|
|
215
|
+
|
|
188
216
|
---
|
|
189
217
|
|
|
190
218
|
## What gets stored where
|
|
@@ -202,7 +230,7 @@ When the dashboard's `w` overlay opens, it suggests a kebab description capped a
|
|
|
202
230
|
└── init.sh # opt-in. Runs in the new worktree post-create (copy .env, install deps, …)
|
|
203
231
|
```
|
|
204
232
|
|
|
205
|
-
The worktree directory is named after the bare issue id (`100`, `FE-123`); the branch keeps
|
|
233
|
+
The worktree directory is named after the bare issue id (`100`, `FE-123`, `VAL-68`); the branch keeps its full name — `<type>/<issue>-<desc>` for the convention, or Linear's own `<user>/<team>-<n>-<desc>` on Linear repos.
|
|
206
234
|
|
|
207
235
|
`metadata.json` is gitignored because the `session_id` is local to your machine — sharing it would only generate noise. The `provider` and `linear.*` keys can be re-derived from a Linear workspace if needed; sharing them would just leak local config preference.
|
|
208
236
|
|
|
@@ -204,14 +204,22 @@ function RemoveOverlayView({ overlay }) {
|
|
|
204
204
|
function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
|
|
205
205
|
const labelWidth = 14;
|
|
206
206
|
const isNewBranch = overlay.branchMode === "new";
|
|
207
|
+
const isLinearBranch = overlay.linearBranch !== null;
|
|
208
|
+
// type/desc only apply to the convention "new branch" path — hidden both
|
|
209
|
+
// for detached ("current") mode and for the Linear-branchName case.
|
|
210
|
+
const showTypeDesc = isNewBranch && !isLinearBranch;
|
|
207
211
|
const detachedDesc = kebabize(overlay.issue.issue.title) || `issue-${overlay.issue.issue.id}`;
|
|
208
|
-
const branchPreview = isNewBranch
|
|
209
|
-
?
|
|
210
|
-
:
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
212
|
+
const branchPreview = !isNewBranch
|
|
213
|
+
? `detached @ ${overlay.currentBranch ?? "(unknown)"}`
|
|
214
|
+
: isLinearBranch
|
|
215
|
+
? overlay.linearBranch
|
|
216
|
+
: `${overlay.type}/${overlay.issue.issue.id}-${overlay.desc}`;
|
|
217
|
+
const dirPreview = !isNewBranch
|
|
218
|
+
? `${overlay.issue.issue.id}-${detachedDesc}`
|
|
219
|
+
: isLinearBranch
|
|
220
|
+
? overlay.issue.issue.id
|
|
221
|
+
: `${overlay.issue.issue.id}-${overlay.desc}`;
|
|
222
|
+
return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Create worktree" }), _jsx(Text, { dimColor: true, children: ` for ${overlay.issue.issue.id}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "branchMode", children: overlay.field === "branchMode" ? "▸ Branch:" : " Branch:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "branchMode" ? "cyan" : undefined, bold: overlay.field === "branchMode", children: isNewBranch ? "new" : `current (${overlay.currentBranch ?? "?"})` }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "branchMode" && _jsx(Text, { dimColor: true, children: " (use ← / → to toggle)" })] }), isNewBranch && isLinearBranch && (_jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Branch name:" }) }), _jsx(Text, { color: "green", children: overlay.linearBranch }), _jsx(Text, { dimColor: true, children: " (from Linear)" })] })), showTypeDesc && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "type", children: overlay.field === "type" ? "▸ Type:" : " Type:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "type" ? "cyan" : undefined, bold: overlay.field === "type", children: overlay.type }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "type" && _jsx(Text, { dimColor: true, children: " (use ← / → to cycle)" })] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "desc", children: overlay.field === "desc" ? "▸ Description:" : " Description:" }) }), _jsx(Box, { children: overlay.field === "desc" ? (_jsx(TextInput, { value: overlay.desc, onChange: onDescChange, placeholder: "kebab-case" })) : (_jsx(Text, { children: overlay.desc || "(empty)" })) })] })] })), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "prompt", children: overlay.field === "prompt" ? "▸ Prompt:" : " Prompt:" }) }), _jsx(Box, { children: overlay.field === "prompt" ? (_jsx(TextInput, { value: overlay.prompt, onChange: onPromptChange, placeholder: "(empty = no initial message)" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(empty — Claude starts with no message)" })) })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Checkout:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Worktree:" }) }), _jsxs(Text, { dimColor: true, children: [".mintree/worktrees/", dirPreview] })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Mode:" }) }), _jsx(Text, { dimColor: true, children: "--work (Claude launches in the new worktree)" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [isLinearBranch ? (_jsx(Text, { dimColor: true, children: "Branch name comes from Linear (the issue's suggested `branchName`). The worktree dir is the bare issue id." })) : isNewBranch ? (_jsxs(Text, { dimColor: true, children: ["Suggestion is a kebab of the title (capped at ", SUGGESTED_DESC_MAX_WORDS, " words). Edit it to match your repo's branch conventions."] })) : (_jsxs(Text, { dimColor: true, children: ["Detached HEAD at the tip of ", overlay.currentBranch ?? "the current branch", ". No new branch is created \u2014 commit on a new one with `git switch -c` when ready."] })), showTypeDesc && overlay.conventionDoc && (_jsx(Text, { dimColor: true, children: `This repo has \`${overlay.conventionDoc}\` — review it before creating.` }))] }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) })), overlay.steps.length > 0 && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: overlay.steps.map((step, i) => (_jsxs(Box, { children: [_jsx(CreateStepIcon, { kind: step.kind }), _jsx(Text, { children: " " }), _jsx(Text, { children: step.label }), step.detail && _jsxs(Text, { dimColor: true, children: [" (", step.detail, ")"] })] }, i))) })), overlay.pending && (_jsxs(Box, { marginTop: overlay.steps.length > 0 ? 0 : 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", overlay.pending] })] }))] }));
|
|
215
223
|
}
|
|
216
224
|
function CreateStepIcon({ kind }) {
|
|
217
225
|
if (kind === "ok")
|
|
@@ -964,6 +972,12 @@ export default function Dashboard() {
|
|
|
964
972
|
if (state.phase !== "ready")
|
|
965
973
|
return;
|
|
966
974
|
const root = findMainRepoRoot();
|
|
975
|
+
// On a Linear repo, prefer the branch Linear suggests for the issue
|
|
976
|
+
// (its `branchName`) over the synthesised `<type>/<issue>-<desc>` form —
|
|
977
|
+
// that's the convention those repos actually follow. Falls back to the
|
|
978
|
+
// convention form when the issue has no branchName.
|
|
979
|
+
const provider = root ? readMetadata(root).provider : undefined;
|
|
980
|
+
const linearBranch = provider === "linear" && issue.issue.branchName ? issue.issue.branchName : null;
|
|
967
981
|
setState({
|
|
968
982
|
...state,
|
|
969
983
|
overlay: {
|
|
@@ -973,6 +987,7 @@ export default function Dashboard() {
|
|
|
973
987
|
currentBranch: root ? getCurrentBranch(root) : null,
|
|
974
988
|
type: "feat",
|
|
975
989
|
desc: kebabize(issue.issue.title) || `issue-${issue.issue.id}`,
|
|
990
|
+
linearBranch,
|
|
976
991
|
prompt: defaultPromptForIssue(issue.issue.id, issue.issue.title, issue.issue.url),
|
|
977
992
|
field: "branchMode",
|
|
978
993
|
error: null,
|
|
@@ -1001,11 +1016,13 @@ export default function Dashboard() {
|
|
|
1001
1016
|
handleRemoveOverlayInput(input, key, overlay);
|
|
1002
1017
|
return;
|
|
1003
1018
|
}
|
|
1004
|
-
// In "current" branch mode
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
1019
|
+
// In "current" branch mode (detached) and in the Linear-branchName case
|
|
1020
|
+
// we skip type+desc fields entirely — they have no meaning when the
|
|
1021
|
+
// branch is fixed (detached HEAD, or Linear's own `branchName`). Tab
|
|
1022
|
+
// cycles branchMode ⇄ prompt only.
|
|
1023
|
+
const skipTypeDesc = overlay.branchMode === "current" || overlay.linearBranch !== null;
|
|
1007
1024
|
if (key.tab) {
|
|
1008
|
-
const order =
|
|
1025
|
+
const order = skipTypeDesc
|
|
1009
1026
|
? ["branchMode", "prompt"]
|
|
1010
1027
|
: ["branchMode", "type", "desc", "prompt"];
|
|
1011
1028
|
const i = order.indexOf(overlay.field);
|
|
@@ -1068,8 +1085,9 @@ export default function Dashboard() {
|
|
|
1068
1085
|
if (state.phase !== "ready")
|
|
1069
1086
|
return;
|
|
1070
1087
|
// Validate first so we don't flash a spinner just to immediately show
|
|
1071
|
-
// a sync-fail message.
|
|
1072
|
-
|
|
1088
|
+
// a sync-fail message. A Linear-branch create needs no desc (the branch
|
|
1089
|
+
// is Linear's `branchName`), so only the convention path requires it.
|
|
1090
|
+
if (overlay.branchMode === "new" && !overlay.linearBranch && !overlay.desc.trim()) {
|
|
1073
1091
|
setState({
|
|
1074
1092
|
...state,
|
|
1075
1093
|
overlay: { ...overlay, error: "Description is required." },
|
|
@@ -1124,8 +1142,9 @@ export default function Dashboard() {
|
|
|
1124
1142
|
});
|
|
1125
1143
|
}
|
|
1126
1144
|
else {
|
|
1127
|
-
|
|
1128
|
-
|
|
1145
|
+
// Linear repos with a `branchName` use it verbatim; everyone else
|
|
1146
|
+
// synthesises the `<type>/<issue>-<desc>` convention branch.
|
|
1147
|
+
const branch = overlay.linearBranch ?? `${overlay.type}/${issueId}-${overlay.desc.trim()}`;
|
|
1129
1148
|
result = await runCreate(branch, {
|
|
1130
1149
|
work: true,
|
|
1131
1150
|
progress: { onStep, onPending },
|
|
@@ -13,7 +13,7 @@ export const description = "Create a worktree for an issue branch";
|
|
|
13
13
|
export const args = z.tuple([
|
|
14
14
|
z.string().describe(argument({
|
|
15
15
|
name: "branch",
|
|
16
|
-
description: "Branch in `<type>/<issue>-<kebab-desc>` format (e.g. feat/100-claude-md-inicial)",
|
|
16
|
+
description: "Branch in `<type>/<issue>-<kebab-desc>` format (e.g. feat/100-claude-md-inicial). On a Linear repo you can instead pass the issue's Linear branch name (e.g. martinmineo/val-68-landing-publica).",
|
|
17
17
|
})),
|
|
18
18
|
]);
|
|
19
19
|
export const options = z.object({
|
package/dist/lib/branch.d.ts
CHANGED
|
@@ -7,19 +7,25 @@
|
|
|
7
7
|
* bare digit run (GitHub issue number — "100") or a team-prefixed Linear
|
|
8
8
|
* identifier ("FE-100"); `<desc>` is lower-case kebab-case.
|
|
9
9
|
*
|
|
10
|
+
* The Linear team key is normalized to upper-case: `feat/be-256-x` is
|
|
11
|
+
* accepted and rewritten to `feat/BE-256-x` (issueId "BE-256"). Linear's
|
|
12
|
+
* canonical identifier is always upper-case, so this just rescues a branch
|
|
13
|
+
* a human (or a target-repo skill) typed in the wrong case.
|
|
14
|
+
*
|
|
10
15
|
* Examples that PARSE: feat/100-readme-update, fix/55-upload-timeout,
|
|
11
|
-
* feat/BACK-100-readme-update, fix/WEB-7-modal
|
|
16
|
+
* feat/BACK-100-readme-update, fix/WEB-7-modal,
|
|
17
|
+
* feat/back-100-x (normalized to feat/BACK-100-x)
|
|
12
18
|
* Examples that REJECT: feat/abc-foo, /100-foo, gh-100-foo, feat/100,
|
|
13
|
-
* feat/100-FooBar,
|
|
19
|
+
* feat/100-FooBar, Feat/BE-1-x (upper-case type)
|
|
14
20
|
*/
|
|
15
21
|
export declare const ALLOWED_TYPES: readonly ["feat", "fix", "docs", "chore", "refactor", "test", "build", "ci", "perf", "style", "revert"];
|
|
16
22
|
export type BranchType = (typeof ALLOWED_TYPES)[number];
|
|
17
23
|
export type ParsedBranch = {
|
|
18
24
|
branch: string;
|
|
19
|
-
type: BranchType;
|
|
20
25
|
issueId: string;
|
|
21
|
-
desc: string;
|
|
22
26
|
worktreeDirName: string;
|
|
27
|
+
type?: BranchType;
|
|
28
|
+
desc?: string;
|
|
23
29
|
};
|
|
24
30
|
export type ParseError = {
|
|
25
31
|
error: string;
|
|
@@ -27,3 +33,26 @@ export type ParseError = {
|
|
|
27
33
|
};
|
|
28
34
|
export declare function parseBranch(branch: string): ParsedBranch | ParseError;
|
|
29
35
|
export declare function isParseError(result: ParsedBranch | ParseError): result is ParseError;
|
|
36
|
+
/**
|
|
37
|
+
* Pulls the Linear issue identifier (`<TEAM>-<n>`) out of a Linear-style
|
|
38
|
+
* branch name. Linear's `branchName` looks like `<user>/<team>-<n>-<slug>`
|
|
39
|
+
* (or just `<team>-<n>-<slug>` when the workspace has no user prefix), with
|
|
40
|
+
* the identifier lower-cased. We locate `<team>-<digits>` as a delimited token
|
|
41
|
+
* and return it upper-cased so it matches the canonical issue id everywhere
|
|
42
|
+
* else in mintree.
|
|
43
|
+
*
|
|
44
|
+
* `teamKeys` narrows the search to the repo's configured teams (the robust
|
|
45
|
+
* path); when empty — a mis-configured repo — we fall back to the first
|
|
46
|
+
* `<letters>-<digits>` token, which is still correct for the common single-
|
|
47
|
+
* identifier branch shape.
|
|
48
|
+
*/
|
|
49
|
+
export declare function extractLinearIssueId(branch: string, teamKeys: string[]): string | null;
|
|
50
|
+
/**
|
|
51
|
+
* Resolves a Linear `branchName` into a ParsedBranch. The branch is kept
|
|
52
|
+
* verbatim (only trimmed) — git refs are case-sensitive and Linear's value is
|
|
53
|
+
* the exact ref GitHub/Linear will link against, so we must not rewrite it.
|
|
54
|
+
* Only the extracted `issueId` is normalised to upper-case. The worktree dir
|
|
55
|
+
* is the bare issue id (`VAL-68`), matching the convention path and the
|
|
56
|
+
* dir-name recovery regexes elsewhere.
|
|
57
|
+
*/
|
|
58
|
+
export declare function parseLinearBranch(rawBranch: string, teamKeys: string[]): ParsedBranch | ParseError;
|
package/dist/lib/branch.js
CHANGED
|
@@ -7,10 +7,16 @@
|
|
|
7
7
|
* bare digit run (GitHub issue number — "100") or a team-prefixed Linear
|
|
8
8
|
* identifier ("FE-100"); `<desc>` is lower-case kebab-case.
|
|
9
9
|
*
|
|
10
|
+
* The Linear team key is normalized to upper-case: `feat/be-256-x` is
|
|
11
|
+
* accepted and rewritten to `feat/BE-256-x` (issueId "BE-256"). Linear's
|
|
12
|
+
* canonical identifier is always upper-case, so this just rescues a branch
|
|
13
|
+
* a human (or a target-repo skill) typed in the wrong case.
|
|
14
|
+
*
|
|
10
15
|
* Examples that PARSE: feat/100-readme-update, fix/55-upload-timeout,
|
|
11
|
-
* feat/BACK-100-readme-update, fix/WEB-7-modal
|
|
16
|
+
* feat/BACK-100-readme-update, fix/WEB-7-modal,
|
|
17
|
+
* feat/back-100-x (normalized to feat/BACK-100-x)
|
|
12
18
|
* Examples that REJECT: feat/abc-foo, /100-foo, gh-100-foo, feat/100,
|
|
13
|
-
* feat/100-FooBar,
|
|
19
|
+
* feat/100-FooBar, Feat/BE-1-x (upper-case type)
|
|
14
20
|
*/
|
|
15
21
|
export const ALLOWED_TYPES = [
|
|
16
22
|
"feat",
|
|
@@ -26,11 +32,12 @@ export const ALLOWED_TYPES = [
|
|
|
26
32
|
"revert",
|
|
27
33
|
];
|
|
28
34
|
// `<type>/<issueId>-<desc>` where issueId is either `\d+` (GitHub) or
|
|
29
|
-
// `<TEAM_PREFIX>-\d+` (Linear). The TEAM_PREFIX is
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
35
|
+
// `<TEAM_PREFIX>-\d+` (Linear). The TEAM_PREFIX is letters/digits/underscores
|
|
36
|
+
// starting with a letter, mirroring Linear's team-key constraints. Both cases
|
|
37
|
+
// are accepted here and the captured team key is upper-cased on the way out
|
|
38
|
+
// (see `parseBranch`), so the worktree dir name and the created branch stay
|
|
39
|
+
// canonical regardless of how the caller typed it.
|
|
40
|
+
const BRANCH_REGEX = /^([a-z]+)\/((?:[A-Za-z][A-Za-z0-9_]*-)?\d+)-([a-z0-9][a-z0-9-]*)$/;
|
|
34
41
|
export function parseBranch(branch) {
|
|
35
42
|
const match = BRANCH_REGEX.exec(branch);
|
|
36
43
|
if (!match) {
|
|
@@ -39,8 +46,8 @@ export function parseBranch(branch) {
|
|
|
39
46
|
hint: "Expected `<type>/<issue>-<kebab-desc>`. Examples: feat/100-claude-md-inicial, feat/BACK-100-claude-md-inicial",
|
|
40
47
|
};
|
|
41
48
|
}
|
|
42
|
-
const [, type,
|
|
43
|
-
if (!type || !
|
|
49
|
+
const [, type, rawIssueId, desc] = match;
|
|
50
|
+
if (!type || !rawIssueId || !desc) {
|
|
44
51
|
return {
|
|
45
52
|
error: `Invalid branch name: ${branch}`,
|
|
46
53
|
hint: "Expected `<type>/<issue>-<kebab-desc>`. Examples: feat/100-claude-md-inicial, feat/BACK-100-claude-md-inicial",
|
|
@@ -52,8 +59,14 @@ export function parseBranch(branch) {
|
|
|
52
59
|
hint: `Allowed types: ${ALLOWED_TYPES.join(", ")}`,
|
|
53
60
|
};
|
|
54
61
|
}
|
|
62
|
+
// Normalize the Linear team key to upper-case. `toUpperCase()` is a no-op
|
|
63
|
+
// for a bare-digit GitHub id ("256") and only touches the team prefix of a
|
|
64
|
+
// Linear id ("be-256" → "BE-256"). The returned `branch` is rebuilt from
|
|
65
|
+
// the normalized id so callers create/look up the canonical branch name.
|
|
66
|
+
const issueId = rawIssueId.toUpperCase();
|
|
67
|
+
const normalizedBranch = `${type}/${issueId}-${desc}`;
|
|
55
68
|
return {
|
|
56
|
-
branch,
|
|
69
|
+
branch: normalizedBranch,
|
|
57
70
|
type: type,
|
|
58
71
|
issueId,
|
|
59
72
|
desc,
|
|
@@ -65,3 +78,71 @@ export function parseBranch(branch) {
|
|
|
65
78
|
export function isParseError(result) {
|
|
66
79
|
return "error" in result;
|
|
67
80
|
}
|
|
81
|
+
// Chars that make a string an invalid (or risky) git ref. Linear's
|
|
82
|
+
// `branchName` never contains these, but this entry point is also reachable
|
|
83
|
+
// from the CLI (`mintree worktree create <branch>`), so junk input is rejected
|
|
84
|
+
// rather than handed to `git worktree add`.
|
|
85
|
+
const GIT_REF_INVALID = /[\s~^:?*[\]\\]/;
|
|
86
|
+
function escapeRegex(s) {
|
|
87
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Pulls the Linear issue identifier (`<TEAM>-<n>`) out of a Linear-style
|
|
91
|
+
* branch name. Linear's `branchName` looks like `<user>/<team>-<n>-<slug>`
|
|
92
|
+
* (or just `<team>-<n>-<slug>` when the workspace has no user prefix), with
|
|
93
|
+
* the identifier lower-cased. We locate `<team>-<digits>` as a delimited token
|
|
94
|
+
* and return it upper-cased so it matches the canonical issue id everywhere
|
|
95
|
+
* else in mintree.
|
|
96
|
+
*
|
|
97
|
+
* `teamKeys` narrows the search to the repo's configured teams (the robust
|
|
98
|
+
* path); when empty — a mis-configured repo — we fall back to the first
|
|
99
|
+
* `<letters>-<digits>` token, which is still correct for the common single-
|
|
100
|
+
* identifier branch shape.
|
|
101
|
+
*/
|
|
102
|
+
export function extractLinearIssueId(branch, teamKeys) {
|
|
103
|
+
const keys = teamKeys.filter((k) => k.length > 0);
|
|
104
|
+
// The identifier must sit on a token boundary (start, or after `/ _ -`) and
|
|
105
|
+
// be followed by one (end, or before `/ _ -`) so we don't match a team key
|
|
106
|
+
// buried inside a slug word.
|
|
107
|
+
const keyAlt = keys.length > 0 ? keys.map(escapeRegex).join("|") : "[A-Za-z][A-Za-z0-9_]*";
|
|
108
|
+
const re = new RegExp(`(?:^|[/_-])(${keyAlt})-(\\d+)(?=$|[/_-])`, "i");
|
|
109
|
+
const m = re.exec(branch);
|
|
110
|
+
if (!m || !m[1] || !m[2])
|
|
111
|
+
return null;
|
|
112
|
+
return `${m[1].toUpperCase()}-${m[2]}`;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Resolves a Linear `branchName` into a ParsedBranch. The branch is kept
|
|
116
|
+
* verbatim (only trimmed) — git refs are case-sensitive and Linear's value is
|
|
117
|
+
* the exact ref GitHub/Linear will link against, so we must not rewrite it.
|
|
118
|
+
* Only the extracted `issueId` is normalised to upper-case. The worktree dir
|
|
119
|
+
* is the bare issue id (`VAL-68`), matching the convention path and the
|
|
120
|
+
* dir-name recovery regexes elsewhere.
|
|
121
|
+
*/
|
|
122
|
+
export function parseLinearBranch(rawBranch, teamKeys) {
|
|
123
|
+
const branch = rawBranch.trim();
|
|
124
|
+
if (!branch) {
|
|
125
|
+
return {
|
|
126
|
+
error: "Empty branch name",
|
|
127
|
+
hint: "Linear should provide a `branchName` like `martinmineo/val-68-landing-publica`.",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (GIT_REF_INVALID.test(branch) || branch.startsWith("/") || branch.endsWith("/")) {
|
|
131
|
+
return {
|
|
132
|
+
error: `Invalid branch name: ${rawBranch}`,
|
|
133
|
+
hint: "A Linear branch must be a valid git ref (no spaces or ~^:?*[]\\).",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const issueId = extractLinearIssueId(branch, teamKeys);
|
|
137
|
+
if (!issueId) {
|
|
138
|
+
const hint = teamKeys.length > 0
|
|
139
|
+
? `Expected a Linear identifier for one of: ${teamKeys.join(", ")} (e.g. ${teamKeys[0]}-123). Got \`${branch}\`.`
|
|
140
|
+
: `Expected a Linear identifier like \`team-123\` somewhere in the branch. Got \`${branch}\`.`;
|
|
141
|
+
return { error: `Could not find a Linear issue id in branch \`${branch}\``, hint };
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
branch,
|
|
145
|
+
issueId,
|
|
146
|
+
worktreeDirName: issueId,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -271,6 +271,7 @@ const BOOTSTRAP_QUERY = /* GraphQL */ `
|
|
|
271
271
|
title
|
|
272
272
|
description
|
|
273
273
|
url
|
|
274
|
+
branchName
|
|
274
275
|
priority
|
|
275
276
|
createdAt
|
|
276
277
|
updatedAt
|
|
@@ -334,6 +335,7 @@ function mapIssueToProviderIssue(wi) {
|
|
|
334
335
|
// value) to null so the dashboard treats it the same as GitHub's
|
|
335
336
|
// no-priority rows.
|
|
336
337
|
priority: wi.priority && wi.priority > 0 ? wi.priority : null,
|
|
338
|
+
...(wi.branchName && wi.branchName.length > 0 ? { branchName: wi.branchName } : {}),
|
|
337
339
|
};
|
|
338
340
|
}
|
|
339
341
|
export class LinearProvider {
|
|
@@ -504,8 +506,17 @@ export class LinearProvider {
|
|
|
504
506
|
}
|
|
505
507
|
const targetStateName = cfg.inProgressStateName;
|
|
506
508
|
let targetState = targetStateName ? states.find((s) => s.name === targetStateName) : undefined;
|
|
507
|
-
if (!targetState)
|
|
508
|
-
|
|
509
|
+
if (!targetState) {
|
|
510
|
+
// Linear marks BOTH "In Progress" and "In Review" as type "started",
|
|
511
|
+
// and the bootstrap query returns states unordered — so a plain
|
|
512
|
+
// `find(type === "started")` can land on "In Review". Prefer a state
|
|
513
|
+
// literally named "In Progress", otherwise pick the leftmost started
|
|
514
|
+
// state by workflow position (lowest = earliest = "In Progress").
|
|
515
|
+
const started = states.filter((s) => s.type === "started");
|
|
516
|
+
targetState =
|
|
517
|
+
started.find((s) => s.name?.toLowerCase() === "in progress") ??
|
|
518
|
+
started.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))[0];
|
|
519
|
+
}
|
|
509
520
|
if (!targetState) {
|
|
510
521
|
return {
|
|
511
522
|
kind: "skip-no-in-progress-option",
|
|
@@ -2,9 +2,32 @@ import * as fs from "fs";
|
|
|
2
2
|
import * as os from "os";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import { execSync } from "child_process";
|
|
5
|
-
import { parseBranch, isParseError } from "./branch.js";
|
|
5
|
+
import { parseBranch, parseLinearBranch, isParseError, } from "./branch.js";
|
|
6
6
|
import { findMainRepoRoot, getMintreeDir, getWorktreesDir, getInitScriptPath, getDefaultBranch, getCurrentBranch, branchExists, remoteBranchExists, fetchRemote, worktreeForBranch, addWorktree, pathExists, isExecutable, } from "./git.js";
|
|
7
|
-
import { upsertIssue } from "./metadata.js";
|
|
7
|
+
import { readMetadata, upsertIssue } from "./metadata.js";
|
|
8
|
+
/**
|
|
9
|
+
* Resolves the branch arg into a ParsedBranch, choosing the parser by
|
|
10
|
+
* provider. The convention parser (`<type>/<issue>-<desc>`) is tried first for
|
|
11
|
+
* every provider — a Linear repo can still use convention branches. When that
|
|
12
|
+
* fails AND the repo is on the Linear provider, we fall back to parsing the
|
|
13
|
+
* arg as a Linear `branchName` (`<user>/<team>-<n>-<slug>`), keyed off the
|
|
14
|
+
* configured team keys. GitHub repos never reach the Linear branch, so their
|
|
15
|
+
* behaviour is unchanged.
|
|
16
|
+
*/
|
|
17
|
+
function resolveCreateBranch(repoRoot, branchArg) {
|
|
18
|
+
const conv = parseBranch(branchArg);
|
|
19
|
+
if (!isParseError(conv))
|
|
20
|
+
return conv;
|
|
21
|
+
const meta = readMetadata(repoRoot);
|
|
22
|
+
if (meta.provider === "linear" && meta.linear) {
|
|
23
|
+
const teamKeys = meta.linear.teams.map((t) => t.key);
|
|
24
|
+
const linear = parseLinearBranch(branchArg, teamKeys);
|
|
25
|
+
// On a Linear repo the Linear-branch error is the more useful one to
|
|
26
|
+
// surface, so return it whether it parsed or not.
|
|
27
|
+
return linear;
|
|
28
|
+
}
|
|
29
|
+
return conv;
|
|
30
|
+
}
|
|
8
31
|
function tryRunInitScript(scriptPath, worktreePath, repoRoot) {
|
|
9
32
|
if (!pathExists(scriptPath))
|
|
10
33
|
return { ran: false };
|
|
@@ -72,7 +95,7 @@ export async function runCreate(branchArg, opts) {
|
|
|
72
95
|
hint: "Run `mintree init` first.",
|
|
73
96
|
};
|
|
74
97
|
}
|
|
75
|
-
const parsed =
|
|
98
|
+
const parsed = resolveCreateBranch(root, branchArg);
|
|
76
99
|
if (isParseError(parsed)) {
|
|
77
100
|
return { ok: false, message: parsed.error, hint: parsed.hint };
|
|
78
101
|
}
|
|
@@ -100,7 +123,9 @@ export async function runCreate(branchArg, opts) {
|
|
|
100
123
|
pushStep({
|
|
101
124
|
kind: "ok",
|
|
102
125
|
label: "parsed branch",
|
|
103
|
-
detail:
|
|
126
|
+
detail: parsed.type
|
|
127
|
+
? `type=${parsed.type}, issue=${parsed.issueId}, desc=${parsed.desc}`
|
|
128
|
+
: `issue=${parsed.issueId}, branch=${parsed.branch}`,
|
|
104
129
|
});
|
|
105
130
|
await nextFrame(progress);
|
|
106
131
|
// Fetch before resolving refs so the worktree forks from fresh code, not a
|
|
@@ -17,6 +17,10 @@ export type RemoveResult = {
|
|
|
17
17
|
*
|
|
18
18
|
* Branch and metadata are deliberately preserved so a later re-attach can
|
|
19
19
|
* resume the same Claude session.
|
|
20
|
+
*
|
|
21
|
+
* The branch name is NOT validated against the naming convention here:
|
|
22
|
+
* removal is a cleanup op, and a worktree on a non-canonical branch (e.g.
|
|
23
|
+
* one with a lowercase Linear team key) must still be removable.
|
|
20
24
|
*/
|
|
21
25
|
export declare function runRemove(branchArg: string, force: boolean): RemoveResult;
|
|
22
26
|
/**
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { parseBranch, isParseError } from "./branch.js";
|
|
2
1
|
import { findMainRepoRoot, getMintreeDir, worktreeForBranch, isDirty, removeWorktree, pruneWorktrees, pathExists, } from "./git.js";
|
|
3
2
|
/**
|
|
4
3
|
* Removes the worktree backing `branchArg`. Same behavior as the CLI command:
|
|
@@ -8,6 +7,10 @@ import { findMainRepoRoot, getMintreeDir, worktreeForBranch, isDirty, removeWork
|
|
|
8
7
|
*
|
|
9
8
|
* Branch and metadata are deliberately preserved so a later re-attach can
|
|
10
9
|
* resume the same Claude session.
|
|
10
|
+
*
|
|
11
|
+
* The branch name is NOT validated against the naming convention here:
|
|
12
|
+
* removal is a cleanup op, and a worktree on a non-canonical branch (e.g.
|
|
13
|
+
* one with a lowercase Linear team key) must still be removable.
|
|
11
14
|
*/
|
|
12
15
|
export function runRemove(branchArg, force) {
|
|
13
16
|
const root = findMainRepoRoot();
|
|
@@ -25,15 +28,11 @@ export function runRemove(branchArg, force) {
|
|
|
25
28
|
hint: "Run `mintree init` first.",
|
|
26
29
|
};
|
|
27
30
|
}
|
|
28
|
-
const
|
|
29
|
-
if (isParseError(parsed)) {
|
|
30
|
-
return { ok: false, message: parsed.error, hint: parsed.hint };
|
|
31
|
-
}
|
|
32
|
-
const worktreePath = worktreeForBranch(root, parsed.branch);
|
|
31
|
+
const worktreePath = worktreeForBranch(root, branchArg);
|
|
33
32
|
if (!worktreePath) {
|
|
34
33
|
return {
|
|
35
34
|
ok: false,
|
|
36
|
-
message: `No worktree found for branch ${
|
|
35
|
+
message: `No worktree found for branch ${branchArg}.`,
|
|
37
36
|
hint: "Use `mintree worktree list` to see existing worktrees.",
|
|
38
37
|
};
|
|
39
38
|
}
|
|
@@ -49,7 +48,7 @@ export function runRemove(branchArg, force) {
|
|
|
49
48
|
}
|
|
50
49
|
return {
|
|
51
50
|
ok: true,
|
|
52
|
-
branch:
|
|
51
|
+
branch: branchArg,
|
|
53
52
|
worktreePath,
|
|
54
53
|
variant: "pruned-orphan",
|
|
55
54
|
wasDirty: false,
|
|
@@ -76,7 +75,7 @@ export function runRemove(branchArg, force) {
|
|
|
76
75
|
}
|
|
77
76
|
return {
|
|
78
77
|
ok: true,
|
|
79
|
-
branch:
|
|
78
|
+
branch: branchArg,
|
|
80
79
|
worktreePath,
|
|
81
80
|
variant: "removed",
|
|
82
81
|
wasDirty: dirty,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mintree",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"description": "Issue-driven git worktrees + Claude Code sessions for repos with an opinionated SDD+TDD flow.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Martin Mineo <mmineo@canarytechnologies.com>",
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
"start": "node dist/cli.js",
|
|
35
35
|
"lint": "eslint source",
|
|
36
36
|
"lint:fix": "eslint source --fix",
|
|
37
|
-
"format": "prettier --write source"
|
|
37
|
+
"format": "prettier --write source",
|
|
38
|
+
"test": "node --import tsx --test test/*.test.ts"
|
|
38
39
|
},
|
|
39
40
|
"files": [
|
|
40
41
|
"dist",
|
|
@@ -58,6 +59,7 @@
|
|
|
58
59
|
"eslint-config-prettier": "^10.1.8",
|
|
59
60
|
"eslint-plugin-prettier": "^5.5.5",
|
|
60
61
|
"prettier": "^3.7.4",
|
|
62
|
+
"tsx": "^4.22.4",
|
|
61
63
|
"typescript": "^5.7.0"
|
|
62
64
|
},
|
|
63
65
|
"overrides": {
|