santree 0.5.1 → 0.5.2

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.
@@ -9,7 +9,7 @@ import * as fs from "fs";
9
9
  import * as path from "path";
10
10
  const require = createRequire(import.meta.url);
11
11
  const { version } = require("../../package.json");
12
- import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasInitScript, getInitScriptPath, removeWorktree, getDiffTool, } from "../lib/git.js";
12
+ import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasInitScript, getInitScriptPath, removeWorktree, getDiffTool, getWorktreeStatus, stageFile, unstageFile, stageAll, unstageAll, discardFile, } from "../lib/git.js";
13
13
  import { run, spawnAsync } from "../lib/exec.js";
14
14
  import { resolveAgentBinary } from "../lib/ai.js";
15
15
  import { getInstalledClaudeVersion } from "../lib/version.js";
@@ -200,7 +200,7 @@ function CommandBar({ showWorkspace, mode = "default", }) {
200
200
  const dot = _jsx(Text, { dimColor: true, children: " · " });
201
201
  const Key = ({ k }) => (_jsx(Text, { color: "cyan", bold: true, children: k }));
202
202
  if (mode === "diff") {
203
- return (_jsxs(Text, { children: [_jsx(Key, { k: "j/k" }), _jsx(Text, { dimColor: true, children: " file" }), dot, _jsx(Key, { k: "\u21E7\u2191\u2193" }), _jsx(Text, { dimColor: true, children: " scroll" }), dot, _jsx(Key, { k: "g/G" }), _jsx(Text, { dimColor: true, children: " top/bot" }), dot, _jsx(Key, { k: "q" }), _jsx(Text, { dimColor: true, children: " close" })] }));
203
+ return (_jsxs(Text, { children: [_jsx(Key, { k: "j/k" }), _jsx(Text, { dimColor: true, children: " file" }), dot, _jsx(Key, { k: "\u21E7\u2191\u2193" }), _jsx(Text, { dimColor: true, children: " scroll" }), dot, _jsx(Key, { k: "\u2423" }), _jsx(Text, { dimColor: true, children: " stage" }), dot, _jsx(Key, { k: "a" }), _jsx(Text, { dimColor: true, children: " all" }), dot, _jsx(Key, { k: "d" }), _jsx(Text, { dimColor: true, children: " discard" }), dot, _jsx(Key, { k: "e" }), _jsx(Text, { dimColor: true, children: " edit" }), dot, _jsx(Key, { k: "q" }), _jsx(Text, { dimColor: true, children: " close" })] }));
204
204
  }
205
205
  return (_jsxs(Text, { children: [_jsx(Key, { k: "j/k" }), _jsx(Text, { dimColor: true, children: " nav" }), dot, _jsx(Key, { k: "\u21E7\u2191\u2193" }), _jsx(Text, { dimColor: true, children: " scroll" }), dot, _jsx(Key, { k: "1/2" }), _jsx(Text, { dimColor: true, children: " tabs" }), showWorkspace ? (_jsxs(_Fragment, { children: [dot, _jsx(Key, { k: "E" }), _jsx(Text, { dimColor: true, children: " workspace" })] })) : null, dot, _jsx(Key, { k: "R" }), _jsx(Text, { dimColor: true, children: " refresh" }), dot, _jsx(Key, { k: "q" }), _jsx(Text, { dimColor: true, children: " quit" })] }));
206
206
  }
@@ -519,8 +519,16 @@ export default function Dashboard() {
519
519
  await refresh(true);
520
520
  };
521
521
  init();
