mintree 0.4.8 → 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/README.md CHANGED
@@ -159,6 +159,9 @@ Same building blocks, scriptable from any shell:
159
159
  mintree worktree create feat/100-validar-patente
160
160
  mintree worktree create feat/FE-123-validar-patente --work --prompt "empezar FE-123"
161
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
+
162
165
  # Resume Claude in the worktree you're currently inside
163
166
  # (the worktree dir is the bare issue id)
164
167
  cd .mintree/worktrees/FE-123
@@ -178,7 +181,7 @@ mintree worktree clean # sweep worktrees whose PR is mer
178
181
 
179
182
  ## Branch convention
180
183
 
181
- mintree enforces:
184
+ By default mintree enforces:
182
185
 
183
186
  ```
184
187
  <type>/<issue>-<kebab-desc>
@@ -199,6 +202,17 @@ Examples: `feat/42-validacion-patente`, `fix/55-selfie-upload-timeout`, `feat/FE
199
202
 
200
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.
201
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
+
202
216
  ---
203
217
 
204
218
  ## What gets stored where
@@ -216,7 +230,7 @@ When the dashboard's `w` overlay opens, it suggests a kebab description capped a
216
230
  └── init.sh # opt-in. Runs in the new worktree post-create (copy .env, install deps, …)
217
231
  ```
218
232
 
219
- The worktree directory is named after the bare issue id (`100`, `FE-123`); the branch keeps the full `<type>/<issue>-<desc>` name.
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.
220
234
 
221
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.
222
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
- ? `${overlay.type}/${overlay.issue.issue.id}-${overlay.desc}`
210
- : `detached @ ${overlay.currentBranch ?? "(unknown)"}`;
211
- const dirPreview = isNewBranch
212
- ? `${overlay.issue.issue.id}-${overlay.desc}`
213
- : `${overlay.issue.issue.id}-${detachedDesc}`;
214
- 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 && (_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: [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."] })), isNewBranch && 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] })] }))] }));
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 we skip type+desc fields entirely — they have
1005
- // no meaning when the worktree is going to be detached. Tab cycles
1006
- // branchMode prompt only.
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 = overlay.branchMode === "current"
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
- if (overlay.branchMode === "new" && !overlay.desc.trim()) {
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
- const desc = overlay.desc.trim();
1128
- const branch = `${overlay.type}/${issueId}-${desc}`;
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({
@@ -22,10 +22,10 @@ export declare const ALLOWED_TYPES: readonly ["feat", "fix", "docs", "chore", "r
22
22
  export type BranchType = (typeof ALLOWED_TYPES)[number];
23
23
  export type ParsedBranch = {
24
24
  branch: string;
25
- type: BranchType;
26
25
  issueId: string;
27
- desc: string;
28
26
  worktreeDirName: string;
27
+ type?: BranchType;
28
+ desc?: string;
29
29
  };
30
30
  export type ParseError = {
31
31
  error: string;
@@ -33,3 +33,26 @@ export type ParseError = {
33
33
  };
34
34
  export declare function parseBranch(branch: string): ParsedBranch | ParseError;
35
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;
@@ -78,3 +78,71 @@ export function parseBranch(branch) {
78
78
  export function isParseError(result) {
79
79
  return "error" in result;
80
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 {
@@ -27,6 +27,7 @@ export type ProviderIssue = {
27
27
  createdAt: string;
28
28
  updatedAt: string;
29
29
  priority: number | null;
30
+ branchName?: string;
30
31
  };
31
32
  /**
32
33
  * The issue's membership on a project board (GitHub Projects v2 / Linear
@@ -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 = parseBranch(branchArg);
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: `type=${parsed.type}, issue=${parsed.issueId}, desc=${parsed.desc}`,
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mintree",
3
- "version": "0.4.8",
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": {