Compare commits

...

6 Commits

Author SHA1 Message Date
be277b7623 Add stat formatting with colorized diff indicators 2026-02-23 21:28:23 -08:00
33f820f124 sandlot web <branch> 2026-02-23 21:14:29 -08:00
1d7f60b50c Simplify commit message prompts to single-line format
Change the commit message generation prompts from requesting
50/72-rule multi-line messages to single-line subjects only.
This avoids unnecessary body text in automated commits and
ensures the first line is always used via `.split("\n")[0]`.
2026-02-23 20:35:43 -08:00
4c1530760c Merge branch 'super-diffs' 2026-02-23 20:33:39 -08:00
909189b745 Extract browse HTML into external template
The HTML for the browse view was inlined as a template literal in
browse.ts, making it hard to edit and losing syntax highlighting.
Move it to browse.html and use placeholder replacement instead.

Also update branchDiff to accept the main branch as a parameter
so the caller resolves it once and reuses it for diffStat too.
2026-02-23 20:33:10 -08:00
b8f7aea3b0 add browse command to open branch diff in web browser 2026-02-23 20:17:25 -08:00
5 changed files with 124 additions and 4 deletions

View File

@ -16,6 +16,7 @@ import { action as rebaseAction } from "./commands/rebase.ts"
import { action as saveAction } from "./commands/save.ts"
import { action as diffAction } from "./commands/diff.ts"
import { action as showAction } from "./commands/show.ts"
import { action as webAction } from "./commands/web.ts"
import { action as logAction } from "./commands/log.ts"
import { action as dirAction } from "./commands/dir.ts"
import { action as editAction } from "./commands/edit.ts"
@ -102,6 +103,12 @@ program
.description("Show the prompt and full diff for a branch")
.action(showAction)
program
.command("web")
.argument("<branch>", "branch name")
.description("Open the branch diff in a web browser")
.action(webAction)
program
.command("save")
.argument("<branch>", "branch name")