522
- // Auto-refresh every 30s
523
- refreshTimerRef.current = setInterval(() => refresh(), 30_000);
522
+ // Auto-refresh every 30s. While the diff overlay is open, also bump
523
+ // the diff refresh tick so new/removed files (created or deleted
524
+ // outside the dashboard) eventually show up. Stage/unstage already
525
+ // patch XY in place, so this is purely about file-set drift.
526
+ refreshTimerRef.current = setInterval(() => {
527
+ refresh();
528
+ if (stateRef.current.overlay === "diff") {
529
+ dispatch({ type: "DIFF_REFRESH_FILES" });
530
+ }
531
+ }, 30_000);
524
532
  return () => {
525
533
  if (refreshTimerRef.current)
526
534
  clearInterval(refreshTimerRef.current);
@@ -586,17 +594,49 @@ export default function Dashboard() {
586
594
  useEffect(() => {
587
595
  if (state.overlay !== "diff" || !state.diffWorktreePath || !state.diffBaseBranch)
588
596
  return;
589
- if (!state.diffLoadingFiles)
590
- return;
591
597
  const cwd = state.diffWorktreePath;
592
598
  const base = state.diffBaseBranch;
593
599
  void (async () => {
594
600
  try {
595
601
  const { stdout: mergeBaseOut } = await execAsync(`git -C "${cwd}" merge-base "${base}" HEAD`);
596
602
  const mergeBase = mergeBaseOut.trim() || base;
597
- const { stdout } = await execAsync(`git -C "${cwd}" diff --name-status "${mergeBase}"`);
603
+ const [{ stdout }, porcelain] = await Promise.all([
604
+ execAsync(`git -C "${cwd}" diff --name-status "${mergeBase}"`),
605
+ getWorktreeStatus(cwd).catch(() => []),
606
+ ]);
598
607
  const files = parseNameStatus(stdout);
599
- const ordered = flattenTreeFiles(files);
608
+ // Merge porcelain (working-tree state) into the merge-base file list.
609
+ // XY status drives stage/unstage UX; untracked files (`??`) only show
610
+ // up here since `git diff` ignores them.
611
+ const porcelainByPath = new Map();
612
+ for (const p of porcelain)
613
+ porcelainByPath.set(p.path, p);
614
+ const enriched = files.map((f) => {
615
+ const p = porcelainByPath.get(f.path);
616
+ if (!p)
617
+ return f;
618
+ porcelainByPath.delete(f.path);
619
+ return {
620
+ ...f,
621
+ indexStatus: p.index,
622
+ workingStatus: p.working,
623
+ isUntracked: p.index === "?" && p.working === "?",
624
+ };
625
+ });
626
+ // Untracked entries left over → add as new DiffFile rows so they
627
+ // appear in the tree and can be staged.
628
+ for (const p of porcelainByPath.values()) {
629
+ if (p.index === "?" && p.working === "?") {
630
+ enriched.push({
631
+ path: p.path,
632
+ status: "?",
633
+ indexStatus: p.index,
634
+ workingStatus: p.working,
635
+ isUntracked: true,
636
+ });
637
+ }
638
+ }
639
+ const ordered = flattenTreeFiles(enriched);
600
640
  dispatch({ type: "DIFF_FILES_LOADED", files: ordered, mergeBase });
601
641
  }
602
642
  catch (err) {
@@ -604,7 +644,7 @@ export default function Dashboard() {
604
644
  dispatch({ type: "DIFF_FILES_ERROR", error: msg });
605
645
  }
606
646
  })();
607
- }, [state.overlay, state.diffWorktreePath, state.diffBaseBranch, state.diffLoadingFiles]);
647
+ }, [state.overlay, state.diffWorktreePath, state.diffBaseBranch, state.diffRefreshTick]);
608
648
  // ── Diff overlay: load content for selected file ──────────────────
609
649
  // If SANTREE_DIFF_TOOL is set, pipe `git diff` output through the tool so
610
650
  // the user's preferred renderer (delta, diff-so-fancy, etc.) handles
@@ -621,7 +661,15 @@ export default function Dashboard() {
621
661
  dispatch({ type: "DIFF_CONTENT_LOADING" });
622
662
  void (async () => {
623
663
  try {
624
- if (tool) {
664
+ if (file.isUntracked) {
665
+ // Untracked files aren't in `git diff` output — fake a "full
666
+ // addition" diff via --no-index against /dev/null. git exits 1
667
+ // when files differ; that's expected, so capture stdout via
668
+ // spawnAsync rather than execAsync (which throws on non-zero).
669
+ const { output } = await spawnAsync("git", ["-C", cwd, "diff", "--no-color", "--no-index", "--", "/dev/null", file.path], { cwd });
670
+ dispatch({ type: "DIFF_CONTENT_LOADED", content: output });
671
+ }
672
+ else if (tool) {
625
673
  // Pipe git diff (with colors enabled so the tool can pass them
626
674
  // through if desired) into the configured tool. Use spawn pipes
627
675
  // rather than shell to avoid quoting concerns.
@@ -1307,6 +1355,42 @@ export default function Dashboard() {
1307
1355
  }
1308
1356
  // Diff overlay
1309
1357
  if (state.overlay === "diff") {
1358
+ // Pending discard modal — intercepts y/n/ESC/q so they don't
1359
+ // also close the diff overlay.
1360
+ if (state.diffPendingDiscard) {
1361
+ const pd = state.diffPendingDiscard;
1362
+ if (input === "y") {
1363
+ const cwd = state.diffWorktreePath;
1364
+ if (!cwd) {
1365
+ dispatch({ type: "DIFF_DISCARD_CANCEL" });
1366
+ return;
1367
+ }
1368
+ (async () => {
1369
+ try {
1370
+ await discardFile(cwd, pd.path, pd.isUntracked);
1371
+ dispatch({ type: "DIFF_DISCARD_CANCEL" });
1372
+ dispatch({ type: "DIFF_REFRESH_FILES" });
1373
+ dispatch({
1374
+ type: "SET_ACTION_MESSAGE",
1375
+ message: pd.isUntracked
1376
+ ? `Deleted ${pd.path}`
1377
+ : `Discarded changes in ${pd.path}`,
1378
+ });
1379
+ }
1380
+ catch (err) {
1381
+ const msg = err instanceof Error ? err.message : String(err);
1382
+ dispatch({ type: "DIFF_DISCARD_CANCEL" });
1383
+ dispatch({ type: "SET_ACTION_MESSAGE", message: `Discard failed: ${msg}` });
1384
+ }
1385
+ })();
1386
+ return;
1387
+ }
1388
+ if (input === "n" || key.escape || input === "q") {
1389
+ dispatch({ type: "DIFF_DISCARD_CANCEL" });
1390
+ return;
1391
+ }
1392
+ return;
1393
+ }
1310
1394
  if (key.escape || input === "q") {
1311
1395
  dispatch({ type: "DIFF_CLOSE" });
1312
1396
  return;
@@ -1362,6 +1446,98 @@ export default function Dashboard() {
1362
1446
  dispatch({ type: "DIFF_FILE_SELECT", index: prev });
1363
1447
  return;
1364
1448
  }
1449
+ // Stage / unstage / discard — only meaningful when the worktree
1450
+ // path is known. All ops dispatch DIFF_REFRESH_FILES so the
1451
+ // porcelain status (and selection) updates immediately.
1452
+ const cwd = state.diffWorktreePath;
1453
+ const currentFile = state.diffFiles[state.diffFileIndex];
1454
+ if (input === " " && cwd && currentFile) {
1455
+ // Toggle: if anything is staged for this file, unstage it;
1456
+ // otherwise stage. Files with no uncommitted state (only
1457
+ // committed changes vs base) have no XY → no-op. Updates XY
1458
+ // in place via porcelain re-fetch — no full reload, no spinner.
1459
+ const xRaw = currentFile.indexStatus;
1460
+ const yRaw = currentFile.workingStatus;
1461
+ if (xRaw === undefined && yRaw === undefined) {
1462
+ dispatch({
1463
+ type: "SET_ACTION_MESSAGE",
1464
+ message: "No uncommitted changes to stage on this file",
1465
+ });
1466
+ return;
1467
+ }
1468
+ const x = xRaw ?? " ";
1469
+ const isStaged = x !== " " && x !== "?";
1470
+ const path = currentFile.path;
1471
+ (async () => {
1472
+ try {
1473
+ if (isStaged)
1474
+ await unstageFile(cwd, path);
1475
+ else
1476
+ await stageFile(cwd, path);
1477
+ const porcelain = await getWorktreeStatus(cwd);
1478
+ dispatch({ type: "DIFF_STATUS_UPDATED", porcelain });
1479
+ }
1480
+ catch (err) {
1481
+ const msg = err instanceof Error ? err.message : String(err);
1482
+ dispatch({
1483
+ type: "SET_ACTION_MESSAGE",
1484
+ message: `${isStaged ? "Unstage" : "Stage"} failed: ${msg}`,
1485
+ });
1486
+ }
1487
+ })();
1488
+ return;
1489
+ }
1490
+ if (input === "a" && cwd) {
1491
+ // Stage-all if anything is unstaged or untracked; otherwise
1492
+ // unstage everything. Untracked files have Y === "?" so they
1493
+ // fall under "unstaged" — staging them adds them to the index.
1494
+ // Same in-place porcelain refresh as `space`.
1495
+ const anyUnstaged = state.diffFiles.some((f) => {
1496
+ const y = f.workingStatus;
1497
+ return y !== undefined && y !== " ";
1498
+ });
1499
+ (async () => {
1500
+ try {
1501
+ if (anyUnstaged)
1502
+ await stageAll(cwd);
1503
+ else
1504
+ await unstageAll(cwd);
1505
+ const porcelain = await getWorktreeStatus(cwd);
1506
+ dispatch({ type: "DIFF_STATUS_UPDATED", porcelain });
1507
+ dispatch({
1508
+ type: "SET_ACTION_MESSAGE",
1509
+ message: anyUnstaged ? "Staged all changes" : "Unstaged all changes",
1510
+ });
1511
+ }
1512
+ catch (err) {
1513
+ const msg = err instanceof Error ? err.message : String(err);
1514
+ dispatch({ type: "SET_ACTION_MESSAGE", message: `Failed: ${msg}` });
1515
+ }
1516
+ })();
1517
+ return;
1518
+ }
1519
+ if (input === "d" && currentFile) {
1520
+ if (currentFile.indexStatus === undefined && currentFile.workingStatus === undefined) {
1521
+ dispatch({
1522
+ type: "SET_ACTION_MESSAGE",
1523
+ message: "No uncommitted changes to discard",
1524
+ });
1525
+ return;
1526
+ }
1527
+ dispatch({
1528
+ type: "DIFF_DISCARD_OPEN",
1529
+ path: currentFile.path,
1530
+ isUntracked: !!currentFile.isUntracked,
1531
+ });
1532
+ return;
1533
+ }
1534
+ // Open the selected file in the user's editor — useful when
1535
+ // the diff alone isn't enough context. Editor resolution
1536
+ // matches the rest of santree (SANTREE_EDITOR > "code").
1537
+ if (input === "e" && cwd && currentFile) {
1538
+ openInEditor(path.join(cwd, currentFile.path));
1539
+ return;
1540
+ }
1365
1541
  return;
1366
1542
  }
1367
1543
  // Confirm delete overlay
@@ -1884,10 +2060,10 @@ export default function Dashboard() {
1884
2060
  const defaultBranch = getDefaultBranch();
1885
2061
  const label = branch === defaultBranch ? `${branch} (default)` : branch;
1886
2062
  return (_jsx(Text, { children: _jsxs(Text, { color: selected ? "cyan" : undefined, bold: selected, children: [selected ? "> " : " ", label] }) }, branch));
1887
- }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "j/k to navigate, Enter to select, ESC to cancel" })] }) })) : state.overlay === "confirm-delete" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Remove worktree?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: selectedIssue?.worktree?.branch ?? "" }), selectedIssue?.worktree?.dirty && (_jsx(Text, { color: "yellow", children: "Warning: worktree has uncommitted changes" })), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "red", bold: true, children: "y" }), " Confirm"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "n" }), " Cancel"] })] }) })) : state.overlay === "diff" ? (_jsx(DiffOverlay, { width: innerWidth, height: contentHeight, ticketId: state.diffTicketId ?? "", baseBranch: state.diffBaseBranch ?? "", files: state.diffFiles, fileIndex: state.diffFileIndex, fileScrollOffset: state.diffFileScrollOffset, content: state.diffContent, contentScrollOffset: state.diffContentScrollOffset, loadingFiles: state.diffLoadingFiles, loadingContent: state.diffLoadingContent, error: state.diffError, selectionBg: theme.selectionBg, leftWidthOverride: diffLeftWidth ?? undefined })) : state.overlay === "confirm-setup" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Run setup script?" }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: ".santree/init.sh" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " Run setup"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", bold: true, children: "n" }), " Skip"] })] }) })) : (_jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: leftWidth, children: state.activeTab === "reviews" ? (_jsx(ReviewList, { flatReviews: state.flatReviews, selectedIndex: state.reviewSelectedIndex, scrollOffset: state.reviewListScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg })) : state.flatIssues.length === 0 ? (_jsx(Box, { width: leftWidth, height: contentHeight, justifyContent: "center", alignItems: "center", children: _jsx(Text, { color: "yellow", children: "No active issues" }) })) : (_jsx(IssueList, { groups: state.groups, flatIssues: state.flatIssues, selectedIndex: state.selectedIndex, scrollOffset: state.listScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg })) }), _jsx(Box, { flexDirection: "column", width: 3, children: Array.from({ length: contentHeight }).map((_, i) => (_jsx(Text, { dimColor: true, children: " │ " }, i))) }), _jsx(Box, { width: rightWidth, children: state.activeTab === "reviews" && state.creatingForTicket ? (_jsxs(Box, { flexDirection: "column", width: rightWidth, height: contentHeight, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Setting up worktree for ", state.creatingForTicket, "..."] }), state.creationLogs
2063
+ }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "j/k to navigate, Enter to select, ESC to cancel" })] }) })) : state.overlay === "confirm-delete" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Remove worktree?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: selectedIssue?.worktree?.branch ?? "" }), selectedIssue?.worktree?.dirty && (_jsx(Text, { color: "yellow", children: "Warning: worktree has uncommitted changes" })), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "red", bold: true, children: "y" }), " Confirm"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "n" }), " Cancel"] })] }) })) : state.overlay === "diff" ? (_jsx(DiffOverlay, { width: innerWidth, height: contentHeight, ticketId: state.diffTicketId ?? "", baseBranch: state.diffBaseBranch ?? "", files: state.diffFiles, fileIndex: state.diffFileIndex, fileScrollOffset: state.diffFileScrollOffset, content: state.diffContent, contentScrollOffset: state.diffContentScrollOffset, loadingFiles: state.diffLoadingFiles, loadingContent: state.diffLoadingContent, error: state.diffError, selectionBg: theme.selectionBg, leftWidthOverride: diffLeftWidth ?? undefined, pendingDiscard: state.diffPendingDiscard })) : state.overlay === "confirm-setup" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Run setup script?" }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: ".santree/init.sh" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " Run setup"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", bold: true, children: "n" }), " Skip"] })] }) })) : (_jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: leftWidth, children: state.activeTab === "reviews" ? (_jsx(ReviewList, { flatReviews: state.flatReviews, selectedIndex: state.reviewSelectedIndex, scrollOffset: state.reviewListScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg })) : state.flatIssues.length === 0 ? (_jsx(Box, { width: leftWidth, height: contentHeight, justifyContent: "center", alignItems: "center", children: _jsx(Text, { color: "yellow", children: "No active issues" }) })) : (_jsx(IssueList, { groups: state.groups, flatIssues: state.flatIssues, selectedIndex: state.selectedIndex, scrollOffset: state.listScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg })) }), _jsx(Box, { flexDirection: "column", width: 3, children: Array.from({ length: contentHeight }).map((_, i) => (_jsx(Text, { dimColor: true, children: " │ " }, i))) }), _jsx(Box, { width: rightWidth, children: state.activeTab === "reviews" && state.creatingForTicket ? (_jsxs(Box, { flexDirection: "column", width: rightWidth, height: contentHeight, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Setting up worktree for ", state.creatingForTicket, "..."] }), state.creationLogs
1888
2064
  .split("\n")
1889
2065
  .slice(-(contentHeight - 1))
1890
- .map((line, i) => (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: line }) }, i)))] })) : state.activeTab === "reviews" ? (_jsx(ReviewDetailPanel, { item: selectedReview, scrollOffset: state.reviewDetailScrollOffset, height: contentHeight, width: rightWidth })) : state.overlay === "commit" ? (_jsx(CommitOverlay, { width: rightWidth, height: contentHeight, branch: state.commitBranch, ticketId: state.commitTicketId, gitStatus: state.commitGitStatus, phase: state.commitPhase, message: state.commitMessage, error: state.commitError, dispatch: dispatch, onSubmit: handleCommitSubmit })) : state.overlay === "pr-create" ? (_jsx(PrCreateOverlay, { width: rightWidth, height: contentHeight, branch: state.prCreateBranch, ticketId: state.prCreateTicketId, phase: state.prCreatePhase, error: state.prCreateError, url: state.prCreateUrl, body: state.prCreateBody, title: state.prCreateTitle, dispatch: dispatch })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] })), _jsxs(Box, { children: [_jsx(Box, { width: leftWidth + separatorWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: state.overlay === "diff" ? "diff" : "default" }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview, overlay: state.overlay }) })] })] })] }));
2066
+ .map((line, i) => (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: line }) }, i)))] })) : state.activeTab === "reviews" ? (_jsx(ReviewDetailPanel, { item: selectedReview, scrollOffset: state.reviewDetailScrollOffset, height: contentHeight, width: rightWidth })) : state.overlay === "commit" ? (_jsx(CommitOverlay, { width: rightWidth, height: contentHeight, branch: state.commitBranch, ticketId: state.commitTicketId, gitStatus: state.commitGitStatus, phase: state.commitPhase, message: state.commitMessage, error: state.commitError, dispatch: dispatch, onSubmit: handleCommitSubmit })) : state.overlay === "pr-create" ? (_jsx(PrCreateOverlay, { width: rightWidth, height: contentHeight, branch: state.prCreateBranch, ticketId: state.prCreateTicketId, phase: state.prCreatePhase, error: state.prCreateError, url: state.prCreateUrl, body: state.prCreateBody, title: state.prCreateTitle, dispatch: dispatch })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] })), _jsx(Box, { children: state.overlay === "diff" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "diff" }) })) : (_jsxs(_Fragment, { children: [_jsx(Box, { width: leftWidth + separatorWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "default" }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview, overlay: state.overlay }) })] })) })] })] }));
1891
2067
  }
