mintree 0.1.2 → 0.1.4

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
@@ -16,19 +16,13 @@ It is a smaller, opinionated cousin of [santree](https://github.com/santiagotosc
16
16
  ## Install
17
17
 
18
18
  ```bash
19
- # mintree isn't on npm yet. Install via git clone + npm link — the
20
- # `npm install -g github:user/repo` flow has a known bug in npm 10+
21
- # where dependencies don't make it into the global install dir.
22
- git clone git@github.com:minex-labs/mintree.git ~/dev/mintree
23
- cd ~/dev/mintree
24
- npm install
25
- npm link
19
+ npm install -g mintree
26
20
 
27
- # To upgrade later:
28
- # cd ~/dev/mintree && git pull && npm install && npm run build
21
+ # Upgrade later:
22
+ # npm update -g mintree
29
23
 
30
24
  # Verify
31
- mintree --version # 0.1.0
25
+ mintree --version # should match the latest published version
32
26
  mintree doctor # checks toolchain (git, gh, claude, tmux, ...)
33
27
 
34
28
  # Enable the shell wrapper so `mintree worktree create` and the
@@ -50,6 +44,8 @@ _MT=${XDG_CACHE_HOME:-$HOME/.cache}/mintree/init-bash.bash
50
44
  EOF
51
45
  ```
52
46
 
47
+ > Working on mintree itself? Clone the repo, run `npm install` and `npm link` instead — that wires `mintree` to your local checkout so source edits show up after `npm run build`.
48
+
53
49
  `mintree doctor` should report **all required checks pass** before you continue. The most common gaps are:
54
50
 
55
51
  - `gh` not authenticated → `gh auth login`
@@ -5,12 +5,12 @@ import TextInput from "ink-text-input";
5
5
  import Spinner from "ink-spinner";
6
6
  import { execSync } from "child_process";
7
7
  import { createRequire } from "module";
8
- import { findBranchConventionDoc, findMainRepoRoot, getMintreeDir, pathExists, } from "../lib/git.js";
8
+ import { findBranchConventionDoc, findMainRepoRoot, getCurrentBranch, getMintreeDir, pathExists, } from "../lib/git.js";
9
9
  import { resolveClaudeBinary } from "../lib/claude.js";
10
10
  import { tryExec } from "../lib/exec.js";
11
11
  import { ALLOWED_TYPES } from "../lib/branch.js";
12
- import { runCreate } from "../lib/worktreeCreate.js";
13
- import { runRemove } from "../lib/worktreeRemove.js";
12
+ import { runCreate, runCreateDetached } from "../lib/worktreeCreate.js";
13
+ import { runRemove, runRemoveByPath } from "../lib/worktreeRemove.js";
14
14
  import { buildCreateMarkers, emitMarkers } from "../lib/markers.js";
15
15
  import { loadDashboard, } from "../lib/dashboard.js";
16
16
  const require = createRequire(import.meta.url);
@@ -160,7 +160,7 @@ function FooterRow({ phase, overlayKind, }) {
160
160
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "q quit" }) }));
161
161
  }
162
162
  if (overlayKind === "create") {
163
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Tab" }), _jsx(Text, { dimColor: true, children: " switch field " }), _jsx(Text, { bold: true, children: "\u2190/\u2192" }), _jsx(Text, { dimColor: true, children: " change type " }), _jsx(Text, { bold: true, children: "Enter" }), _jsx(Text, { dimColor: true, children: " create + work" })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Esc" }), _jsx(Text, { dimColor: true, children: " cancel" })] })] }));
163
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Tab" }), _jsx(Text, { dimColor: true, children: " switch field " }), _jsx(Text, { bold: true, children: "\u2190/\u2192" }), _jsx(Text, { dimColor: true, children: " toggle branch / cycle type " }), _jsx(Text, { bold: true, children: "Enter" }), _jsx(Text, { dimColor: true, children: " create + work" })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Esc" }), _jsx(Text, { dimColor: true, children: " cancel" })] })] }));
164
164
  }
165
165
  if (overlayKind === "remove") {
166
166
  return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "y/Y" }), _jsx(Text, { dimColor: true, children: " confirm " }), _jsx(Text, { bold: true, children: "n/Esc" }), _jsx(Text, { dimColor: true, children: " cancel" })] }));
