mintree 0.1.8 → 0.1.10

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.
@@ -272,6 +272,83 @@ function buildListRows(issues) {
272
272
  });
273
273
  return rows;
274
274
  }
275
+ /**
276
+ * Splits the grouped list into a pinned-header region and a scrollable body
277
+ * windowed around the selected issue. The selected issue's own project and
278
+ * Status headers are lifted out of the scroll flow so they stay visible
279
+ * ("sticky") while paging through a long group — without them the user loses
280
+ * track of which project/status the rows below belong to.
281
+ *
282
+ * Navigation is unaffected: it still moves `selectedIndex` over the flat
283
+ * issues array; this only decides what's drawn where.
284
+ */
285
+ function windowListRows(listRows, selectedIndex, viewportRows) {
286
+ const selRow = listRows.findIndex((r) => r.kind === "issue" && r.index === selectedIndex);
287
+ const anchor = selRow >= 0 ? selRow : 0;
288
+ // Walk back from the selected row to find its enclosing project and Status
289
+ // headers. A "Sin proyecto" issue has a project header but no status one.
290
+ let projIdx = -1;
291
+ let statusIdx = -1;
292
+ for (let i = anchor; i >= 0; i--) {
293
+ const r = listRows[i];
294
+ if (!r)
295
+ continue;
296
+ if (statusIdx === -1 && r.kind === "status")
297
+ statusIdx = i;
298
+ if (r.kind === "project") {
299
+ projIdx = i;
300
+ break;
301
+ }
302
+ }
303
+ // Pinned rows are pulled out of the body so they never render twice. The
304
+ // blank spacer that precedes a project header is dropped too, so the pane
305
+ // doesn't open with an empty line.
306
+ const pinned = new Set();
307
+ const sticky = [];
308
+ if (projIdx >= 0) {
309
+ pinned.add(projIdx);
310
+ sticky.push(listRows[projIdx]);
311
+ const before = listRows[projIdx - 1];
312
+ if (before && before.kind === "spacer")
313
+ pinned.add(projIdx - 1);
314
+ }
315
+ if (statusIdx >= 0) {
316
+ pinned.add(statusIdx);
317
+ sticky.push(listRows[statusIdx]);
318
+ }
319
+ const body = [];
320
+ let anchorInBody = 0;
321
+ listRows.forEach((r, i) => {
322
+ if (pinned.has(i))
323
+ return;
324
+ if (i === anchor)
325
+ anchorInBody = body.length;
326
+ body.push(r);
327
+ });
328
+ const bodyViewport = Math.max(1, viewportRows - sticky.length);
329
+ const maxStart = Math.max(0, body.length - bodyViewport);
330
+ const start = Math.max(0, Math.min(maxStart, anchorInBody - Math.floor(bodyViewport / 2)));
331
+ const end = Math.min(body.length, start + bodyViewport);
332
+ return {
333
+ sticky,
334
+ body: body.slice(start, end),
335
+ issuesAbove: body.slice(0, start).filter((r) => r.kind === "issue").length,
336
+ issuesBelow: body.slice(end).filter((r) => r.kind === "issue").length,
337
+ };
338
+ }
339
+ // Renders a single grouped-list row — used for both the sticky header region
340
+ // and the scrollable body so the two stay visually identical.
341
+ function ListRowView({ row, selectedIndex, identifierWidth, maxTitleWidth, width, }) {
342
+ if (row.kind === "spacer")
343
+ return _jsx(Text, { children: " " });
344
+ if (row.kind === "project") {
345
+ return _jsx(ProjectHeaderRow, { title: row.title, count: row.count, width: width });
346
+ }
347
+ if (row.kind === "status") {
348
+ return _jsx(StatusHeaderRow, { name: row.name, color: row.color, count: row.count, width: width });
349
+ }
350
+ return (_jsx(IssueListRow, { d: row.d, selected: row.index === selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth }));
351
+ }
275
352
  // Word-wraps a single line at `width` columns, breaking on the last space
276
353
  // before the limit when that yields a reasonable cut. Falls back to a hard
277
354
  // cut for unbroken runs (long URLs, code-fence content) so the detail pane