1892
2068
  /**
1893
2069
  * Renders the per-issue action key hints (Resume / Editor / View diff / …)
@@ -19,6 +19,14 @@ interface Props {
19
19
  * formula when undefined. Always clamped against pane minimums.
20
20
  */
21
21
  leftWidthOverride?: number;
22
+ /**
23
+ * When non-null, render a confirmation modal over the body asking the user
24
+ * to confirm discarding tracked changes or deleting an untracked file.
25
+ */
26
+ pendingDiscard?: {
27
+ path: string;
28
+ isUntracked: boolean;
29
+ } | null;
22
30
  }
23
31
  interface RenderedRow {
24
32
  prefix: string;
@@ -27,6 +35,10 @@ interface RenderedRow {
27
35
  dim?: boolean;
28
36
  bold?: boolean;
29
37
  fileIndex: number | null;
38
+ xy?: {
39
+ index: string;
40
+ working: string;
41
+ };
30
42
  }
31
43
  export declare function flattenTreeFiles(files: DiffFile[]): DiffFile[];
32
44
  export interface DiffLayout {
@@ -57,5 +69,5 @@ export declare function computeDiffLayout(opts: {
57
69
  fileScrollOffset: number;
58
70
  leftWidthOverride?: number;
59
71
  }): DiffLayout;
60
- export default function DiffOverlay({ width, height, ticketId, baseBranch, files, fileIndex, fileScrollOffset, content, contentScrollOffset, loadingFiles, loadingContent, error, selectionBg, leftWidthOverride, }: Props): import("react/jsx-runtime").JSX.Element;
72
+ export default function DiffOverlay({ width, height, ticketId, baseBranch, files, fileIndex, fileScrollOffset, content, contentScrollOffset, loadingFiles, loadingContent, error, selectionBg, leftWidthOverride, pendingDiscard, }: Props): import("react/jsx-runtime").JSX.Element;
61
73
  export {};
@@ -75,12 +75,37 @@ function renderTree(dir, depth, rows, fileCounter) {
75
75
  }
76
76
  else {
77
77
  const idx = fileCounter.value++;
78
- rows.push({
79
- prefix: indent,
80
- label: `${child.file.status} ${child.name}`,
81
- color: statusColor(child.file.status),
82
- fileIndex: idx,
83
- });
78
+ const file = child.file;
79
+ const hasUncommitted = file.indexStatus !== undefined || file.workingStatus !== undefined;
80
+ if (hasUncommitted) {
81
+ // Lazygit-style XY display — XY conveys the staged/unstaged state,
82
+ // so we drop the merge-base status letter from the label to avoid
83
+ // redundant "M M foo.ts"-style rows. The XY chars are colored at
84
+ // render time (green for index, red for working).
85
+ rows.push({
86
+ prefix: indent,
87
+ label: child.name,
88
+ fileIndex: idx,
89
+ xy: {
90
+ index: file.indexStatus ?? " ",
91
+ working: file.workingStatus ?? " ",
92
+ },
93
+ });
94
+ }
95
+ else {
96
+ // Committed-only files (no working-tree state vs HEAD). Dimmed
97
+ // so the user can tell at a glance that stage/unstage/discard
98
+ // don't apply — only files showing a colored XY column are
99
+ // actionable. The merge-base status letter still tells the
100
+ // reviewer what changed vs base.
101
+ rows.push({
102
+ prefix: indent,
103
+ label: `${file.status} ${child.name}`,
104
+ color: statusColor(file.status),
105
+ dim: true,
106
+ fileIndex: idx,
107
+ });
108
+ }
84
109
  }
85
110
  }
86
111
  }