@@ -168,12 +168,21 @@ function FooterRow({ phase, overlayKind, }) {
168
168
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " nav " }), _jsx(Text, { bold: true, children: "\u21B5" }), _jsx(Text, { dimColor: true, children: " work (resume / create) " }), _jsx(Text, { bold: true, children: "w" }), _jsx(Text, { dimColor: true, children: " work (always create) " }), _jsx(Text, { bold: true, children: "d" }), _jsx(Text, { dimColor: true, children: " remove" })] }) }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "r" }), _jsx(Text, { dimColor: true, children: " refresh " }), _jsx(Text, { bold: true, children: "o" }), _jsx(Text, { dimColor: true, children: " open in browser " }), _jsx(Text, { bold: true, children: "PgUp/PgDn" }), _jsx(Text, { dimColor: true, children: "/" }), _jsx(Text, { bold: true, children: "wheel" }), _jsx(Text, { dimColor: true, children: " scroll detail " }), _jsx(Text, { bold: true, children: "q" }), _jsx(Text, { dimColor: true, children: " quit" })] })] }));
169
169
  }
170
170
  function RemoveOverlayView({ overlay }) {
171
- return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Remove worktree" }), _jsx(Text, { dimColor: true, children: ` for #${overlay.issue.issue.number}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Branch: " }), _jsx(Text, { color: "cyan", children: overlay.branch })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "State: " }), overlay.dirty ? (_jsx(Text, { color: "yellow", children: "dirty (uncommitted changes will be lost)" })) : (_jsx(Text, { color: "green", children: "clean" }))] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { dimColor: true, children: "Removing the worktree leaves the branch and the issue's session_id in place. You can re-attach later with `mintree worktree create`." }) }), _jsx(Box, { marginTop: 1, children: overlay.dirty ? (_jsxs(Text, { children: ["This worktree is dirty. Press ", _jsx(Text, { bold: true, color: "red", children: "Y" }), " to force-remove,", " ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) : (_jsxs(Text, { children: ["Press ", _jsx(Text, { bold: true, color: "green", children: "y" }), " to remove,", " ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) }))] }));
171
+ return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Remove worktree" }), _jsx(Text, { dimColor: true, children: ` for #${overlay.issue.issue.number}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Branch: " }), _jsx(Text, { color: "cyan", children: overlay.branch ?? `(detached) ${overlay.worktreePath}` })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "State: " }), overlay.dirty ? (_jsx(Text, { color: "yellow", children: "dirty (uncommitted changes will be lost)" })) : (_jsx(Text, { color: "green", children: "clean" }))] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { dimColor: true, children: "Removing the worktree leaves the branch and the issue's session_id in place. You can re-attach later with `mintree worktree create`." }) }), _jsx(Box, { marginTop: 1, children: overlay.dirty ? (_jsxs(Text, { children: ["This worktree is dirty. Press ", _jsx(Text, { bold: true, color: "red", children: "Y" }), " to force-remove,", " ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) : (_jsxs(Text, { children: ["Press ", _jsx(Text, { bold: true, color: "green", children: "y" }), " to remove,", " ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) }))] }));
172
172
  }
173
173
  function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
174
174
  const labelWidth = 14;