@@ -980,29 +1057,10 @@ export default function Dashboard() {
980
1057
  // value without re-binding on every resize.
981
1058
  listWidthRef.current = listWidth;
982
1059
  // Grouped list: build the project/status header rows interleaved with
983
- // issue rows, then window the rows around the selected issue's row.
984
- // Navigation still moves `selectedIndex` over the flat issues array
985
- // headers and spacers are non-selectable, purely visual rows.
1060
+ // issue rows, then split into a sticky header region (the selected issue's
1061
+ // project + Status, pinned to the top) and a windowed scrollable body.
986
1062
  const listRows = buildListRows(issues);
987
- const selectedRowIdx = listRows.findIndex((r) => r.kind === "issue" && r.index === selectedIndex);
988
- const rowAnchor = selectedRowIdx >= 0 ? selectedRowIdx : 0;
989
- const maxRowStart = Math.max(0, listRows.length - listVisibleRows);
990
- const rowStart = Math.max(0, Math.min(maxRowStart, rowAnchor - Math.floor(listVisibleRows / 2)));
991
- const rowEnd = Math.min(listRows.length, rowStart + listVisibleRows);
992
- const visibleRows = listRows.slice(rowStart, rowEnd);
993
- const issuesAbove = listRows.slice(0, rowStart).filter((r) => r.kind === "issue").length;
994
- const issuesBelow = listRows.slice(rowEnd).filter((r) => r.kind === "issue").length;
1063
+ const listView = windowListRows(listRows, selectedIndex, listVisibleRows);
995
1064
  const listContentWidth = Math.max(8, listWidth - 4);
996
- return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Box, { paddingX: 1, paddingTop: 0, flexDirection: "column", children: _jsx(HeaderRow, { repoName: repoName, claudeVersion: claudeVersion, issueCount: issues.length, updateAvailable: latestVersion !== null }) }), overlay ? (_jsx(Box, { flexGrow: 1, flexDirection: "column", borderStyle: "round", borderColor: overlay.kind === "remove" ? "yellow" : "cyan", children: overlay.kind === "create" ? (_jsx(CreateOverlayView, { overlay: overlay, onDescChange: onOverlayDescChange, onPromptChange: onOverlayPromptChange })) : (_jsx(RemoveOverlayView, { overlay: overlay })) })) : (_jsxs(Box, { flexGrow: 1, flexDirection: "row", children: [_jsx(Box, { width: listWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No open issues assigned to you in this repo." })) : (_jsxs(_Fragment, { children: [visibleRows.map((row, i) => {
997
- const key = rowStart + i;
998
- if (row.kind === "spacer")
999
- return _jsx(Text, { children: " " }, key);
1000
- if (row.kind === "project") {
1001
- return (_jsx(ProjectHeaderRow, { title: row.title, count: row.count, width: listContentWidth }, key));
1002
- }
1003
- if (row.kind === "status") {
1004
- return (_jsx(StatusHeaderRow, { name: row.name, color: row.color, count: row.count, width: listContentWidth }, key));
1005
- }
1006
- return (_jsx(IssueListRow, { d: row.d, selected: row.index === selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth }, key));
1007
- }), issuesAbove > 0 && _jsxs(Text, { dimColor: true, children: ["\u2191 ", issuesAbove, " more above"] }), issuesBelow > 0 && _jsxs(Text, { dimColor: true, children: ["\u2193 ", issuesBelow, " more below"] })] })) }), _jsx(Box, { width: detailWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: _jsx(DetailPane, { d: selected, contentWidth: detailWidth - 4, contentHeight: detailContentHeight, scrollOffset: state.detailScrollOffset }) })] })), _jsxs(Box, { paddingX: 1, flexDirection: "column", children: [toast && (_jsx(Box, { children: _jsxs(Text, { color: toast.kind === "success" ? "green" : toast.kind === "error" ? "red" : "cyan", children: [toast.kind === "success" ? "✓ " : toast.kind === "error" ? "✗ " : "· ", toast.text] }) })), refreshing && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " refreshing" })] })), _jsx(FooterRow, { phase: "ready", overlayKind: overlay?.kind, latestVersion: latestVersion })] })] }));
1065
+ return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Box, { paddingX: 1, paddingTop: 0, flexDirection: "column", children: _jsx(HeaderRow, { repoName: repoName, claudeVersion: claudeVersion, issueCount: issues.length, updateAvailable: latestVersion !== null }) }), overlay ? (_jsx(Box, { flexGrow: 1, flexDirection: "column", borderStyle: "round", borderColor: overlay.kind === "remove" ? "yellow" : "cyan", children: overlay.kind === "create" ? (_jsx(CreateOverlayView, { overlay: overlay, onDescChange: onOverlayDescChange, onPromptChange: onOverlayPromptChange })) : (_jsx(RemoveOverlayView, { overlay: overlay })) })) : (_jsxs(Box, { flexGrow: 1, flexDirection: "row", children: [_jsx(Box, { width: listWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No open issues assigned to you in this repo." })) : (_jsxs(_Fragment, { children: [listView.sticky.map((row, i) => (_jsx(ListRowView, { row: row, selectedIndex: selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth, width: listContentWidth }, `sticky-${i}`))), listView.issuesAbove > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2191 ", listView.issuesAbove, " more above"] })), listView.body.map((row, i) => (_jsx(ListRowView, { row: row, selectedIndex: selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth, width: listContentWidth }, `body-${i}`))), listView.issuesBelow > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2193 ", listView.issuesBelow, " more below"] }))] })) }), _jsx(Box, { width: detailWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: _jsx(DetailPane, { d: selected, contentWidth: detailWidth - 4, contentHeight: detailContentHeight, scrollOffset: state.detailScrollOffset }) })] })), _jsxs(Box, { paddingX: 1, flexDirection: "column", children: [toast && (_jsx(Box, { children: _jsxs(Text, { color: toast.kind === "success" ? "green" : toast.kind === "error" ? "red" : "cyan", children: [toast.kind === "success" ? "✓ " : toast.kind === "error" ? "✗ " : "· ", toast.text] }) })), refreshing && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " refreshing" })] })), _jsx(FooterRow, { phase: "ready", overlayKind: overlay?.kind, latestVersion: latestVersion })] })] }));
1008
1066
  }
package/dist/lib/git.d.ts CHANGED
@@ -43,6 +43,24 @@ export declare function ensureGitignoreEntries(repoRoot: string, entries: string
43
43
  export declare function getDefaultBranch(repoRoot: string): string | null;
44
44
  export type BranchExistence = "local" | "remote" | null;
45
45
  export declare function branchExists(repoRoot: string, branch: string): BranchExistence;
46
+ /**
47
+ * True when `origin/<branch>` resolves locally. Unlike `branchExists`, this
48
+ * reports the remote-tracking ref even when a local branch of the same name
49
+ * also exists — callers that want to fork from the freshest remote tip need
50
+ * to know the remote ref is there, not just "some ref named X".
51
+ */
52
+ export declare function remoteBranchExists(repoRoot: string, branch: string): boolean;
53
+ export type FetchResult = {
54
+ ok: boolean;
55
+ reason?: string;
56
+ };
57
+ /**
58
+ * Best-effort `git fetch origin` so worktrees get created off fresh refs
59
+ * instead of a stale local checkout. Never throws: when there's no `origin`
60
+ * remote or the network is down, returns `{ ok: false, reason }` and callers
61
+ * fall back to whatever refs are already local.
62
+ */
63
+ export declare function fetchRemote(repoRoot: string): FetchResult;
46
64
  /**
47
65
  * Returns the absolute path where `branch` is checked out as a worktree, or
48
66
  * null when the branch is not checked out anywhere. Parses the porcelain
package/dist/lib/git.js CHANGED
@@ -158,6 +158,38 @@ export function branchExists(repoRoot, branch) {
158
158
  return "remote";
159
159
  return null;
160
160
  }
161
+ /**
162
+ * True when `origin/<branch>` resolves locally. Unlike `branchExists`, this
163
+ * reports the remote-tracking ref even when a local branch of the same name
164
+ * also exists — callers that want to fork from the freshest remote tip need
165
+ * to know the remote ref is there, not just "some ref named X".
166
+ */
167
+ export function remoteBranchExists(repoRoot, branch) {
168
+ return trySh(`git rev-parse --verify --quiet "refs/remotes/origin/${branch}"`, repoRoot) !== null;
169
+ }
170
+ /**
171
+ * Best-effort `git fetch origin` so worktrees get created off fresh refs
172
+ * instead of a stale local checkout. Never throws: when there's no `origin`
173
+ * remote or the network is down, returns `{ ok: false, reason }` and callers
174
+ * fall back to whatever refs are already local.
175
+ */
176
+ export function fetchRemote(repoRoot) {
177
+ if (!trySh("git remote get-url origin", repoRoot)) {
178
+ return { ok: false, reason: "no origin remote" };
179
+ }
180
+ try {
181
+ execSync("git fetch origin", { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] });
182
+ return { ok: true };
183
+ }
184
+ catch (err) {
185
+ const stderr = err && typeof err === "object" && "stderr" in err
186
+ ? String(err.stderr).trim()
187
+ : err instanceof Error
188
+ ? err.message
189
+ : String(err);
190
+ return { ok: false, reason: stderr || "git fetch failed" };
191
+ }
192
+ }
161
193
  /**
162
194
  * Returns the absolute path where `branch` is checked out as a worktree, or
163
195
  * null when the branch is not checked out anywhere. Parses the porcelain
@@ -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, getCurrentBranch, branchExists, worktreeForBranch, addWorktree, pathExists, isExecutable, } from "./git.js";
6
+ import { findMainRepoRoot, getMintreeDir, getWorktreesDir, getInitScriptPath, getDefaultBranch, getCurrentBranch, branchExists, remoteBranchExists, fetchRemote, 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))
@@ -80,6 +80,19 @@ export function runCreate(branchArg, opts) {
80
80
  hint: "Remove it first or pick a different branch description.",
81
81
  };
82
82
  }
83
+ const steps = [];
84
+ steps.push({
85
+ kind: "ok",
86
+ label: "parsed branch",
87
+ detail: `type=${parsed.type}, issue=${parsed.issueId}, desc=${parsed.desc}`,
88
+ });
89
+ // Fetch before resolving refs so the worktree forks from fresh code, not a
90
+ // stale local checkout. Best-effort: offline / no-remote just warns and we
91
+ // fall back to whatever is already local.
92
+ const fetch = fetchRemote(root);
93
+ steps.push(fetch.ok
94
+ ? { kind: "ok", label: "fetched origin", detail: "refs up to date" }
95
+ : { kind: "warn", label: "skipped git fetch", detail: fetch.reason });
83
96
  const existence = branchExists(root, parsed.branch);
84
97
  let base;
85
98
  if (existence === null) {
@@ -99,14 +112,15 @@ export function runCreate(branchArg, opts) {
99
112
  };
100
113
  }
101
114
  }
102
- const steps = [];
103
- steps.push({
104
- kind: "ok",
105
- label: "parsed branch",
106
- detail: `type=${parsed.type}, issue=${parsed.issueId}, desc=${parsed.desc}`,
107
- });
115
+ // For a brand-new branch, fork from the freshly fetched `origin/<base>`
116
+ // tip when origin has it — that's the whole point of the fetch above.
117
+ // Without a successful fetch (or origin ref) we fork from the local base.
118
+ let baseRef = base;
119
+ if (existence === null && base && fetch.ok && remoteBranchExists(root, base)) {
120
+ baseRef = `origin/${base}`;
121
+ }
108
122
  try {
109
- addWorktree({ repoRoot: root, branch: parsed.branch, worktreePath, base });
123
+ addWorktree({ repoRoot: root, branch: parsed.branch, worktreePath, base: baseRef });
110
124
  }
111
125
  catch (err) {
112
126
  const stderr = err && typeof err === "object" && "stderr" in err
@@ -134,7 +148,7 @@ export function runCreate(branchArg, opts) {
134
148
  steps.push({
135
149
  kind: "ok",
136
150
  label: "created new branch",
137
- detail: `${parsed.branch} (from ${base})`,
151
+ detail: `${parsed.branch} (from ${baseRef})`,
138
152
  });
139
153
  }
140
154
  steps.push({ kind: "ok", label: "worktree created", detail: worktreePath });
@@ -243,8 +257,15 @@ export function runCreateDetached(opts) {
243
257
  label: "detached worktree",
244
258
  detail: `issue=${opts.issueId}, base=${currentBranch}`,
245
259
  });
260
+ // Fetch so the detached worktree forks from the fresh remote tip of the
261
+ // current branch instead of a stale local checkout. Best-effort.
262
+ const fetch = fetchRemote(root);
263
+ steps.push(fetch.ok
264
+ ? { kind: "ok", label: "fetched origin", detail: "refs up to date" }
265
+ : { kind: "warn", label: "skipped git fetch", detail: fetch.reason });
266
+ const baseRef = fetch.ok && remoteBranchExists(root, currentBranch) ? `origin/${currentBranch}` : currentBranch;
246
267
  try {
247
- execSync(`git worktree add --detach '${worktreePath.replace(/'/g, `'\\''`)}' '${currentBranch.replace(/'/g, `'\\''`)}'`, { cwd: root, stdio: ["ignore", "pipe", "pipe"] });
268
+ execSync(`git worktree add --detach '${worktreePath.replace(/'/g, `'\\''`)}' '${baseRef.replace(/'/g, `'\\''`)}'`, { cwd: root, stdio: ["ignore", "pipe", "pipe"] });
248
269
  }
249
270
  catch (err) {
250
271
  const stderr = err && typeof err === "object" && "stderr" in err
@@ -257,7 +278,7 @@ export function runCreateDetached(opts) {
257
278
  steps.push({
258
279
  kind: "ok",
259
280
  label: "checked out detached HEAD",
260
- detail: `at tip of ${currentBranch}`,
281
+ detail: `at tip of ${baseRef}`,
261
282
  });
262
283
  steps.push({ kind: "ok", label: "worktree created", detail: worktreePath });
263
284
  upsertIssue(root, opts.issueId, { base_branch: currentBranch });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mintree",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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>",