@@ -206,7 +231,7 @@ function truncateVisible(s, max) {
206
231
  return out + "…";
207
232
  }
208
233
  // ── Component ─────────────────────────────────────────────────────────
209
- export default function DiffOverlay({ width, height, ticketId, baseBranch, files, fileIndex, fileScrollOffset, content, contentScrollOffset, loadingFiles, loadingContent, error, selectionBg = "#1e3a5f", leftWidthOverride, }) {
234
+ export default function DiffOverlay({ width, height, ticketId, baseBranch, files, fileIndex, fileScrollOffset, content, contentScrollOffset, loadingFiles, loadingContent, error, selectionBg = "#1e3a5f", leftWidthOverride, pendingDiscard = null, }) {
210
235
  const layout = computeDiffLayout({
211
236
  width,
212
237
  height,
@@ -245,18 +270,30 @@ export default function DiffOverlay({ width, height, ticketId, baseBranch, files
245
270
  const p = currentFile.path;
246
271
  truncatedPath = p.length > pathRoom ? "…" + p.slice(-Math.max(0, pathRoom - 1)) : p;
247
272
  }
248
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, overflow: "hidden", children: [_jsxs(Box, { flexShrink: 0, width: width, children: [_jsx(Text, { bold: true, color: "cyan", children: "Diff" }), _jsx(Text, { dimColor: true, children: meta }), currentFile && pathRoom > 0 && _jsx(Text, { dimColor: true, children: sep }), currentFile && pathRoom > 0 && _jsx(Text, { children: truncatedPath })] }), _jsx(Box, { flexShrink: 0, width: width, children: _jsx(Text, { dimColor: true, wrap: "truncate", children: "─".repeat(width) }) }), _jsxs(Box, { height: bodyHeight, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", width: leftWidth, height: bodyHeight, overflow: "hidden", paddingRight: 1, children: loadingFiles ? (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " Loading files..." })] })) : error ? (_jsx(Text, { color: "red", children: error })) : files.length === 0 ? (_jsx(Text, { dimColor: true, children: "No changes" })) : (visibleRows.map((row, i) => {
273
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, overflow: "hidden", children: [_jsxs(Box, { flexShrink: 0, width: width, children: [_jsx(Text, { bold: true, color: "cyan", children: "Diff" }), _jsx(Text, { dimColor: true, children: meta }), currentFile && pathRoom > 0 && _jsx(Text, { dimColor: true, children: sep }), currentFile && pathRoom > 0 && _jsx(Text, { children: truncatedPath })] }), _jsx(Box, { flexShrink: 0, width: width, children: _jsx(Text, { dimColor: true, wrap: "truncate", children: "─".repeat(width) }) }), pendingDiscard ? (_jsx(Box, { height: bodyHeight, flexShrink: 0, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, color: "red", children: pendingDiscard.isUntracked ? "Delete file?" : "Discard changes?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: pendingDiscard.path }), pendingDiscard.isUntracked && (_jsx(Text, { color: "yellow", children: "Warning: untracked file will be permanently deleted" })), !pendingDiscard.isUntracked && (_jsx(Text, { color: "yellow", children: "Warning: uncommitted changes will be lost" })), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "red", bold: true, children: "y" }), " Confirm"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "n" }), " Cancel"] })] }) })) : (_jsxs(Box, { height: bodyHeight, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", width: leftWidth, height: bodyHeight, overflow: "hidden", paddingRight: 1, children: loadingFiles ? (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " Loading files..." })] })) : error ? (_jsx(Text, { color: "red", children: error })) : files.length === 0 ? (_jsx(Text, { dimColor: true, children: "No changes" })) : (visibleRows.map((row, i) => {
249
274
  const absIdx = effectiveScroll + i;
250
275
  const isSelected = absIdx === selectedRowIdx;
276
+ const bg = isSelected ? selectionBg : undefined;
277
+ // Lazygit-style XY rendering — index char (green) + working
278
+ // char (red), then a separator and the file name. Each
279
+ // nested <Text> gets the same backgroundColor so the
280
+ // selection highlight covers the whole row uniformly.
281
+ if (row.xy) {
282
+ const x = row.xy.index || " ";
283
+ const y = row.xy.working || " ";
284
+ const xColor = x.trim() ? "green" : "gray";
285
+ const yColor = y.trim() ? "red" : "gray";
286
+ return (_jsxs(Text, { backgroundColor: bg, bold: row.bold || isSelected, wrap: "truncate", children: [_jsx(Text, { backgroundColor: bg, children: row.prefix }), _jsx(Text, { color: xColor, backgroundColor: bg, bold: true, children: x }), _jsx(Text, { color: yColor, backgroundColor: bg, bold: true, children: y }), _jsx(Text, { backgroundColor: bg, children: ` ${row.label}` })] }, i));
287
+ }
251
288
  const text = `${row.prefix}${row.label}`;
252
289
  // Selected row keeps its own color (file-status hue or directory
253
290
  // blue) but gets the theme-aware selection bg + bold so it stays
254
291
  // readable in light and dark modes alike.
255
- return (_jsx(Text, { color: row.color, backgroundColor: isSelected ? selectionBg : undefined, bold: row.bold || isSelected, dimColor: row.dim, wrap: "truncate", children: text }, i));
292
+ return (_jsx(Text, { color: row.color, backgroundColor: bg, bold: row.bold || isSelected, dimColor: row.dim, wrap: "truncate", children: text }, i));
256
293
  })) }), _jsx(Box, { flexDirection: "column", height: bodyHeight, children: Array.from({ length: bodyHeight }).map((_, i) => (_jsx(Text, { dimColor: true, children: "\u2502" }, i))) }), _jsx(Box, { flexDirection: "column", width: rightWidth, height: bodyHeight, overflow: "hidden", paddingLeft: 1, children: loadingContent ? (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " Loading diff..." })] })) : !currentFile ? (_jsx(Text, { dimColor: true, children: "Select a file" })) : visibleLines.length === 0 ? (_jsx(Text, { dimColor: true, children: "(empty diff)" })) : (visibleLines.map((line, i) => {
257
294
  // rightWidth includes the paddingLeft={1} of the wrapper Box,
258
295
  // so usable column count is rightWidth - 1.
259
296
  const cell = truncateVisible(line.text || " ", Math.max(1, rightWidth - 1));
260
297
  return (_jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, children: cell }, i));
261
- })) })] })] }));
298
+ })) })] }))] }));
262
299
  }
