santree 0.5.1 → 0.5.3

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";
@@ -42,17 +42,72 @@ const CLAUDE_VERSION = getInstalledClaudeVersion() ?? "";
42
42
  * R100\told/path\tnew/path
43
43
  * For renames/copies, the status code has a similarity suffix we strip.
44
44
  */
45
+ /**
46
+ * Split combined-parameter SGR sequences (e.g. `\x1b[48;2;R;G;B;38;2;R;G;B m`)
47
+ * into separate single-attribute SGRs (`\x1b[48;2;...m\x1b[38;2;...m`).
48
+ *
49
+ * Why: Ink uses `slice-ansi` to clip text horizontally, and `slice-ansi`
50
+ * miscounts visible width on combined RGB bg+fg SGRs — it cuts the line at
51
+ * roughly half the requested visible width. Delta emits exactly this combined
52
+ * form on every styled token, so the diff pane was rendering content cut at
53
+ * arbitrary points (e.g. `from datetime i` instead of `from datetime import
54
+ * timedelta`). Splitting them sidesteps the slice-ansi bug without losing any
55
+ * styling — the terminal renders the two SGRs identically to the combined one.
56
+ */
57
+ function splitCombinedSgr(s) {
58
+ return s.replace(/\x1b\[([0-9;]+)m/g, (_match, params) => {
59
+ const tokens = params.split(";");
60
+ const groups = [];
61
+ for (let i = 0; i < tokens.length; i++) {
62
+ const t = tokens[i];
63
+ if ((t === "38" || t === "48") && tokens[i + 1] === "2") {
64
+ groups.push([t, "2", tokens[i + 2], tokens[i + 3], tokens[i + 4]].join(";"));
65
+ i += 4;
66
+ }
67
+ else if ((t === "38" || t === "48") && tokens[i + 1] === "5") {
68
+ groups.push([t, "5", tokens[i + 2]].join(";"));
69
+ i += 2;
70
+ }
71
+ else {
72
+ groups.push(t);
73
+ }
74
+ }
75
+ if (groups.length <= 1)
76
+ return `\x1b[${params}m`;
77
+ return groups.map((g) => `\x1b[${g}m`).join("");
78
+ });
79
+ }
45
80
  /**
46
81
  * Pipe `git diff` output through an external tool (e.g. delta) and return the
47
82
  * combined ANSI output. Uses spawn pipes — no shell — so the tool name is safe
48
83
  * even though we already validate it in getDiffTool().
49
84
  */
50
- function runPipedDiff(cwd, mergeBase, filePath, tool) {
85
+ function runPipedDiff(cwd, gitArgs, tool, themeMode) {
51
86
  return new Promise((resolve, reject) => {
52
- const git = spawn("git", ["-C", cwd, "diff", "--color=always", mergeBase, "--", filePath], {
87
+ const git = spawn("git", ["-C", cwd, ...gitArgs], {
53
88
  stdio: ["ignore", "pipe", "pipe"],
54
89
  });
55
- const pager = spawn(tool, [], { stdio: ["pipe", "pipe", "pipe"] });
90
+ // Delta's syntax theme defaults are tuned for dark backgrounds — pale
91
+ // Monokai foreground on a light terminal becomes invisible. Force the
92
+ // theme flag matching santree's detected mode so colors stay readable.
93
+ const pagerArgs = tool === "delta" ? [themeMode === "light" ? "--light" : "--dark"] : [];
94
+ // Disable hyperlinks for delta: OSC 8 sequences (`\x1b]8;...`) are not
95
+ // handled by truncateVisible() — its CSI-only regex counts the URL
96
+ // bytes as visible characters, mangling line truncation and breaking
97
+ // terminal rendering of the wrapped text. Delta's CLI rejects an
98
+ // inline `--hyperlinks=false`, so override via GIT_CONFIG_PARAMETERS
99
+ // (delta reads its config from git). Also drop line-numbers — they
100
+ // eat ~6 cols of an already-narrow right pane.
101
+ const pagerEnv = tool === "delta"
102
+ ? {
103
+ ...process.env,
104
+ GIT_CONFIG_PARAMETERS: "'delta.hyperlinks=false' 'delta.line-numbers=false'",
105
+ }
106
+ : process.env;
107
+ const pager = spawn(tool, pagerArgs, {
108
+ stdio: ["pipe", "pipe", "pipe"],
109
+ env: pagerEnv,
110
+ });
56
111
  let out = "";
57
112
  let err = "";
58
113
  git.stdout.pipe(pager.stdin);
@@ -72,7 +127,7 @@ function runPipedDiff(cwd, mergeBase, filePath, tool) {
72
127
  reject(new Error(err || `${tool} exited with code ${code}`));
73
128
  }
74
129
  else {
75
- resolve(out);
130
+ resolve(splitCombinedSgr(out));
76
131
  }
77
132
  });
78
133
  });
@@ -200,7 +255,7 @@ function CommandBar({ showWorkspace, mode = "default", }) {
200
255
  const dot = _jsx(Text, { dimColor: true, children: " · " });
201
256
  const Key = ({ k }) => (_jsx(Text, { color: "cyan", bold: true, children: k }));
202
257
  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" })] }));
258
+ 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
259
  }
205
260
  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
261
  }
@@ -519,8 +574,16 @@ export default function Dashboard() {
519
574
  await refresh(true);
520
575
  };
521
576
  init();
522
- // Auto-refresh every 30s
523
- refreshTimerRef.current = setInterval(() => refresh(), 30_000);
577
+ // Auto-refresh every 30s. While the diff overlay is open, also bump
578
+ // the diff refresh tick so new/removed files (created or deleted
579
+ // outside the dashboard) eventually show up. Stage/unstage already
580
+ // patch XY in place, so this is purely about file-set drift.
581
+ refreshTimerRef.current = setInterval(() => {
582
+ refresh();
583
+ if (stateRef.current.overlay === "diff") {
584
+ dispatch({ type: "DIFF_REFRESH_FILES" });
585
+ }
586
+ }, 30_000);
524
587
  return () => {
525
588
  if (refreshTimerRef.current)
526
589
  clearInterval(refreshTimerRef.current);
@@ -586,17 +649,49 @@ export default function Dashboard() {
586
649
  useEffect(() => {
587
650
  if (state.overlay !== "diff" || !state.diffWorktreePath || !state.diffBaseBranch)
588
651
  return;
589
- if (!state.diffLoadingFiles)
590
- return;
591
652
  const cwd = state.diffWorktreePath;
592
653
  const base = state.diffBaseBranch;
593
654
  void (async () => {
594
655
  try {
595
656
  const { stdout: mergeBaseOut } = await execAsync(`git -C "${cwd}" merge-base "${base}" HEAD`);
596
657
  const mergeBase = mergeBaseOut.trim() || base;
597
- const { stdout } = await execAsync(`git -C "${cwd}" diff --name-status "${mergeBase}"`);
658
+ const [{ stdout }, porcelain] = await Promise.all([
659
+ execAsync(`git -C "${cwd}" diff --name-status "${mergeBase}"`),
660
+ getWorktreeStatus(cwd).catch(() => []),
661
+ ]);
598
662
  const files = parseNameStatus(stdout);
599
- const ordered = flattenTreeFiles(files);
663
+ // Merge porcelain (working-tree state) into the merge-base file list.
664
+ // XY status drives stage/unstage UX; untracked files (`??`) only show
665
+ // up here since `git diff` ignores them.
666
+ const porcelainByPath = new Map();
667
+ for (const p of porcelain)
668
+ porcelainByPath.set(p.path, p);
669
+ const enriched = files.map((f) => {
670
+ const p = porcelainByPath.get(f.path);
671
+ if (!p)
672
+ return f;
673
+ porcelainByPath.delete(f.path);
674
+ return {
675
+ ...f,
676
+ indexStatus: p.index,
677
+ workingStatus: p.working,
678
+ isUntracked: p.index === "?" && p.working === "?",
679
+ };
680
+ });
681
+ // Untracked entries left over → add as new DiffFile rows so they
682
+ // appear in the tree and can be staged.
683
+ for (const p of porcelainByPath.values()) {
684
+ if (p.index === "?" && p.working === "?") {
685
+ enriched.push({
686
+ path: p.path,
687
+ status: "?",
688
+ indexStatus: p.index,
689
+ workingStatus: p.working,
690
+ isUntracked: true,
691
+ });
692
+ }
693
+ }
694
+ const ordered = flattenTreeFiles(enriched);
600
695
  dispatch({ type: "DIFF_FILES_LOADED", files: ordered, mergeBase });
601
696
  }
602
697
  catch (err) {
@@ -604,7 +699,7 @@ export default function Dashboard() {
604
699
  dispatch({ type: "DIFF_FILES_ERROR", error: msg });
605
700
  }
606
701
  })();
607
- }, [state.overlay, state.diffWorktreePath, state.diffBaseBranch, state.diffLoadingFiles]);
702
+ }, [state.overlay, state.diffWorktreePath, state.diffBaseBranch, state.diffRefreshTick]);
608
703
  // ── Diff overlay: load content for selected file ──────────────────
609
704
  // If SANTREE_DIFF_TOOL is set, pipe `git diff` output through the tool so
610
705
  // the user's preferred renderer (delta, diff-so-fancy, etc.) handles
@@ -621,11 +716,27 @@ export default function Dashboard() {
621
716
  dispatch({ type: "DIFF_CONTENT_LOADING" });
622
717
  void (async () => {
623
718
  try {
624
- if (tool) {
719
+ if (file.isUntracked) {
720
+ // Untracked files aren't in `git diff` output — fake a "full
721
+ // addition" diff via --no-index against /dev/null. git exits 1
722
+ // when files differ; that's expected, so we capture stdout
723
+ // regardless. Pipe through the configured tool when set so
724
+ // untracked files get the same syntax highlighting as tracked
725
+ // ones; otherwise fall back to spawnAsync + manual colorize.
726
+ if (tool) {
727
+ const content = await runPipedDiff(cwd, ["diff", "--color=always", "--no-index", "--", "/dev/null", file.path], tool, theme.mode);
728
+ dispatch({ type: "DIFF_CONTENT_LOADED", content });
729
+ }
730
+ else {
731
+ const { output } = await spawnAsync("git", ["-C", cwd, "diff", "--no-color", "--no-index", "--", "/dev/null", file.path], { cwd });
732
+ dispatch({ type: "DIFF_CONTENT_LOADED", content: output });
733
+ }
734
+ }
735
+ else if (tool) {
625
736
  // Pipe git diff (with colors enabled so the tool can pass them
626
737
  // through if desired) into the configured tool. Use spawn pipes
627
738
  // rather than shell to avoid quoting concerns.
628
- const content = await runPipedDiff(cwd, mergeBase, file.path, tool);
739
+ const content = await runPipedDiff(cwd, ["diff", "--color=always", mergeBase, "--", file.path], tool, theme.mode);
629
740
  dispatch({ type: "DIFF_CONTENT_LOADED", content });
630
741
  }
631
742
  else {
@@ -645,6 +756,7 @@ export default function Dashboard() {
645
756
  state.diffMergeBase,
646
757
  state.diffFileIndex,
647
758
  state.diffFiles,
759
+ theme.mode,
648
760
  ]);
649
761
  // ── Actions ───────────────────────────────────────────────────────
650
762
  const launchWorkInTmux = useCallback(async (di, mode, worktreePath, contextFile) => {
@@ -1307,6 +1419,42 @@ export default function Dashboard() {
1307
1419
  }
1308
1420
  // Diff overlay
1309
1421
  if (state.overlay === "diff") {
1422
+ // Pending discard modal — intercepts y/n/ESC/q so they don't
1423
+ // also close the diff overlay.
1424
+ if (state.diffPendingDiscard) {
1425
+ const pd = state.diffPendingDiscard;
1426
+ if (input === "y") {
1427
+ const cwd = state.diffWorktreePath;
1428
+ if (!cwd) {
1429
+ dispatch({ type: "DIFF_DISCARD_CANCEL" });
1430
+ return;
1431
+ }
1432
+ (async () => {
1433
+ try {
1434
+ await discardFile(cwd, pd.path, pd.isUntracked);
1435
+ dispatch({ type: "DIFF_DISCARD_CANCEL" });
1436
+ dispatch({ type: "DIFF_REFRESH_FILES" });
1437
+ dispatch({
1438
+ type: "SET_ACTION_MESSAGE",
1439
+ message: pd.isUntracked
1440
+ ? `Deleted ${pd.path}`
1441
+ : `Discarded changes in ${pd.path}`,
1442
+ });
1443
+ }
1444
+ catch (err) {
1445
+ const msg = err instanceof Error ? err.message : String(err);
1446
+ dispatch({ type: "DIFF_DISCARD_CANCEL" });
1447
+ dispatch({ type: "SET_ACTION_MESSAGE", message: `Discard failed: ${msg}` });
1448
+ }
1449
+ })();
1450
+ return;
1451
+ }
1452
+ if (input === "n" || key.escape || input === "q") {
1453
+ dispatch({ type: "DIFF_DISCARD_CANCEL" });
1454
+ return;
1455
+ }
1456
+ return;
1457
+ }
1310
1458
  if (key.escape || input === "q") {
1311
1459
  dispatch({ type: "DIFF_CLOSE" });
1312
1460
  return;
@@ -1362,6 +1510,98 @@ export default function Dashboard() {
1362
1510
  dispatch({ type: "DIFF_FILE_SELECT", index: prev });
1363
1511
  return;
1364
1512
  }
1513
+ // Stage / unstage / discard — only meaningful when the worktree
1514
+ // path is known. All ops dispatch DIFF_REFRESH_FILES so the
1515
+ // porcelain status (and selection) updates immediately.
1516
+ const cwd = state.diffWorktreePath;
1517
+ const currentFile = state.diffFiles[state.diffFileIndex];
1518
+ if (input === " " && cwd && currentFile) {
1519
+ // Toggle: if anything is staged for this file, unstage it;
1520
+ // otherwise stage. Files with no uncommitted state (only
1521
+ // committed changes vs base) have no XY → no-op. Updates XY
1522
+ // in place via porcelain re-fetch — no full reload, no spinner.
1523
+ const xRaw = currentFile.indexStatus;
1524
+ const yRaw = currentFile.workingStatus;
1525
+ if (xRaw === undefined && yRaw === undefined) {
1526
+ dispatch({
1527
+ type: "SET_ACTION_MESSAGE",
1528
+ message: "No uncommitted changes to stage on this file",
1529
+ });
1530
+ return;
1531
+ }
1532
+ const x = xRaw ?? " ";
1533
+ const isStaged = x !== " " && x !== "?";
1534
+ const path = currentFile.path;
1535
+ (async () => {
1536
+ try {
1537
+ if (isStaged)
1538
+ await unstageFile(cwd, path);
1539
+ else
1540
+ await stageFile(cwd, path);
1541
+ const porcelain = await getWorktreeStatus(cwd);
1542
+ dispatch({ type: "DIFF_STATUS_UPDATED", porcelain });
1543
+ }
1544
+ catch (err) {
1545
+ const msg = err instanceof Error ? err.message : String(err);
1546
+ dispatch({
1547
+ type: "SET_ACTION_MESSAGE",
1548
+ message: `${isStaged ? "Unstage" : "Stage"} failed: ${msg}`,
1549
+ });
1550
+ }
1551
+ })();
1552
+ return;
1553
+ }
1554
+ if (input === "a" && cwd) {
1555
+ // Stage-all if anything is unstaged or untracked; otherwise
1556
+ // unstage everything. Untracked files have Y === "?" so they
1557
+ // fall under "unstaged" — staging them adds them to the index.
1558
+ // Same in-place porcelain refresh as `space`.
1559
+ const anyUnstaged = state.diffFiles.some((f) => {
1560
+ const y = f.workingStatus;
1561
+ return y !== undefined && y !== " ";
1562
+ });
1563
+ (async () => {
1564
+ try {
1565
+ if (anyUnstaged)
1566
+ await stageAll(cwd);
1567
+ else
1568
+ await unstageAll(cwd);
1569
+ const porcelain = await getWorktreeStatus(cwd);
1570
+ dispatch({ type: "DIFF_STATUS_UPDATED", porcelain });
1571
+ dispatch({
1572
+ type: "SET_ACTION_MESSAGE",
1573
+ message: anyUnstaged ? "Staged all changes" : "Unstaged all changes",
1574
+ });
1575
+ }
1576
+ catch (err) {
1577
+ const msg = err instanceof Error ? err.message : String(err);
1578
+ dispatch({ type: "SET_ACTION_MESSAGE", message: `Failed: ${msg}` });
1579
+ }
1580
+ })();
1581
+ return;
1582
+ }
1583
+ if (input === "d" && currentFile) {
1584
+ if (currentFile.indexStatus === undefined && currentFile.workingStatus === undefined) {
1585
+ dispatch({
1586
+ type: "SET_ACTION_MESSAGE",
1587
+ message: "No uncommitted changes to discard",
1588
+ });
1589
+ return;
1590
+ }
1591
+ dispatch({
1592
+ type: "DIFF_DISCARD_OPEN",
1593
+ path: currentFile.path,
1594
+ isUntracked: !!currentFile.isUntracked,
1595
+ });
1596
+ return;
1597
+ }
1598
+ // Open the selected file in the user's editor — useful when
1599
+ // the diff alone isn't enough context. Editor resolution
1600
+ // matches the rest of santree (SANTREE_EDITOR > "code").
1601
+ if (input === "e" && cwd && currentFile) {
1602
+ openInEditor(path.join(cwd, currentFile.path));
1603
+ return;
1604
+ }
1365
1605
  return;
1366
1606
  }
1367
1607
  // Confirm delete overlay
@@ -1884,10 +2124,10 @@ export default function Dashboard() {
1884
2124
  const defaultBranch = getDefaultBranch();
1885
2125
  const label = branch === defaultBranch ? `${branch} (default)` : branch;
1886
2126
  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
2127
+ }), _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
2128
  .split("\n")
1889
2129
  .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 }) })] })] })] }));
2130
+ .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
2131
  }
1892
2132
  /**
1893
2133
  * 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,37 @@ 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
- return (_jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, children: cell }, i));
261
- })) })] })] }));
297
+ // wrap="truncate" prevents Ink from soft-wrapping. Default
298
+ // `wrap` mode measures byte length (counting ANSI escape
299
+ // bytes as visible chars), which makes color-heavy lines
300
+ // like syntax-highlighted code wrap *very* early — visible
301
+ // content gets clobbered by the next row. truncateVisible
302
+ // has already sized the cell, so `truncate` is a no-op for
303
+ // already-fitting lines.
304
+ return (_jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, wrap: "truncate", children: cell }, i));
305
+ })) })] }))] }));
262
306
  }
@@ -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.3",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",