175
- const branchPreview = `${overlay.type}/${overlay.issue.issue.number}-${overlay.desc}`;
176
- 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.number}` })] }), _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 === "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: "(optional) initial message for Claude" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(optional — 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: " Branch:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _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: [_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."] }), 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] }) }))] }));
175
+ const isNewBranch = overlay.branchMode === "new";
176
+ const detachedDesc = kebabize(overlay.issue.issue.title) || `issue-${overlay.issue.issue.number}`;
177
+ const branchPreview = isNewBranch
178
+ ? `${overlay.type}/${overlay.issue.issue.number}-${overlay.desc}`
179
+ : `detached @ ${overlay.currentBranch ?? "(unknown)"}`;
180
+ const dirPreview = isNewBranch
181
+ ? `${overlay.issue.issue.number}-${overlay.desc}`
182
+ : `${overlay.issue.issue.number}-${detachedDesc}`;
183
+ 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.number}` })] }), _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
184
+ ? "new"
185
+ : `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: "(optional) initial message for Claude" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(optional — 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] }) }))] }));
177
186
  }
178
187
  function stateChar(state) {
179
188
  if (!state)
@@ -296,7 +305,7 @@ function buildDetailLines(d, width) {
296
305
  lines.push(blank());
297
306
  lines.push([{ text: "⌥ Worktree", bold: true }]);
298
307
  if (d.worktree) {
299
- for (const w2 of wrapLine(` branch: ${d.worktree.branch}`, w))
308
+ for (const w2 of wrapLine(` branch: ${d.worktree.branch ?? "(detached HEAD)"}`, w))
300
309
  lines.push([{ text: w2, dim: true }]);
301
310
  for (const w2 of wrapLine(` path: ${d.worktree.path}`, w))
302
311
  lines.push([{ text: w2, dim: true }]);
@@ -638,6 +647,7 @@ export default function Dashboard() {
638
647
  kind: "remove",
639
648
  issue,
640
649
  branch: issue.worktree.branch,
650
+ worktreePath: issue.worktree.path,
641
651
  dirty: issue.worktree.dirty,
642
652
  error: null,
643
653
  },
@@ -655,10 +665,12 @@ export default function Dashboard() {
655
665
  overlay: {
656
666
  kind: "create",
657
667
  issue,
668
+ branchMode: "new",
669
+ currentBranch: root ? getCurrentBranch(root) : null,
658
670
  type: "feat",
659
671
  desc: kebabize(issue.issue.title) || `issue-${issue.issue.number}`,
660
672
  prompt: "",
661
- field: "type",
673
+ field: "branchMode",
662
674
  error: null,
663
675
  conventionDoc: root ? findBranchConventionDoc(root) : null,
664
676
  },
@@ -678,18 +690,28 @@ export default function Dashboard() {
678
690
  return;
679
691
  }
680
692
  // Create overlay from here on.
693
+ // In "current" branch mode we skip type+desc fields entirely — they have
694
+ // no meaning when the worktree is going to be detached. Tab cycles
695
+ // branchMode ⇄ prompt only.
681
696
  if (key.tab) {
682
- const nextField = overlay.field === "type"
683
- ? "desc"
684
- : overlay.field === "desc"
685
- ? "prompt"
686
- : "type";
697
+ const order = overlay.branchMode === "current"
698
+ ? ["branchMode", "prompt"]
699
+ : ["branchMode", "type", "desc", "prompt"];
700
+ const i = order.indexOf(overlay.field);
701
+ const nextField = order[(i + 1) % order.length];
687
702
  setState({
688
703
  ...state,
689
704
  overlay: { ...overlay, field: nextField },
690
705
  });
691
706
  return;
692
707
  }
708
+ if (overlay.field === "branchMode") {
709
+ if (key.leftArrow || key.rightArrow || input === "h" || input === "l") {
710
+ const next = overlay.branchMode === "new" ? "current" : "new";
711
+ setState({ ...state, overlay: { ...overlay, branchMode: next, error: null } });
712
+ return;
713
+ }
714
+ }
693
715
  if (overlay.field === "type") {
694
716
  if (key.leftArrow || input === "h") {
695
717
  const idx = ALLOWED_TYPES.indexOf(overlay.type);
@@ -734,20 +756,36 @@ export default function Dashboard() {
734
756
  async function confirmCreate(overlay) {
735
757
  if (state.phase !== "ready")
736
758
  return;
737
- const desc = overlay.desc.trim();
738
- if (!desc) {
739
- setState({
740
- ...state,
741
- overlay: { ...overlay, error: "Description is required." },
759
+ const prompt = overlay.prompt.trim();
760
+ const issueNumber = overlay.issue.issue.number;
761
+ let result;
762
+ if (overlay.branchMode === "current") {
763
+ // Detached worktree off the main repo's current branch. Desc comes
764
+ // from the issue title (kebabized), not user input — keeping the
765
+ // "current branch" flow as low-friction as possible.
766
+ const descKebab = kebabize(overlay.issue.issue.title) || `issue-${issueNumber}`;
767
+ result = runCreateDetached({
768
+ issueId: String(issueNumber),
769
+ descKebab,
770
+ work: true,
771
+ ...(prompt.length > 0 ? { prompt } : {}),
772
+ });
773
+ }
774
+ else {
775
+ const desc = overlay.desc.trim();
776
+ if (!desc) {
777
+ setState({
778
+ ...state,
779
+ overlay: { ...overlay, error: "Description is required." },
780
+ });
781
+ return;
782
+ }
783
+ const branch = `${overlay.type}/${issueNumber}-${desc}`;
784
+ result = runCreate(branch, {
785
+ work: true,
786
+ ...(prompt.length > 0 ? { prompt } : {}),
742
787
  });
743
- return;
744
788
  }
745
- const branch = `${overlay.type}/${overlay.issue.issue.number}-${desc}`;
746
- const prompt = overlay.prompt.trim();
747
- const result = runCreate(branch, {
748
- work: true,
749
- ...(prompt.length > 0 ? { prompt } : {}),
750
- });
751
789
  if (!result.ok) {
752
790
  setState({
753
791
  ...state,
@@ -766,7 +804,9 @@ export default function Dashboard() {
766
804
  async function confirmRemove(overlay, force) {
767
805
  if (state.phase !== "ready")
768
806
  return;
769
- const result = runRemove(overlay.branch, force);
807
+ const result = overlay.branch
808
+ ? runRemove(overlay.branch, force)
809
+ : runRemoveByPath(overlay.worktreePath, force);
770
810
  if (!result.ok) {
771
811
  setState({
772
812
  ...state,
@@ -7,7 +7,6 @@ import { z } from "zod";
7
7
  import { randomUUID } from "crypto";
8
8
  import { readFileSync, unlinkSync } from "fs";
9
9
  import * as path from "path";
10
- import { parseBranch, isParseError } from "../../lib/branch.js";
11
10
  import { findMainRepoRoot, getMintreeDir, getWorktreesDir, getCurrentBranch, pathExists, } from "../../lib/git.js";
12
11
  import { getSessionId, setSessionId } from "../../lib/metadata.js";
13
12
  import { launchClaude, PERMISSION_MODES } from "../../lib/claude.js";
@@ -61,28 +60,28 @@ function resolve(cwd) {
61
60
  hint: "Run `mintree worktree work` from inside `.mintree/worktrees/<issue>-<desc>`.",
62
61
  };
63
62
  }
64
- const branch = getCurrentBranch(cwdAbs);
65
- if (!branch) {
66
- return {
67
- ok: false,
68
- message: "Could not determine the current branch (detached HEAD?)",
69
- };
70
- }
71
- const parsed = parseBranch(branch);
72
- if (isParseError(parsed)) {
73
- return {
74
- ok: false,
75
- message: `Branch '${branch}' does not match the mintree convention.`,
76
- hint: parsed.hint,
77
- };
78
- }
79
63
  // The worktree path that git knows about is the *root* of this checkout.
80
64
  // Walk up from cwd until we land directly under .mintree/worktrees/<name>.
81
65
  const segmentBeneathWorktreesDir = cwdAbs.slice(worktreesDir.length + 1).split(path.sep)[0];
82
66
  const worktreePath = segmentBeneathWorktreesDir
83
67
  ? path.join(worktreesDir, segmentBeneathWorktreesDir)
84
68
  : cwdAbs;
85
- const existing = getSessionId(repoRoot, parsed.issueId);
69
+ const worktreeDirName = path.basename(worktreePath);
70
+ // IssueId comes from the worktree dir name, not the branch — that way
71
+ // detached-HEAD worktrees (the "current branch" path from the dashboard)
72
+ // still resolve their session_id. Convention guarantees the dir is named
73
+ // `<issueId>-<desc>` for both attached and detached creates.
74
+ const issueIdMatch = worktreeDirName.match(/^(\d+)-/);
75
+ if (!issueIdMatch || !issueIdMatch[1]) {
76
+ return {
77
+ ok: false,
78
+ message: `Worktree directory '${worktreeDirName}' doesn't start with an issue number.`,
79
+ hint: "Expected `<issue>-<desc>` (e.g. 100-claude-md-inicial).",
80
+ };
81
+ }
82
+ const issueId = issueIdMatch[1];
83
+ const branch = getCurrentBranch(cwdAbs); // null = detached HEAD, that's fine
84
+ const existing = getSessionId(repoRoot, issueId);
86
85
  let sessionId;
