mintree 0.5.11 → 0.5.12

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,6 +161,29 @@ 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)
165
+
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
+
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:
169
+
170
+ ```json
171
+ {
172
+ "version": 1,
173
+ "provider": "linear",
174
+ "issues": {},
175
+ "linkFiles": [".env"],
176
+ "linear": { "workspaceSlug": "my-team", "teams": [{ "key": "FE" }] }
177
+ }
178
+ ```
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.
181
+ - **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.
184
+
185
+ 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
+
164
187
  ---
165
188
 
166
189
  ## Daily flow
@@ -276,7 +299,7 @@ The worktree directory is still the **bare, upper-case issue id** (`VAL-68`) reg
276
299
  │ └── FE-123/ # Linear form: <TEAM-digits>
277
300
  ├── session-states/ # gitignored
278
301
  │ └── 100.json # live state written by Claude hooks (active/waiting/idle/exited)
279
- └── init.sh # opt-in. Runs in the new worktree post-create (copy .env, install deps, …)
302
+ └── init.sh # opt-in. Runs in the new worktree post-create (install deps, scaffold, …)
280
303
  ```
281
304
 
282
305
  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.
@@ -30,6 +30,7 @@ export type Metadata = {
30
30
  defaultPermissionMode?: PermissionMode;
31
31
  promptTemplate?: string;
32
32
  orchestratorPromptTemplate?: string;
33
+ linkFiles?: string[];
33
34
  };
34
35
  export declare function readMetadata(repoRoot: string): Metadata;
35
36
  export declare function writeMetadata(repoRoot: string, data: Metadata): void;
@@ -1,4 +1,5 @@
1
1
  import * as fs from "fs";
2
+ import * as path from "path";
2
3
  import { getMetadataPath } from "./git.js";
3
4
  import { PERMISSION_MODES } from "./claude.js";
4
5
  const EMPTY = { version: 1, issues: {} };
@@ -16,6 +17,36 @@ function sanitizePromptTemplate(raw) {
16
17
  function sanitizeOrchestratorPromptTemplate(raw) {
17
18
  return typeof raw === "string" && raw.trim().length > 0 ? raw : undefined;
18
19
  }
20
+ /**
21
+ * Keeps only safe, repo-root-relative paths. Drops non-strings, blanks,
22
+ * absolute paths and any entry that escapes the repo root via `..` — a
23
+ * malicious / fat-fingered `metadata.json` must never make mintree symlink
24
+ * something outside the worktree. Normalises and de-dupes the survivors.
25
+ */
26
+ function sanitizeLinkFiles(raw) {
27
+ if (!Array.isArray(raw))
28
+ return undefined;
29
+ const out = [];
30
+ const seen = new Set();
31
+ for (const v of raw) {
32
+ if (typeof v !== "string")
33
+ continue;
34
+ const trimmed = v.trim();
35
+ if (trimmed.length === 0 || path.isAbsolute(trimmed))
36
+ continue;
37
+ const norm = path.normalize(trimmed);
38
+ if (norm === ".." ||
39
+ norm.startsWith(`..${path.sep}`) ||
40
+ norm.includes(`${path.sep}..${path.sep}`)) {
41
+ continue;
42
+ }
43
+ if (seen.has(norm))
44
+ continue;
45
+ seen.add(norm);
46
+ out.push(norm);
47
+ }
48
+ return out.length > 0 ? out : undefined;
49
+ }
19
50
  function sanitizeLinearTeam(raw) {
20
51
  if (typeof raw !== "object" || raw === null)
21
52
  return undefined;
@@ -90,6 +121,7 @@ export function readMetadata(repoRoot) {
90
121
  const defaultPermissionMode = sanitizePermissionMode(parsed.defaultPermissionMode);
91
122
  const promptTemplate = sanitizePromptTemplate(parsed.promptTemplate);
92
123
  const orchestratorPromptTemplate = sanitizeOrchestratorPromptTemplate(parsed.orchestratorPromptTemplate);
124
+ const linkFiles = sanitizeLinkFiles(parsed.linkFiles);
93
125
  return {
94
126
  version: 1,
95
127
  issues: typeof parsed.issues === "object" && parsed.issues !== null
@@ -101,6 +133,7 @@ export function readMetadata(repoRoot) {
101
133
  ...(defaultPermissionMode ? { defaultPermissionMode } : {}),
102
134
  ...(promptTemplate ? { promptTemplate } : {}),
103
135
  ...(orchestratorPromptTemplate ? { orchestratorPromptTemplate } : {}),
136
+ ...(linkFiles ? { linkFiles } : {}),
104
137
  };
105
138
  }
106
139
  catch {
@@ -49,6 +49,59 @@ function tryRunInitScript(scriptPath, worktreePath, repoRoot) {
49
49
  };
50
50
  }
51
51
  }
52
+ /**
53
+ * Symlinks each `metadata.linkFiles` entry from the main repo into the freshly
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
56
+ * worktree's tooling finds the same secrets/config as the main checkout.
57
+ *
58
+ * A symlink (not a copy) keeps a single source of truth — rotating 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
62
+ * already-present target is skipped, never fatal.
63
+ */
64
+ function linkFilesIntoWorktree(repoRoot, worktreePath, linkFiles, pushStep) {
65
+ for (const rel of linkFiles) {
66
+ const source = path.join(repoRoot, rel);
67
+ const target = path.join(worktreePath, rel);
68
+ if (!pathExists(source)) {
69
+ pushStep({ kind: "skip", label: `skipped link ${rel}`, detail: "not present in repo root" });
70
+ continue;
71
+ }
72
+ // lstat (not pathExists) so an existing symlink/file/dir already at the
73
+ // target — e.g. a tracked file git checked out — counts as present and
74
+ // we don't clobber it.
75
+ let targetTaken = false;
76
+ try {
77
+ fs.lstatSync(target);
78
+ targetTaken = true;
79
+ }
80
+ catch {
81
+ targetTaken = false;
82
+ }
83
+ if (targetTaken) {
84
+ pushStep({
85
+ kind: "skip",
86
+ label: `skipped link ${rel}`,
87
+ detail: "already present in worktree",
88
+ });
89
+ continue;
90
+ }
91
+ try {
92
+ fs.mkdirSync(path.dirname(target), { recursive: true });
93
+ fs.symlinkSync(source, target);
94
+ pushStep({ kind: "ok", label: `linked ${rel}`, detail: `→ ${source}` });
95
+ }
96
+ catch (err) {
97
+ pushStep({
98
+ kind: "warn",
99
+ label: `failed to link ${rel}`,
100
+ detail: err instanceof Error ? err.message : String(err),
101
+ });
102
+ }
103
+ }
104
+ }
52
105
  /**
53
106
  * Stashes a `--prompt` value into a temp file so the shell wrapper can hand
54
107
  * it back to `worktree work` via `--prompt-file`. Plain stdout markers can't
@@ -207,6 +260,13 @@ export async function runCreate(branchArg, opts) {
207
260
  upsertIssue(root, parsed.issueId, base ? { base_branch: base } : {});
208
261
  pushStep({ kind: "ok", label: "metadata updated", detail: `issue ${parsed.issueId}` });
209
262
  await nextFrame(progress);
263
+ // Link gitignored config (e.g. .env) before init.sh, so the hook can rely
264
+ // on those files being present.
265
+ const linkFiles = readMetadata(root).linkFiles;
266
+ if (linkFiles && linkFiles.length > 0) {
267
+ linkFilesIntoWorktree(root, worktreePath, linkFiles, pushStep);
268
+ await nextFrame(progress);
269
+ }
210
270
  const initShPath = getInitScriptPath(root);
211
271
  if (pathExists(initShPath)) {
212
272
  progress?.onPending?.("Running .mintree/init.sh...");
@@ -362,6 +422,13 @@ export async function runCreateDetached(opts) {
362
422
  upsertIssue(root, opts.issueId, { base_branch: currentBranch });
363
423
  pushStep({ kind: "ok", label: "metadata updated", detail: `issue ${opts.issueId}` });
364
424
  await nextFrame(progress);
425
+ // Link gitignored config (e.g. .env) before init.sh, so the hook can rely
426
+ // on those files being present.
427
+ const linkFiles = readMetadata(root).linkFiles;
428
+ if (linkFiles && linkFiles.length > 0) {
429
+ linkFilesIntoWorktree(root, worktreePath, linkFiles, pushStep);
430
+ await nextFrame(progress);
431
+ }
365
432
  const initShPath = getInitScriptPath(root);
366
433
  if (pathExists(initShPath)) {
367
434
  progress?.onPending?.("Running .mintree/init.sh...");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mintree",
3
- "version": "0.5.11",
3
+ "version": "0.5.12",
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>",