mintree 0.5.12 → 0.5.14

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
@@ -161,11 +161,11 @@ Three top-level keys in `.mintree/metadata.json` tune how mintree launches Claud
161
161
 
162
162
  When omitted, mintree uses a built-in default that asks Claude to orchestrate the selected tickets with minimal intervention — parallelising via subagents unless dependencies force sequential work, creating a worktree per ticket with mintree, using the repo's skills, and moving each ticket to *in progress* on start and closing it when done.
163
163
 
164
- ### Linking gitignored files into worktrees (optional)
164
+ ### Copying gitignored files into worktrees (optional)
165
165
 
166
166
  Git worktrees don't share **untracked** files: a new worktree is a fresh working directory, so gitignored config like `.env` lives only in your main checkout and is **absent** in every worktree mintree creates. That breaks per-worktree tooling that needs it — e.g. running an E2E suite that reads `.env` for staging credentials.
167
167
 
168
- The `linkFiles` top-level key (valid on GitHub and Linear repos) lists repo-root-relative paths that mintree **symlinks** into each new worktree, right after creating it:
168
+ The `linkFiles` top-level key (valid on GitHub and Linear repos) lists repo-root-relative paths that mintree **copies** into each new worktree, right after creating it:
169
169
 
170
170
  ```json
171
171
  {
@@ -177,10 +177,11 @@ The `linkFiles` top-level key (valid on GitHub and Linear repos) lists repo-root
177
177
  }
178
178
  ```
179
179
 
180
- - **Symlink, not copy** — one source of truth. Rotate a credential in the main `.env` and every worktree sees it; no re-copying. `worktree remove` deletes the link, never the original file.
180
+ - **Copy, not symlink** — each worktree gets its **own** file. Editing the worktree's `.env` (a port, a feature flag, a per-worktree tweak) stays local and never mutates the main checkout's. The trade-off: it's a snapshot taken at create time, so rotating a credential in the main `.env` does **not** propagate to worktrees already created — re-copy by hand if you need it.
181
+ > Up to 0.5.13 these were **symlinks**, so editing a worktree's `.env` wrote through to the main checkout. The switch to copies only affects worktrees created from 0.5.14 on — existing worktrees keep their old symlink. To convert one, replace the link with a real copy: `rm <worktree>/.env && cp <repo-root>/.env <worktree>/.env`.
181
182
  - **Best-effort, never fatal** — an entry that doesn't exist in the repo root is skipped, and so is one whose target is already present in the worktree (e.g. a tracked file). Both show up as `skip` steps in the create log.
182
- - **Runs before `.mintree/init.sh`** — so the post-create hook (if any) can rely on the linked files being there.
183
- - **Sandboxed paths** — entries must be repo-root-relative; absolute paths and `..` escapes are dropped on read, so a stray `metadata.json` can't make mintree link something outside the worktree.
183
+ - **Runs before `.mintree/init.sh`** — so the post-create hook (if any) can rely on the copied files being there.
184
+ - **Sandboxed paths** — entries must be repo-root-relative; absolute paths and `..` escapes are dropped on read, so a stray `metadata.json` can't make mintree copy something outside the worktree.
184
185
 