49
src/commands/diff.html Normal file
View File

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{BRANCH}} — sandlot diff</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/styles/github-dark.min.css">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; margin: 0; padding: 0; background: #0d1117; color: #e6edf3; }
.header { padding: 24px 32px; border-bottom: 1px solid #30363d; }
.header h1 { margin: 0 0 8px; font-size: 24px; font-weight: 600; }
.header h1 code { background: #1f2937; padding: 2px 8px; border-radius: 6px; font-size: 22px; }
.prompt { color: #8b949e; margin: 0 0 16px; font-style: italic; }
.meta { display: flex; gap: 32px; }
.meta-section h3 { margin: 0 0 6px; font-size: 13px; text-transform: uppercase; color: #8b949e; letter-spacing: 0.05em; }
.meta-section pre { margin: 0; font-size: 13px; line-height: 1.5; color: #c9d1d9; white-space: pre-wrap; }
.diff-container { padding: 16px 32px; }
.d2h-file-wrapper { margin-bottom: 16px; border-radius: 6px; overflow: hidden; }
.d2h-code-line-ctn { background: transparent; }
</style>
</head>
<body>
<div class="header">
<h1><code>{{BRANCH}}</code></h1>
{{PROMPT_SECTION}}
<div class="meta">
{{LOG_SECTION}}
{{STAT_SECTION}}
</div>
</div>
<div class="diff-container" id="diff"></div>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/highlight.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/diff2html/bundles/js/diff2html-ui.min.js"></script>
<script>
const diffString = {{DIFF_JSON}};
const targetElement = document.getElementById("diff");
const configuration = {
drawFileList: true,
matching: "lines",
outputFormat: "side-by-side",
highlight: true,
colorScheme: "dark",
};
const diff2htmlUi = new Diff2HtmlUI(targetElement, diffString, configuration);
diff2htmlUi.draw();
diff2htmlUi.highlightCode();
</script>
</body>
</html>

View File

@ -97,10 +97,10 @@ async function squashCommit(branch: string, cwd: string): Promise<void> {
if (diff.trim()) {
const gen = await vm.claudePipe(
diff,
"write a commit message for these changes following the 50/72 rule: subject line ≤50 chars, blank line, body wrapped at 72 chars. the body should briefly explain what changed and why. output only the message, no quotes or extra text.",
"write a single-line commit message for these changes, max 50 characters. no body, no blank line, just the subject. output only the message, no quotes or extra text.",
)
if (gen.exitCode === 0 && gen.stdout.trim()) {
await git.commit(gen.stdout.trim(), cwd)
await git.commit(gen.stdout.trim().split("\n")[0], cwd)
return
}
}
@ -133,7 +133,7 @@ export async function saveChanges(worktree: string, branch: string, message?: st
const gen = await vm.claudePipe(
diff,
"write a commit message for these changes following the 50/72 rule: subject line ≤50 chars, blank line, body wrapped at 72 chars. the body should briefly explain what changed and why. output only the message, no quotes or extra text.",
"write a single-line commit message for these changes, max 50 characters. no body, no blank line, just the subject. output only the message, no quotes or extra text.",
)
if (gen.exitCode !== 0) {

57
src/commands/web.ts Normal file
View File

@ -0,0 +1,57 @@
import { $ } from "bun"
import * as git from "../git.ts"
import { die } from "../fmt.ts"
import { requireSession } from "./helpers.ts"
const template = await Bun.file(new URL("diff.html", import.meta.url).pathname).text()
export async function action(branch: string) {
const { session } = await requireSession(branch)
const worktree = session.worktree
const main = await git.mainBranch(worktree)
const [diff, log, stat] = await Promise.all([
git.branchDiff(branch, main, worktree),
git.commitLog(`${main}..${branch}`, worktree),
git.diffStat(`${main}...${branch}`, worktree),
])
if (!diff.trim()) {
die(`No changes on branch "${branch}" compared to ${main}.`)
}
const diffJson = JSON.stringify(diff).replaceAll("<", "\\u003c")
const html = template
.replaceAll("{{BRANCH}}", escapeHtml(branch))
.replace("{{PROMPT_SECTION}}", () => session.prompt ? `<p class="prompt">${escapeHtml(session.prompt)}</p>` : "")
.replace("{{LOG_SECTION}}", () => log ? `<div class="meta-section"><h3>Commits</h3><pre>${escapeHtml(log)}</pre></div>` : "")
.replace("{{STAT_SECTION}}", () => stat ? `<div class="meta-section"><h3>Stats</h3><pre>${formatStat(stat)}</pre></div>` : "")
.replace("{{DIFF_JSON}}", () => diffJson)
const tmpPath = `/tmp/sandlot-${branch}.html`
await Bun.write(tmpPath, html)
await $`open ${tmpPath}`.nothrow().quiet()
}
function formatStat(raw: string): string {
// strip leading space from lines to match the unindented first line
const trimmed = raw.split("\n").map(l => l.startsWith(" ") ? l.slice(1) : l).join("\n")
const escaped = escapeHtml(trimmed)
// colorize + and - in the bar portion (after the |)
return escaped.split("\n").map(line => {
const pipe = line.indexOf("|")
if (pipe === -1) return line
return line.slice(0, pipe + 1) +
line.slice(pipe + 1)
.replace(/\+/g, '<span style="color:#3fb950">+</span>')
.replace(/-/g, '<span style="color:#f85149">-</span>')
}).join("\n")
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}

View File

@ -198,7 +198,7 @@ export async function commitLog(range: string, cwd: string): Promise<string> {
/** Get a diff stat summary for a revision range. Returns empty string on failure. */
export async function diffStat(range: string, cwd: string): Promise<string> {
const result = await $`git diff --stat ${range}`.cwd(cwd).nothrow().quiet()
const result = await $`git diff --stat --stat-width=68 ${range}`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) return ""
return result.text().trim()
}
@ -218,6 +218,13 @@ export async function hasNewCommits(worktreePath: string): Promise<boolean> {
return parseInt(result.text().trim(), 10) > 0
}
/** Get the full unified diff of a branch vs main as a string. */
export async function branchDiff(branch: string, main: string, cwd: string): Promise<string> {
const result = await $`git diff --no-ext-diff ${main}...${branch}`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) return ""
return result.text()
}
/** Detect the main branch name (main or master). */
export async function mainBranch(cwd?: string): Promise<string> {
const dir = cwd ?? "."