87
86
  let resume;
88
87
  if (existing) {
@@ -91,7 +90,7 @@ function resolve(cwd) {
91
90
  }
92
91
  else {
93
92
  sessionId = randomUUID();
94
- setSessionId(repoRoot, parsed.issueId, sessionId);
93
+ setSessionId(repoRoot, issueId, sessionId);
95
94
  resume = false;
96
95
  }
97
96
  return {
@@ -99,8 +98,9 @@ function resolve(cwd) {
99
98
  data: {
100
99
  repoRoot,
101
100
  worktreePath,
101
+ worktreeDirName,
102
102
  branch,
103
- issueId: parsed.issueId,
103
+ issueId,
104
104
  sessionId,
105
105
  resume,
106
106
  },
@@ -156,6 +156,7 @@ export default function Work({ options }) {
156
156
  resume: resolved.resume,
157
157
  prompt: effectivePrompt,
158
158
  cwd: resolved.worktreePath,
159
+ remoteControlName: resolved.worktreeDirName,
159
160
  });
160
161
  child.on("error", (err) => {
161
162
  setState({
@@ -183,7 +184,7 @@ export default function Work({ options }) {
183
184
  const { resolved } = state;
184
185
  const sessionShort = resolved.sessionId.slice(0, 8);
185
186
  const action = resolved.resume ? "resuming" : "starting";
186
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree work" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", resolved.branch] })] }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "session: " }), _jsxs(Text, { children: [sessionShort, "\u2026"] }), _jsxs(Text, { dimColor: true, children: [" (", action, ")"] })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "permission-mode: " }), _jsx(Text, { children: options.permissionMode })] }), options.prompt && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "initial prompt: " }), _jsxs(Text, { children: ["\"", truncate(options.prompt, 60), "\""] })] })), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "cwd: " }), _jsx(Text, { dimColor: true, children: resolved.worktreePath })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude..." }) })] }));
187
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree work" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", resolved.branch ?? `detached @ ${resolved.worktreeDirName}`] })] }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "session: " }), _jsxs(Text, { children: [sessionShort, "\u2026"] }), _jsxs(Text, { dimColor: true, children: [" (", action, ")"] })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "permission-mode: " }), _jsx(Text, { children: options.permissionMode })] }), options.prompt && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "initial prompt: " }), _jsxs(Text, { children: ["\"", truncate(options.prompt, 60), "\""] })] })), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "cwd: " }), _jsx(Text, { dimColor: true, children: resolved.worktreePath })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude..." }) })] }));
187
188
  }
