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.
- package/dist/commands/dashboard.js +81 -23
- package/dist/lib/git.d.ts +18 -0
- package/dist/lib/git.js +32 -0
- package/dist/lib/worktreeCreate.js +32 -11
- package/package.json +1 -1
|
@@ -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
|
|
984
|
-
//
|
|
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
|
|
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: [
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 ${
|
|
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, `'\\''`)}' '${
|
|
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 ${
|
|
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