@@ -69,6 +69,9 @@ export interface DiffFile {
69
69
  path: string;
70
70
  status: DiffFileStatus;
71
71
  oldPath?: string;
72
+ indexStatus?: string;
73
+ workingStatus?: string;
74
+ isUntracked?: boolean;
72
75
  }
73
76
  export type CommitPhase = "idle" | "confirm-stage" | "awaiting-message" | "committing" | "pushing" | "done" | "error";
74
77
  export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "filling" | "review" | "confirm" | "creating" | "done" | "error";
@@ -126,6 +129,11 @@ export interface DashboardState {
126
129
  diffLoadingFiles: boolean;
127
130
  diffLoadingContent: boolean;
128
131
  diffError: string | null;
132
+ diffPendingDiscard: {
133
+ path: string;
134
+ isUntracked: boolean;
135
+ } | null;
136
+ diffRefreshTick: number;
129
137
  }
130
138
  export type DashboardAction = {
131
139
  type: "SET_DATA";
@@ -286,6 +294,21 @@ export type DashboardAction = {
286
294
  } | {
287
295
  type: "DIFF_CONTENT_SCROLL";
288
296
  offset: number;
297
+ } | {
298
+ type: "DIFF_REFRESH_FILES";
299
+ } | {
300
+ type: "DIFF_STATUS_UPDATED";
301
+ porcelain: {
302
+ path: string;
303
+ index: string;
304
+ working: string;
305
+ }[];
306
+ } | {
307
+ type: "DIFF_DISCARD_OPEN";
308
+ path: string;
309
+ isUntracked: boolean;
310
+ } | {
311
+ type: "DIFF_DISCARD_CANCEL";
289
312
  } | {
290
313
  type: "DIFF_CLOSE";
291
314
  };
@@ -53,6 +53,8 @@ export const initialState = {
53
53
  diffLoadingFiles: false,
54
54
  diffLoadingContent: false,
55
55
  diffError: null,
56
+ diffPendingDiscard: null,
57
+ diffRefreshTick: 0,
56
58
  };
57
59
  export function reducer(state, action) {
58
60
  switch (action.type) {
@@ -301,17 +303,73 @@ export function reducer(state, action) {
301
303
  diffLoadingFiles: true,
302
304
  diffLoadingContent: false,
303
305
  diffError: null,
306
+ diffPendingDiscard: null,
307
+ diffRefreshTick: 0,
304
308
  };
305
- case "DIFF_FILES_LOADED":
309
+ case "DIFF_FILES_LOADED": {
310
+ // Preserve the user's selection across reloads (after stage/unstage/
311
+ // discard) by matching the previously-selected file's path. Falls back
312
+ // to the clamped index when the path is gone (e.g. file was discarded).
313
+ const prevPath = state.diffFiles[state.diffFileIndex]?.path;
314
+ let newIndex = 0;
315
+ if (prevPath) {
316
+ const found = action.files.findIndex((f) => f.path === prevPath);
317
+ if (found >= 0)
318
+ newIndex = found;
319
+ else
320
+ newIndex = Math.min(state.diffFileIndex, Math.max(0, action.files.length - 1));
321
+ }
306
322
  return {
307
323
  ...state,
308
324
  diffFiles: action.files,
309
325
  diffMergeBase: action.mergeBase,
310
- diffFileIndex: 0,
311
- diffFileScrollOffset: 0,
326
+ diffFileIndex: newIndex,
312
327
  diffLoadingFiles: false,
313
328
  diffError: null,
314
329
  };
330
+ }
331
+ case "DIFF_REFRESH_FILES":
332
+ // Silent re-fetch — bumps the tick the loader effect depends on,
333
+ // without flipping diffLoadingFiles. The current file list stays
334
+ // rendered until the new one arrives, so there's no spinner blink.
335
+ return { ...state, diffRefreshTick: state.diffRefreshTick + 1 };
336
+ case "DIFF_STATUS_UPDATED": {
337
+ // In-place XY patch — used by stage/unstage where the file SET
338
+ // doesn't change, only the per-file porcelain status. Avoids the
339
+ // full reload's network/git latency and the spinner that goes
340
+ // with it.
341
+ const byPath = new Map();
342
+ for (const p of action.porcelain)
343
+ byPath.set(p.path, p);
344
+ const next = state.diffFiles.map((f) => {
345
+ const p = byPath.get(f.path);
346
+ if (!p) {
347
+ // File no longer has any working-tree state — back to
348
+ // committed-only. Strip the XY fields.
349
+ if (f.indexStatus === undefined && f.workingStatus === undefined)
350
+ return f;
351
+ const cleared = { ...f };
352
+ delete cleared.indexStatus;
353
+ delete cleared.workingStatus;
354
+ delete cleared.isUntracked;
355
+ return cleared;
356
+ }
357
+ return {
358
+ ...f,
359
+ indexStatus: p.index,
360
+ workingStatus: p.working,
361
+ isUntracked: p.index === "?" && p.working === "?",
362
+ };
363
+ });
364
+ return { ...state, diffFiles: next };
365
+ }
366
+ case "DIFF_DISCARD_OPEN":
367
+ return {
368
+ ...state,
369
+ diffPendingDiscard: { path: action.path, isUntracked: action.isUntracked },
370
+ };
371
+ case "DIFF_DISCARD_CANCEL":
372
+ return { ...state, diffPendingDiscard: null };
315
373
  case "DIFF_FILES_ERROR":
316
374
  return {
317
375
  ...state,
@@ -353,6 +411,7 @@ export function reducer(state, action) {
353
411
  diffLoadingFiles: false,
354
412
  diffLoadingContent: false,
355
413
  diffError: null,
414
+ diffPendingDiscard: null,
356
415
  };
357
416
  default:
358
417
  return state;
package/dist/lib/git.d.ts CHANGED
@@ -271,6 +271,45 @@ export declare function getDiffStat(baseBranch: string): string | null;
271
271
  * Returns null if there are no changes or on failure.
272
272
  */
273
273
  export declare function getDiffContent(baseBranch: string): string | null;
274
+ /**
275
+ * One entry from `git status --porcelain=v1 -z`.
276
+ * `index` (X) = staged state; `working` (Y) = unstaged state. Each is a single
277
+ * char per the porcelain format: ' ', 'M', 'A', 'D', 'R', 'C', 'U', '?', '!'.
278
+ */
279
+ export interface PorcelainEntry {
280
+ path: string;
281
+ index: string;
282
+ working: string;
283
+ oldPath?: string;
284
+ }
285
+ /**
286
+ * Read working-tree status as a list of porcelain entries. Uses NUL-delimited
287
+ * output so paths with spaces, quotes, or newlines parse unambiguously.
288
+ */
289
+ export declare function getWorktreeStatus(cwd: string): Promise<PorcelainEntry[]>;
290
+ /**
291
+ * Stage a single file. Works for new (untracked) and modified files.
292
+ */
293
+ export declare function stageFile(cwd: string, filePath: string): Promise<void>;
294
+ /**
295
+ * Unstage a single file. Uses `git restore --staged` (porcelain command,
296
+ * available since git 2.23 — already required elsewhere in santree).
297
+ */
298
+ export declare function unstageFile(cwd: string, filePath: string): Promise<void>;
299
+ /**
300
+ * Stage every uncommitted change in the worktree (new files, modifications, deletions).
301
+ */
302
+ export declare function stageAll(cwd: string): Promise<void>;
303
+ /**
304
+ * Unstage everything in the index (mixed reset on HEAD; working tree untouched).
305
+ */
306
+ export declare function unstageAll(cwd: string): Promise<void>;
307
+ /**
308
+ * Discard uncommitted changes for a single file.
309
+ * - Tracked: `git checkout HEAD -- <path>` restores both index and working tree.
310
+ * - Untracked: deletes the file from disk via fs.unlink.
311
+ */
312
+ export declare function discardFile(cwd: string, filePath: string, isUntracked: boolean): Promise<void>;
274
313
  /**
275
314
  * Read the session state file for a given ticket.
276
315
  * Returns null if missing or "exited".
package/dist/lib/git.js CHANGED
@@ -2,7 +2,7 @@ import { execSync, exec } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import * as path from "path";
4
4
  import * as fs from "fs";
5
- import { run, runAsync } from "./exec.js";
5
+ import { run, runAsync, spawnAsync } from "./exec.js";
6
6
  import { getMultiplexer } from "./multiplexer/index.js";
7
7
  const execAsync = promisify(exec);
8
8
  /**
@@ -623,6 +623,94 @@ export function getDiffStat(baseBranch) {
623
623
  export function getDiffContent(baseBranch) {
624
624
  return run(`git diff ${baseBranch}..HEAD`, { maxBuffer: 10 * 1024 * 1024 }) || null;
625
625
  }
626
+ /**
627
+ * Read working-tree status as a list of porcelain entries. Uses NUL-delimited
628
+ * output so paths with spaces, quotes, or newlines parse unambiguously.
629
+ */
630
+ export async function getWorktreeStatus(cwd) {
631
+ const { code, output } = await spawnAsync("git", ["-C", cwd, "status", "--porcelain=v1", "-z"], {
632
+ cwd,
633
+ });
634
+ if (code !== 0) {
635
+ throw new Error(`git status failed: ${output.trim()}`);
636
+ }
637
+ const entries = [];
638
+ const records = output.split("\0");
639
+ for (let i = 0; i < records.length; i++) {
640
+ const rec = records[i];
641
+ if (!rec)
642
+ continue;
643
+ // Format: "XY <path>" — first 2 chars are status, then a space.
644
+ if (rec.length < 3)
645
+ continue;
646
+ const index = rec.charAt(0);
647
+ const working = rec.charAt(1);
648
+ const main = rec.slice(3);
649
+ // Renames/copies are followed by a NUL-terminated oldPath in the next record.
650
+ if (index === "R" || index === "C" || working === "R" || working === "C") {
651
+ const oldPath = records[i + 1] ?? "";
652
+ i += 1;
653
+ entries.push({ index, working, path: main, oldPath });
654
+ }
655
+ else {
656
+ entries.push({ index, working, path: main });
657
+ }
658
+ }
659
+ return entries;
660
+ }
661
+ /**
662
+ * Stage a single file. Works for new (untracked) and modified files.
663
+ */
664
+ export async function stageFile(cwd, filePath) {
665
+ const { code, output } = await spawnAsync("git", ["-C", cwd, "add", "--", filePath], { cwd });
666
+ if (code !== 0) {
667
+ throw new Error(`git add failed: ${output.trim()}`);
668
+ }
669
+ }
670
+ /**
671
+ * Unstage a single file. Uses `git restore --staged` (porcelain command,
672
+ * available since git 2.23 — already required elsewhere in santree).
673
+ */
674
+ export async function unstageFile(cwd, filePath) {
675
+ const { code, output } = await spawnAsync("git", ["-C", cwd, "restore", "--staged", "--", filePath], { cwd });
676
+ if (code !== 0) {
677
+ throw new Error(`git restore --staged failed: ${output.trim()}`);
678
+ }
679
+ }
680
+ /**
681
+ * Stage every uncommitted change in the worktree (new files, modifications, deletions).
682
+ */
683
+ export async function stageAll(cwd) {
684
+ const { code, output } = await spawnAsync("git", ["-C", cwd, "add", "-A"], { cwd });
685
+ if (code !== 0) {
686
+ throw new Error(`git add -A failed: ${output.trim()}`);
687
+ }
688
+ }
689
+ /**
690
+ * Unstage everything in the index (mixed reset on HEAD; working tree untouched).
691
+ */
692
+ export async function unstageAll(cwd) {
693
+ const { code, output } = await spawnAsync("git", ["-C", cwd, "reset"], { cwd });
694
+ if (code !== 0) {
695
+ throw new Error(`git reset failed: ${output.trim()}`);
696
+ }
697
+ }
698
+ /**
699
+ * Discard uncommitted changes for a single file.
700
+ * - Tracked: `git checkout HEAD -- <path>` restores both index and working tree.
701
+ * - Untracked: deletes the file from disk via fs.unlink.
702
+ */
703
+ export async function discardFile(cwd, filePath, isUntracked) {
704
+ if (isUntracked) {
705
+ const absolute = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
706
+ await fs.promises.unlink(absolute);
707
+ return;
708
+ }
709
+ const { code, output } = await spawnAsync("git", ["-C", cwd, "checkout", "HEAD", "--", filePath], { cwd });
710
+ if (code !== 0) {
711
+ throw new Error(`git checkout HEAD failed: ${output.trim()}`);
712
+ }
713
+ }
626
714
  /**
627
715
  * Get the path to the .santree/session-states directory.
628
716
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",