188
189
  function truncate(s, max) {
189
190
  if (s.length <= max)
@@ -14,6 +14,7 @@ export type LaunchClaudeOptions = {
14
14
  resume: boolean;
15
15
  prompt?: string;
16
16
  cwd: string;
17
+ remoteControlName?: string;
17
18
  };
18
19
  /**
19
20
  * Spawns the Claude CLI with stdio inherited so the child takes over the TTY.
@@ -60,6 +60,17 @@ export function launchClaude(options) {
60
60
  else {
61
61
  args.push("--session-id", options.sessionId);
62
62
  }
63
+ // Always launch with Remote Control. Mintree's whole premise is being able
64
+ // to resume sessions from other devices, so the flag is non-optional —
65
+ // global `remoteControlAtStartup` becomes irrelevant for mintree-launched
66
+ // sessions. The optional name = worktree dir, so the RC UI can identify
67
+ // the session at a glance instead of showing a UUID.
68
+ if (options.remoteControlName) {
69
+ args.push("--remote-control", options.remoteControlName);
70
+ }
71
+ else {
72
+ args.push("--remote-control");
73
+ }
63
74
  if (options.prompt && options.prompt.length > 0) {
64
75
  args.push("--", promptArg(options.prompt));
65
76
  }
@@ -13,7 +13,7 @@ export type GhIssue = {
13
13
  };
14
14
  export type WorktreeInfo = {
15
15
  path: string;
16
- branch: string;
16
+ branch: string | null;
17
17
  dirty: boolean;
18
18
  ab: AheadBehind | null;
19
19
  sessionId?: string;
@@ -26,21 +26,21 @@ export async function fetchAssignedIssues() {
26
26
  }
27
27
  /**
28
28
  * Builds a map from issue id (number, as string) to the matching mintree
29
- * worktree, parsing each branch with the same `<type>/<issue>-<desc>` regex
30
- * the create command enforces. Worktrees with branches that don't follow
31
- * the convention are simply skipped.
29
+ * worktree. IssueId comes from the worktree dir name (`<issue>-<desc>`)
30
+ * rather than the branch, so detached worktrees (created via the dashboard's
31
+ * "current branch" mode) are included alongside the regular branch-based
32
+ * ones. Worktrees outside `.mintree/worktrees/` are skipped.
32
33
  */
33
34
  function buildWorktreeIndex(repoRoot) {
34
35
  const worktreesRoot = path.resolve(getWorktreesDir(repoRoot));
35
- const branchRegex = /^[a-z]+\/(\d+)-/;
36
+ const dirNameRegex = /^(\d+)-/;
36
37
  const index = new Map();
37
38
  for (const w of listWorktrees(repoRoot)) {
38
- if (!w.branch)
39
- continue;
40
39
  const wAbs = path.resolve(w.path);
41
40
  if (wAbs !== worktreesRoot && !wAbs.startsWith(worktreesRoot + path.sep))
42
41
  continue;
43
- const m = w.branch.match(branchRegex);
42
+ const dirName = path.basename(wAbs);
43
+ const m = dirName.match(dirNameRegex);
44
44
  const issueId = m && m[1] ? m[1] : null;
45
45
  if (!issueId)
46
46
  continue;
@@ -115,9 +115,12 @@ export async function loadDashboard(repoRoot) {
115
115
  const metadata = readMetadata(repoRoot);
116
116
  // Fetch PRs in parallel for branches that actually have a worktree —
117
117
  // issues without one wouldn't have a branch on this user's repo, so we
118
- // skip the per-issue gh call for them.
118
+ // skip the per-issue gh call for them. Detached worktrees (branch=null)
119
+ // have no PR by definition.
119
120
  const prByBranch = new Map();
120
- const prFetches = Array.from(worktreesByIssue.values()).map(async (w) => {
121
+ const prFetches = Array.from(worktreesByIssue.values())
122
+ .filter((w) => w.branch !== null)
123
+ .map(async (w) => {
121
124
  const pr = await fetchPrForBranch(w.branch);
122
125
  if (pr)
123
126
  prByBranch.set(w.branch, pr);
@@ -128,7 +131,7 @@ export async function loadDashboard(repoRoot) {
128
131
  const worktreeRaw = worktreesByIssue.get(issueId) ?? null;
129
132
  const sessionId = metadata.issues[issueId]?.session_id;
130
133
  const worktree = worktreeRaw ? { ...worktreeRaw, sessionId } : null;
131
- const pr = worktree ? (prByBranch.get(worktree.branch) ?? null) : null;
134
+ const pr = worktree && worktree.branch ? (prByBranch.get(worktree.branch) ?? null) : null;
132
135
  return {
133
136
  issue,
134
137
  worktree,
@@ -34,3 +34,23 @@ export type CreateResult = {
34
34
  * for the work hand-off when relevant.
35
35
  */
36
36
  export declare function runCreate(branchArg: string, opts: CreateOpts): CreateResult;
37
+ export type CreateDetachedOpts = {
38
+ issueId: string;
39
+ descKebab: string;
40
+ work: boolean;
41
+ prompt?: string;
42
+ permissionMode?: PermissionMode;
43
+ };
44
+ /**
45
+ * Variant of `runCreate` that doesn't create a new branch — the worktree is
46
+ * checked out in detached HEAD at the tip of the main repo's current branch.
47
+ * Used by the dashboard's "current branch" overlay mode: lets the user spin
48
+ * up a worktree off whatever they're on (typically `main`) without forcing
49
+ * the `<type>/<issue>-<desc>` convention upfront. They can `git switch -c`
50
+ * later if/when the work warrants a branch.
51
+ *
52
+ * Worktree dir naming follows the same `<issueId>-<desc>` shape as the
53
+ * branch-based flow so `worktree work` can still recover the issueId from
54
+ * the dir name (where it can't read it from the branch).
55
+ */
56
+ export declare function runCreateDetached(opts: CreateDetachedOpts): CreateResult;
@@ -3,7 +3,7 @@ import * as os from "os";
3
3
  import * as path from "path";
4
4
  import { execSync } from "child_process";
5
5
  import { parseBranch, isParseError } from "./branch.js";
6
- import { findMainRepoRoot, getMintreeDir, getWorktreesDir, getInitScriptPath, getDefaultBranch, branchExists, worktreeForBranch, addWorktree, pathExists, isExecutable, } from "./git.js";
6
+ import { findMainRepoRoot, getMintreeDir, getWorktreesDir, getInitScriptPath, getDefaultBranch, getCurrentBranch, branchExists, worktreeForBranch, addWorktree, pathExists, isExecutable, } from "./git.js";
7
7
  import { upsertIssue } from "./metadata.js";
8
8
  function tryRunInitScript(scriptPath, worktreePath, repoRoot) {
9
9
  if (!pathExists(scriptPath))
@@ -182,3 +182,119 @@ export function runCreate(branchArg, opts) {
182
182
  permissionMode: opts.permissionMode,
183
183
  };
184
184
  }
185
+ /**
186
+ * Variant of `runCreate` that doesn't create a new branch — the worktree is
187
+ * checked out in detached HEAD at the tip of the main repo's current branch.
188
+ * Used by the dashboard's "current branch" overlay mode: lets the user spin
189
+ * up a worktree off whatever they're on (typically `main`) without forcing
190
+ * the `<type>/<issue>-<desc>` convention upfront. They can `git switch -c`
191
+ * later if/when the work warrants a branch.
192
+ *
193
+ * Worktree dir naming follows the same `<issueId>-<desc>` shape as the
194
+ * branch-based flow so `worktree work` can still recover the issueId from
195
+ * the dir name (where it can't read it from the branch).
196
+ */
197
+ export function runCreateDetached(opts) {
198
+ const root = findMainRepoRoot();
199
+ if (!root) {
200
+ return {
201
+ ok: false,
202
+ message: "Not in a git repository.",
203
+ hint: "Run `git init` and then `mintree init`.",
204
+ };
205
+ }
206
+ if (!pathExists(getMintreeDir(root))) {
207
+ return {
208
+ ok: false,
209
+ message: ".mintree/ not found in this repo.",
210
+ hint: "Run `mintree init` first.",
211
+ };
212
+ }
213
+ if (!/^\d+$/.test(opts.issueId)) {
214
+ return { ok: false, message: `Invalid issueId: ${opts.issueId}` };
215
+ }
216
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(opts.descKebab)) {
217
+ return {
218
+ ok: false,
219
+ message: `Invalid desc: ${opts.descKebab}`,
220
+ hint: "Expected kebab-case starting with [a-z0-9].",
221
+ };
222
+ }
223
+ const currentBranch = getCurrentBranch(root);
224
+ if (!currentBranch) {
225
+ return {
226
+ ok: false,
227
+ message: "Main repo is in detached HEAD — can't determine current branch to fork from.",
228
+ hint: "Switch the main repo to a branch first (`git switch main`) and try again.",
229
+ };
230
+ }
231
+ const worktreeDirName = `${opts.issueId}-${opts.descKebab}`;
232
+ const worktreePath = path.join(getWorktreesDir(root), worktreeDirName);
233
+ if (pathExists(worktreePath)) {
234
+ return {
235
+ ok: false,
236
+ message: `Worktree directory already exists: ${worktreePath}`,
237
+ hint: "Remove it first or pick a different description.",
238
+ };
239
+ }
240
+ const steps = [];
241
+ steps.push({
242
+ kind: "ok",
243
+ label: "detached worktree",
244
+ detail: `issue=${opts.issueId}, base=${currentBranch}`,
245
+ });
246
+ try {
247
+ execSync(`git worktree add --detach '${worktreePath.replace(/'/g, `'\\''`)}' '${currentBranch.replace(/'/g, `'\\''`)}'`, { cwd: root, stdio: ["ignore", "pipe", "pipe"] });
248
+ }
249
+ catch (err) {
250
+ const stderr = err && typeof err === "object" && "stderr" in err
251
+ ? String(err.stderr).trim()
252
+ : err instanceof Error
253
+ ? err.message
254
+ : String(err);
255
+ return { ok: false, message: `git worktree add --detach failed: ${stderr}` };
256
+ }
257
+ steps.push({
258
+ kind: "ok",
259
+ label: "checked out detached HEAD",
260
+ detail: `at tip of ${currentBranch}`,
261
+ });
262
+ steps.push({ kind: "ok", label: "worktree created", detail: worktreePath });
263
+ upsertIssue(root, opts.issueId, { base_branch: currentBranch });
264
+ steps.push({ kind: "ok", label: "metadata updated", detail: `issue ${opts.issueId}` });
265
+ const initShPath = getInitScriptPath(root);
266
+ const initResult = tryRunInitScript(initShPath, worktreePath, root);
267
+ if (initResult.ran) {
268
+ steps.push({ kind: "ok", label: "ran .mintree/init.sh", detail: worktreePath });
269
+ }
270
+ else if (initResult.error) {
271
+ steps.push({ kind: "warn", label: "init.sh failed", detail: initResult.error });
272
+ }
273
+ else if (!pathExists(initShPath)) {
274
+ steps.push({ kind: "skip", label: "no init.sh (skipping post-create hook)" });
275
+ }
276
+ let promptFile;
277
+ if (opts.work && opts.prompt && opts.prompt.length > 0) {
278
+ try {
279
+ promptFile = writePromptFile(opts.prompt);
280
+ }
281
+ catch (err) {
282
+ steps.push({
283
+ kind: "warn",
284
+ label: "failed to stage --prompt for hand-off",
285
+ detail: err instanceof Error ? err.message : String(err),
286
+ });
287
+ }
288
+ }
289
+ return {
290
+ ok: true,
291
+ steps,
292
+ worktreePath,
293
+ branch: `detached @ ${currentBranch}`,
294
+ issueId: opts.issueId,
295
+ base: currentBranch,
296
+ work: opts.work,
297
+ promptFile,
298
+ permissionMode: opts.permissionMode,
299
+ };
300
+ }
@@ -19,3 +19,10 @@ export type RemoveResult = {
19
19
  * resume the same Claude session.
20
20
  */
21
21
  export declare function runRemove(branchArg: string, force: boolean): RemoveResult;
22
+ /**
23
+ * Path-keyed counterpart to `runRemove`, used for worktrees that don't have
24
+ * a parseable branch (detached HEAD ones created via the dashboard's
25
+ * "current branch" mode). Same dirty/force/prune semantics as runRemove —
26
+ * just skips the `parseBranch` step and reports the worktree by its path.
27
+ */
28
+ export declare function runRemoveByPath(worktreePath: string, force: boolean): RemoveResult;
@@ -82,3 +82,71 @@ export function runRemove(branchArg, force) {
82
82
  wasDirty: dirty,
83
83
  };
84
84
  }
85
+ /**
86
+ * Path-keyed counterpart to `runRemove`, used for worktrees that don't have
87
+ * a parseable branch (detached HEAD ones created via the dashboard's
88
+ * "current branch" mode). Same dirty/force/prune semantics as runRemove —
89
+ * just skips the `parseBranch` step and reports the worktree by its path.
90
+ */
91
+ export function runRemoveByPath(worktreePath, force) {
92
+ const root = findMainRepoRoot();
93
+ if (!root) {
94
+ return {
95
+ ok: false,
96
+ message: "Not in a git repository.",
97
+ hint: "Run `git init` and then `mintree init`.",
98
+ };
99
+ }
100
+ if (!pathExists(getMintreeDir(root))) {
101
+ return {
102
+ ok: false,
103
+ message: ".mintree/ not found in this repo.",
104
+ hint: "Run `mintree init` first.",
105
+ };
106
+ }
107
+ const label = worktreePath.split("/").pop() ?? worktreePath;
108
+ if (!pathExists(worktreePath)) {
109
+ try {
110
+ pruneWorktrees(root);
111
+ }
112
+ catch (err) {
113
+ return {
114
+ ok: false,
115
+ message: `git worktree prune failed: ${err instanceof Error ? err.message : String(err)}`,
116
+ };
117
+ }
118
+ return {
119
+ ok: true,
120
+ branch: label,
121
+ worktreePath,
122
+ variant: "pruned-orphan",
123
+ wasDirty: false,
124
+ };
125
+ }
126
+ const dirty = isDirty(worktreePath);
127
+ if (dirty && !force) {
128
+ return {
129
+ ok: false,
130
+ message: `Worktree at ${worktreePath} has uncommitted changes.`,
131
+ hint: "Commit/stash first, or pass --force to discard them.",
132
+ };
133
+ }
134
+ try {
135
+ removeWorktree({ repoRoot: root, worktreePath, force });
136
+ }
137
+ catch (err) {
138
+ const stderr = err && typeof err === "object" && "stderr" in err
139
+ ? String(err.stderr).trim()
140
+ : err instanceof Error
141
+ ? err.message
142
+ : String(err);
143
+ return { ok: false, message: `git worktree remove failed: ${stderr}` };
144
+ }
145
+ return {
146
+ ok: true,
147
+ branch: label,
148
+ worktreePath,
149
+ variant: "removed",
150
+ wasDirty: dirty,
151
+ };
152
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mintree",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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>",