185
186
  This applies to `worktree create` (CLI), the dashboard `w` overlay, and the detached-worktree flow alike. For more involved per-worktree setup (installing deps, copying templated files), use `.mintree/init.sh` instead — see [What gets stored where](#what-gets-stored-where).
186
187
 
@@ -211,7 +212,7 @@ It has three tabs, switched with `←` / `→`:
211
212
  | `a` | Orchestrate tab: select / deselect all visible tickets |
212
213
  | `w` | Always open the create overlay (type + kebab description) |
213
214
  | `d` | Delete the selected worktree (confirmation overlay) |
214
- | `r` | Manual refresh (auto-refreshes silently every 5 min) |
215
+ | `r` | Manual refresh — bypasses the Linear snapshot cache, so a just-assigned ticket shows up immediately (auto-refreshes silently every 5 min) |
215
216
  | `o` | Open the issue in your browser |
216
217
  | `q`/`Esc`| Quit (or cancel an open overlay) |
217
218
 
@@ -226,6 +227,9 @@ Same building blocks, scriptable from any shell:
226
227
  mintree worktree create feat/100-validar-patente
227
228
  mintree worktree create feat/FE-123-validar-patente --work --prompt "empezar FE-123"
228
229
 
230
+ # Fork from a specific base instead of origin/HEAD (defaults to main/master)
231
+ mintree worktree create fix/55-hotfix --base release/2.1
232
+
229
233
  # On a Linear repo you can pass the issue's own Linear branch name
230
234
  mintree worktree create martinmineo/val-68-landing-publica --work
231
235
 
@@ -314,7 +318,7 @@ Linear authentication lives in `~/.mintree/credentials.json` (user-scoped, not p
314
318
 
315
319
  - **Sessions persist by issue**: each issue gets a UUID stored in `metadata.json`. Subsequent `worktree work` calls pass `--resume <uuid>` so Claude reopens the same conversation.
316
320
  - **Live state** (optional): the four hooks installed by `mintree helpers session-signal install` write the current Claude state to `.mintree/session-states/<issue>.json` on every prompt / stop / notification / session-end. The dashboard reads those files to colour each row in real time.
317
- - **Remote Control** (optional): `mintree doctor` checks `~/.claude.json` for `remoteControlAtStartup: true`. Enabling it lets you continue a local session from a different device.
321
+ - **Remote Control** (optional): `mintree doctor` checks `~/.claude.json` for `remoteControlAtStartup: true`. Enable it by running `/config` inside Claude Code and turning on *Enable Remote Control for all sessions* — it lets you continue a local session from a different device.
318
322
  - **iTerm2 session badge** (automatic): when you launch on [iTerm2](https://iterm2.com), mintree sets the terminal **badge** — the large translucent label drawn over the session — to the session name (the worktree issue id like `VAL-68`, or the orchestrator's name) so each tab stays identifiable at a glance. It uses the badge rather than the tab title because Claude Code overwrites the title while it runs; the badge is independent of it and persists for the whole session, then clears on exit. No-op on other terminals (detected via `TERM_PROGRAM` / `LC_TERMINAL`).
319
323
 
320
324
  ---
@@ -669,7 +669,7 @@ export default function Dashboard() {
669
669
  // Live value for the mouse handler (mounted once) to read without
670
670
  // re-binding on every resize.
671
671
  const listWidthRef = useRef(0);
672
- const refresh = async () => {
672
+ const refresh = async (opts) => {
673
673
  const root = findMainRepoRoot();
674
674
  if (!root) {
675
675
  setState({
@@ -687,7 +687,7 @@ export default function Dashboard() {
687
687
  });
688
688
  return;
689
689
  }
690
- const issues = await loadDashboard(root);
690
+ const issues = await loadDashboard(root, opts);
691
691
  if (!issues) {
692
692
  const provider = readMetadata(root).provider ?? "github";
693
693
  const message = provider === "linear"
@@ -975,7 +975,10 @@ export default function Dashboard() {
975
975
  }
976
976
  if (input === "r") {
977
977
  setState({ ...state, refreshing: true });
978
- void refresh();
978
+ // Manual refresh bypasses the Linear snapshot cache: the user pressed
979
+ // `r` to see a change they just made externally (e.g. a freshly
980
+ // assigned ticket), so serving cached data would defeat the gesture.
981
+ void refresh({ forceRefresh: true });
979
982
  return;
980
983
  }
981
984
  // Orchestrate tab: Space toggles the ticket under the cursor; `a`
@@ -1,8 +1,8 @@
1
1
  import { type AheadBehind } from "./git.js";
2
2
  import { type PrInfo } from "./pr.js";
3
- import type { IssueProjectInfo, ProviderIssue } from "./providers/types.js";
3
+ import type { IssueProjectInfo, LoadOptions, ProviderIssue } from "./providers/types.js";
4
4
  export type { PrInfo, PrState } from "./pr.js";
5
- export type { ProviderIssue, IssueProjectInfo, IssueId } from "./providers/types.js";
5
+ export type { ProviderIssue, IssueProjectInfo, IssueId, LoadOptions } from "./providers/types.js";
6
6
  export type WorktreeInfo = {
7
7
  path: string;
8
8
  branch: string | null;
@@ -29,4 +29,4 @@ export type DashboardIssue = {
29
29
  * session snapshot. Designed to be called on dashboard mount and on every
30
30
  * `r` refresh — cheap because all the per-worktree probes are local.
31
31
  */
32
- export declare function loadDashboard(repoRoot: string): Promise<DashboardIssue[] | null>;
32
+ export declare function loadDashboard(repoRoot: string, opts?: LoadOptions): Promise<DashboardIssue[] | null>;
@@ -171,9 +171,9 @@ function buildOrphanRows(worktreesByIssue, assignedIds, sessionLookup, prByBranc
171
171
  * session snapshot. Designed to be called on dashboard mount and on every
172
172
  * `r` refresh — cheap because all the per-worktree probes are local.
173
173
  */
174
- export async function loadDashboard(repoRoot) {
174
+ export async function loadDashboard(repoRoot, opts) {
175
175
  const provider = createProvider(repoRoot);
176
- const issues = await provider.listAssignedIssues();
176
+ const issues = await provider.listAssignedIssues(opts);
177
177
  if (!issues)
178
178
  return null;
179
179
  const worktreesByIssue = buildWorktreeIndex(repoRoot);
@@ -196,7 +196,7 @@ export async function loadDashboard(repoRoot) {
196
196
  // alongside the per-branch PR probes so neither blocks the other.
197
197
  const [, projectByIssue] = await Promise.all([
198
198
  Promise.all(prFetches),
199
- provider.fetchProjectAssignments(),
199
+ provider.fetchProjectAssignments(opts),
200
200
  ]);
201
201
  // Provider signals total failure (vs no projects configured) with null —
202
202
  // treat as a partial load failure so the caller's resilient refresh
@@ -20,7 +20,7 @@ function sanitizeOrchestratorPromptTemplate(raw) {
20
20
  /**
21
21
  * Keeps only safe, repo-root-relative paths. Drops non-strings, blanks,
22
22
  * absolute paths and any entry that escapes the repo root via `..` — a
23
- * malicious / fat-fingered `metadata.json` must never make mintree symlink
23
+ * malicious / fat-fingered `metadata.json` must never make mintree copy
24
24
  * something outside the worktree. Normalises and de-dupes the survivors.
25
25
  */
26
26
  function sanitizeLinkFiles(raw) {
@@ -14,7 +14,7 @@
14
14
  * Linear personal API keys (`lin_api_...`) go directly into the
15
15
  * Authorization header with no `Bearer` prefix.
16
16
  */
17
- import type { IssueId, IssueProjectInfo, IssueProvider, ProviderIssue, TransitionResult } from "./types.js";
17
+ import type { IssueId, IssueProjectInfo, IssueProvider, LoadOptions, ProviderIssue, TransitionResult } from "./types.js";
18
18
  export declare class LinearProvider implements IssueProvider {
19
19
  private readonly repoRoot;
20
20
  readonly kind: "linear";
@@ -26,10 +26,15 @@ export declare class LinearProvider implements IssueProvider {
26
26
  * and fetchProjectAssignments call this so we never double-fetch within a
27
27
  * load. Per-instance promise memoisation handles the back-to-back call;
28
28
  * the module-level cache handles refreshes within the TTL.
29
+ *
30
+ * `forceRefresh` skips the module-level cache read so the live GraphQL query
31
+ * runs again (it still writes the result back to the cache). The per-instance
32
+ * promise is kept either way, so the two callers within one load share the
33
+ * single forced fetch instead of issuing two.
29
34
  */
30
35
  private loadSnapshot;
31
- listAssignedIssues(): Promise<ProviderIssue[] | null>;
32
- fetchProjectAssignments(): Promise<Map<IssueId, IssueProjectInfo> | null>;
36
+ listAssignedIssues(opts?: LoadOptions): Promise<ProviderIssue[] | null>;
37
+ fetchProjectAssignments(opts?: LoadOptions): Promise<Map<IssueId, IssueProjectInfo> | null>;
33
38
  transitionIssueToInProgress(issueId: IssueId): Promise<TransitionResult>;
34
39
  }
35
40
  /**
@@ -355,8 +355,13 @@ export class LinearProvider {
355
355
  * and fetchProjectAssignments call this so we never double-fetch within a
356
356
  * load. Per-instance promise memoisation handles the back-to-back call;
357
357
  * the module-level cache handles refreshes within the TTL.
358
+ *
359
+ * `forceRefresh` skips the module-level cache read so the live GraphQL query
360
+ * runs again (it still writes the result back to the cache). The per-instance
361
+ * promise is kept either way, so the two callers within one load share the
362
+ * single forced fetch instead of issuing two.
358
363
  */
359
- async loadSnapshot() {
364
+ async loadSnapshot(forceRefresh = false) {
360
365
  if (this.snapshotPromise)
361
366
  return this.snapshotPromise;
362
367
  const cfg = this.getConfig();
@@ -377,7 +382,7 @@ export class LinearProvider {
377
382
  }
378
383
  const apiUrl = cfg.apiUrl ?? DEFAULT_API_URL;
379
384
  const teamKeys = cfg.teams.map((t) => t.key);
380
- const cached = readSnapshotCache(cfg.workspaceSlug, teamKeys);
385
+ const cached = forceRefresh ? null : readSnapshotCache(cfg.workspaceSlug, teamKeys);
381
386
  if (cached)
382
387
  return cached;
383
388
  this.snapshotPromise = (async () => {
@@ -394,11 +399,11 @@ export class LinearProvider {
394
399
  })();
395
400
  return this.snapshotPromise;
396
401
  }
397
- async listAssignedIssues() {
402
+ async listAssignedIssues(opts) {
398
403
  const cfg = this.getConfig();
399
404
  if (!cfg || cfg.teams.length === 0)
400
405
  return [];
401
- const snapshot = await this.loadSnapshot();
406
+ const snapshot = await this.loadSnapshot(opts?.forceRefresh ?? false);
402
407
  if ("ok" in snapshot && snapshot.ok === false)
403
408
  return null;
404
409
  const data = snapshot;
@@ -417,12 +422,12 @@ export class LinearProvider {
417
422
  }
418
423
  return out;
419
424
  }
420
- async fetchProjectAssignments() {
425
+ async fetchProjectAssignments(opts) {
421
426
  const cfg = this.getConfig();
422
427
  const result = new Map();
423
428
  if (!cfg || cfg.teams.length === 0)
424
429
  return result;
425
- const snapshot = await this.loadSnapshot();
430
+ const snapshot = await this.loadSnapshot(opts?.forceRefresh ?? false);
426
431
  if ("ok" in snapshot && snapshot.ok === false)
427
432
  return null;
428
433
  const data = snapshot;
@@ -10,6 +10,17 @@
10
10
  * worktree dir names round-trip through the IssueId without re-parsing.
11
11
  */
12
12
  export type IssueId = string;
13
+ /**
14
+ * Options shared by the read methods of IssueProvider. `forceRefresh` tells a
15
+ * provider that keeps a snapshot cache (Linear) to bypass it and re-fetch from
16
+ * the source. The dashboard sets it for the manual `r` refresh so a change made
17
+ * seconds ago (e.g. an issue just assigned to the user) shows up immediately,
18
+ * instead of waiting out the cache TTL. Providers without a cache (GitHub)
19
+ * ignore it.
20
+ */
21
+ export type LoadOptions = {
22
+ forceRefresh?: boolean;
23
+ };
13
24
  /**
14
25
  * A workflow issue normalised across providers. Shape mirrors what the GH
15
26
  * `gh issue list --json` payload exposes minus the GH-specific `number`
@@ -91,7 +102,7 @@ export interface IssueProvider {
91
102
  * Linear: the configured workspace/teams). Returns null on transient
92
103
  * failure (auth, network) — the dashboard renders an error hint.
93
104
  */
94
- listAssignedIssues(): Promise<ProviderIssue[] | null>;
105
+ listAssignedIssues(opts?: LoadOptions): Promise<ProviderIssue[] | null>;
95
106
  /**
96
107
  * Returns project/board membership for the assigned issues (same scope as
97
108
  * listAssignedIssues — typically a single round-trip). The dashboard uses
@@ -104,7 +115,7 @@ export interface IssueProvider {
104
115
  * auth missing). Distinct from empty so the dashboard can treat
105
116
  * null as a partial load failure and keep its last-good state.
106
117
  */
107
- fetchProjectAssignments(): Promise<Map<IssueId, IssueProjectInfo> | null>;
118
+ fetchProjectAssignments(opts?: LoadOptions): Promise<Map<IssueId, IssueProjectInfo> | null>;
108
119
  /**
109
120
  * Moves the issue to its project's "In Progress" workflow state. Idempotent
110
121
  * by design (returns noop-already when already there) and conservative on
@@ -50,23 +50,25 @@ function tryRunInitScript(scriptPath, worktreePath, repoRoot) {
50
50
  }
51
51
  }
52
52
  /**
53
- * Symlinks each `metadata.linkFiles` entry from the main repo into the freshly
53
+ * Copies each `metadata.linkFiles` entry from the main repo into the freshly
54
54
  * created worktree. Git worktrees don't share untracked files, so gitignored
55
- * config like `.env` is absent in a new worktree; this links it in so the
55
+ * config like `.env` is absent in a new worktree; this copies it in so the
56
56
  * worktree's tooling finds the same secrets/config as the main checkout.
57
57
  *
58
- * A symlink (not a copy) keeps a single source of truthrotating a credential
59
- * in the main `.env` is seen by every worktree, and `worktree remove` deletes
60
- * only the link. Entries are repo-root-relative (already validated by
61
- * `sanitizeLinkFiles`). Each entry is best-effort: a missing source or an
58
+ * A copy (not a symlink) gives each worktree its OWN fileediting the
59
+ * worktree's `.env` no longer mutates the main checkout's, so a per-worktree
60
+ * tweak (a port, a feature flag) stays local. The trade-off is no single source
61
+ * of truth: rotating a credential in the main `.env` does NOT propagate to
62
+ * already-created worktrees. Entries are repo-root-relative (already validated
63
+ * by `sanitizeLinkFiles`). Each entry is best-effort: a missing source or an
62
64
  * already-present target is skipped, never fatal.
63
65
  */
64
- function linkFilesIntoWorktree(repoRoot, worktreePath, linkFiles, pushStep) {
66
+ function copyFilesIntoWorktree(repoRoot, worktreePath, linkFiles, pushStep) {
65
67
  for (const rel of linkFiles) {
66
68
  const source = path.join(repoRoot, rel);
67
69
  const target = path.join(worktreePath, rel);
68
70
  if (!pathExists(source)) {
69
- pushStep({ kind: "skip", label: `skipped link ${rel}`, detail: "not present in repo root" });
71
+ pushStep({ kind: "skip", label: `skipped copy ${rel}`, detail: "not present in repo root" });
70
72
  continue;
71
73
  }
72
74
  // lstat (not pathExists) so an existing symlink/file/dir already at the
@@ -83,20 +85,20 @@ function linkFilesIntoWorktree(repoRoot, worktreePath, linkFiles, pushStep) {
83
85
  if (targetTaken) {
84
86
  pushStep({
85
87
  kind: "skip",
86
- label: `skipped link ${rel}`,
88
+ label: `skipped copy ${rel}`,
87
89
  detail: "already present in worktree",
88
90
  });
89
91
  continue;
90
92
  }
91
93
  try {
92
94
  fs.mkdirSync(path.dirname(target), { recursive: true });
93
- fs.symlinkSync(source, target);
94
- pushStep({ kind: "ok", label: `linked ${rel}`, detail: `→ ${source}` });
95
+ fs.copyFileSync(source, target);
96
+ pushStep({ kind: "ok", label: `copied ${rel}`, detail: `from ${source}` });
95
97
  }
96
98
  catch (err) {
97
99
  pushStep({
98
100
  kind: "warn",
99
- label: `failed to link ${rel}`,
101
+ label: `failed to copy ${rel}`,
100
102
  detail: err instanceof Error ? err.message : String(err),
101
103
  });
102
104
  }
@@ -260,11 +262,11 @@ export async function runCreate(branchArg, opts) {
260
262
  upsertIssue(root, parsed.issueId, base ? { base_branch: base } : {});
261
263
  pushStep({ kind: "ok", label: "metadata updated", detail: `issue ${parsed.issueId}` });
262
264
  await nextFrame(progress);
263
- // Link gitignored config (e.g. .env) before init.sh, so the hook can rely
265
+ // Copy gitignored config (e.g. .env) before init.sh, so the hook can rely
264
266
  // on those files being present.
265
267
  const linkFiles = readMetadata(root).linkFiles;
266
268
  if (linkFiles && linkFiles.length > 0) {
267
- linkFilesIntoWorktree(root, worktreePath, linkFiles, pushStep);
269
+ copyFilesIntoWorktree(root, worktreePath, linkFiles, pushStep);
268
270
  await nextFrame(progress);
269
271
  }
270
272
  const initShPath = getInitScriptPath(root);
@@ -422,11 +424,11 @@ export async function runCreateDetached(opts) {
422
424
  upsertIssue(root, opts.issueId, { base_branch: currentBranch });
423
425
  pushStep({ kind: "ok", label: "metadata updated", detail: `issue ${opts.issueId}` });
424
426
  await nextFrame(progress);
425
- // Link gitignored config (e.g. .env) before init.sh, so the hook can rely
427
+ // Copy gitignored config (e.g. .env) before init.sh, so the hook can rely
426
428
  // on those files being present.
427
429
  const linkFiles = readMetadata(root).linkFiles;
428
430
  if (linkFiles && linkFiles.length > 0) {
429
- linkFilesIntoWorktree(root, worktreePath, linkFiles, pushStep);
431
+ copyFilesIntoWorktree(root, worktreePath, linkFiles, pushStep);
430
432
  await nextFrame(progress);
431
433
  }
432
434
  const initShPath = getInitScriptPath(root);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mintree",
3
- "version": "0.5.12",
3
+ "version": "0.5.14",
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>",