SLOPSHOPPER

unmerged

My Dotfiles!

newpanebandguardcommandstatus
v?MITupdated 2026-10-080ta2/dots/dot_claude/mods/unmerged
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · unmerged
│ ┃ Unmerged ✕ › fix the failing auth test and add an audit log call │ ┃ マージ前の変更はありません │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /unmerged │ ⎿ unmerged: Unmerged pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Unmerged
マージ前の変更はありません
README

dots

セットアップ (初回のみ、2段階)

1. chezmoi を導入し dots を取得する (反映はしない)

sh -c "$(curl -fsLS get.chezmoi.io)" -- -b $HOME/.local/bin init --source=~/ghq/github.com/0ta2/dots git@github.com:0ta2/dots.git

2. Homebrew と mise を用意して初回セットアップする

~/ghq/github.com/0ta2/dots/bootstrap.sh

Homebrew・mise を導入した後 mise run install (ドットファイル反映・不足ツール・skill・plugin の 導入・Codex 設定同期) を実行する。

日常のコマンド

mise run sync    # ドットファイル反映 + Codex設定マージ (副作用なし、何度実行しても収束する)
mise run update  # brew/mise/skill/uv tool の更新、最後に sync

直接 chezmoi コマンドを実行する場合は下記の通り:

~/.local/bin/chezmoi --source=~/ghq/github.com/0ta2/dots apply

mac セットアップ

  • 一般
  • 情報 
  • コンピュータ名 を変更
  • 自動入力とパスワード
  • パスワードやパスキーを自動入力 をOFF
  • アクセシビリティ
  • ポインタコントロール
  • トラックパッドオプション
  • ドラッグにトラックパッドを使用 をON
  • デスクトップとDock
  • Dock のサイズ小さく好みのサイズへ変更
  • Dock の拡大を好みの大きさへ変更
  • Dock を自動的に表示/非表示をOFF
  • 起動中のアプリケーションをアニメーションで表示をOFF
  • 起動済みのアプリケーションにインジケータを表示をOFF
  • アプリの提案と最近使用したアプリをDockに表示をOFF
  • アプリの提案と最近使用したアプリをDockに表示をOFF
  • 外観
  • 外観モードをダークに変更
  • キーボード
  • キーボードショートカット
  • Spotlight検索を表示 のショートカットのチェックを外す
  • 入力ソース
  • 前の入力ソースを選択 を Cmd + スペース に変更
  • 入力メニューの次のソースを選択 を Cmd + shift + スペース に変更
  • 装飾キー
  • CapsLock を Ctrl に変更する
  • 入力ソース
  • USキー と ひらがな(Google)
  • トラックパッド
  • ポイントとクリック
  • 軌跡の速さを一番早く
  • タップでクリック にチェック

Neovim ローカル開発プラグインのリンク作成

iwano.nvim / amanoukihashi.nvim をローカルのリポジトリから読み込むため、下記のシンボリックリンクを作成する:

mkdir -p ~/.local/share/nvim/site/pack/mine/opt
ln -s ~/ghq/github.com/0ta2/iwano.nvim ~/.local/share/nvim/site/pack/mine/opt/iwano.nvim-local
ln -s ~/ghq/github.com/0ta2/amanoukihashi.nvim ~/.local/share/nvim/site/pack/mine/opt/amanoukihashi.nvim-local
Source 3 files
hooks/register.tsx 387 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Asked, FileChange, RepoSnapshot } from '../types'
5import { dirsInCommand, fitHunks, hunkRange, pickLines, snapshot, type Git } from './git.ts'
6
7const PANE = 'unmerged'
8const CODE_LIMIT = 10000
9const UNTRACKED_LIMIT = 1_000_000
10const CONTEXT_LIMIT = 32_000
11const GIT_ARGS = ['--no-optional-locks', '-c', 'core.quotePath=false', '-c', 'diff.relative=false']
12const repos = atom({ plugin: 'unmerged', key: 'repos' } as const, [])
13const snapshots = atom({ plugin: 'unmerged', key: 'snapshots' } as const, [])
14const selected = atom({ plugin: 'unmerged', key: 'selected' } as const, null)
15const isOpen = atom({ plugin: 'unmerged', key: 'isOpen' } as const, false)
16const asked = atom({ plugin: 'unmerged', key: 'asked' } as const, null)
17const expanded = atom({ plugin: 'unmerged', key: 'expanded' } as const, {})
18
19const gitOf = ($: EngineInterface, okCodes = [0]): Git => async (dir, args) => {
20  const r = await $.process.run(['git', '-C', dir, ...GIT_ARGS, ...args]).catch(() => undefined)
21  return r && okCodes.includes(r.exitCode ?? -1) && !r.isStdoutTruncated ? r.stdout : undefined
22}
23
24async function rootsOf(git: Git, dirs: string[]): Promise<string[]> {
25  const roots = await Promise.all(dirs.map(async d => (await git(d, ['rev-parse', '--show-toplevel']))?.trim()))
26  return roots.filter((r): r is string => !!r)
27}
28
29async function track($: EngineInterface, dirs: string[]) {
30  const roots = await rootsOf(gitOf($), dirs)
31  if (roots.length) await update($, repos, list => [...new Set([...(list ?? []), ...roots])])
32}
33
34export function parseRepos(text: string): { path: string; task?: string }[] {
35  try {
36    const { repos, task } = JSON.parse(text) as { repos?: unknown; task?: unknown }
37    return Array.isArray(repos)
38      ? repos.filter((path): path is string => typeof path === 'string').map(path => ({ path, ...(typeof task === 'string' && { task }) }))
39      : []
40  } catch {
41    return []
42  }
43}
44
45async function delegatedRepos($: EngineInterface): Promise<{ path: string; task?: string }[]> {
46  const [home, workspace] = await Promise.all([$.env.get('HOME'), $.env.get('HERDR_WORKSPACE_ID')])
47  if (!home || !workspace) return []
48  const dir = `${home}/.local/state/herdr-team/${workspace}`
49  const entries = await $.fs.list(dir).catch(() => [])
50  const lists = await Promise.all(
51    entries
52      .filter(f => f.kind === 'file' && f.name.endsWith('.json'))
53      .map(async f => parseRepos(await $.fs.read(`${dir}/${f.name}`).catch(() => ''))),
54  )
55  return lists.flat()
56}
57
58async function diffOf($: EngineInterface, snap: RepoSnapshot, file: FileChange): Promise<string> {
59  const opts = ['--no-textconv', '--no-ext-diff']
60  let diff: string | undefined
61  if (file.isUntracked) {
62    const stat = await $.fs.stat(`${snap.root}/${file.path}`).catch(() => undefined)
63    if (!stat || (!stat.isLink && stat.size > UNTRACKED_LIMIT)) return ''
64    diff = await gitOf($, [0, 1])(snap.root, ['diff', '--no-index', ...opts, '--', '/dev/null', file.path])
65  } else {
66    diff = await gitOf($)(snap.root, ['diff', '--no-renames', ...opts, snap.mergeBase, '--', file.path])
67  }
68  return !diff || diff.includes('\0') ? '' : diff
69}
70
71let selection = 0
72let delegatedTasks = new Map<string, string | undefined>()
73
74async function select($: EngineInterface, snap: RepoSnapshot, file: FileChange) {
75  const mine = ++selection
76  const at = { root: snap.root, path: file.path, isUntracked: file.isUntracked }
77  const isSameFile = (now: typeof at | null | undefined) => !!now && now.root === at.root && now.path === at.path && now.isUntracked === at.isUntracked
78  await update($, selected, now => (isSameFile(now) ? now : { ...at, diff: null }))
79  const diff = await diffOf($, snap, file)
80  if (mine === selection) await update($, selected, now => (isSameFile(now) && now!.diff === diff ? now : { ...at, diff }))
81}
82
83async function unselect($: EngineInterface) {
84  selection++
85  await update($, selected, () => null)
86}
87
88async function refresh($: EngineInterface) {
89  const git = gitOf($)
90  const delegated = await Promise.all(
91    (await delegatedRepos($)).map(async ({ path, task }) => {
92      const root = (await git(path, ['rev-parse', '--show-toplevel']))?.trim()
93      return root ? { root, task } : undefined
94    }),
95  )
96  const delegatedBefore = JSON.stringify([...delegatedTasks])
97  delegatedTasks = new Map(delegated.filter((repo): repo is { root: string; task: string | undefined } => !!repo).map(({ root, task }) => [root, task]))
98  const list = [...new Set([...((await read($, repos)) ?? []), ...delegatedTasks.keys()])]
99  const snaps = (await Promise.all(list.map(root => snapshot(git, root)))).filter(
100    (s): s is RepoSnapshot => !!s,
101  )
102  const isDelegationSame = delegatedBefore === JSON.stringify([...delegatedTasks])
103  await update($, snapshots, now => (isDelegationSame && JSON.stringify(now) === JSON.stringify(snaps) ? now : snaps))
104  const sel = await read($, selected)
105  if (!sel) return
106  const snap = snaps.find(s => s.root === sel.root)
107  const file = snap?.files.find(f => f.path === sel.path && f.isUntracked === sel.isUntracked)
108  if (snap && file) await select($, snap, file)
109  else await unselect($)
110}
111
112const POLL_MS = 5000
113let poll: { cancel: () => void } | undefined
114
115function startPolling($: EngineInterface) {
116  poll?.cancel()
117  let isBusy = false
118  poll = $.clock.every(POLL_MS, () => {
119    if (isBusy) return
120    isBusy = true
121    void refresh($).finally(() => {
122      isBusy = false
123    })
124  })
125}
126
127const CUT_NOTE = '\n(The rest of this diff was cut: it did not fit in the prompt.)'
128let carrying = false
129const isSame = (a: Asked | null, b: Asked | null) => !!a && !!b && a.root === b.root && a.path === b.path
130
131async function askText($: EngineInterface, at: Asked, room: number): Promise<{ name: string; text?: string }> {
132  const name = `${basename(at.root)}/${at.path}`
133  const snap = await snapshot(gitOf($), at.root)
134  const file = snap?.files.findLast(f => f.path === at.path)
135  if (!snap || !file) return { name }
136  const what = at.range ? `${at.range.isOld ? 'removed ' : ''}lines ${spanOf(at.range)} of the diff` : 'the diff'
137  const head = `The user attached ${what} of ${at.path} in ${at.root} (since its merge base with ${snap.base}) from the unmerged pane to this prompt:\n`
138  const diff = at.range?.source ?? (await diffOf($, snap, file))
139  const whole = fitHunks(diff, room - head.length)
140  const fit = whole?.isCut ? fitHunks(diff, room - head.length - CUT_NOTE.length) : whole
141  return { name, text: fit?.source ? head + fit.source + (fit.isCut ? CUT_NOTE : '') : undefined }
142}
143
144const askedName = (at: Asked) =>
145  `${basename(at.root)}/${at.path}${at.range ? `(${at.range.isOld ? '削除した ' : ''}${spanOf(at.range)} 行)` : ''}`
146const spanOf = (r: { from: number; to: number }) => (r.from === r.to ? `${r.from}` : `${r.from}–${r.to}`)
147const STATUS_COLORS: Record<string, string> = { A: 'success', M: 'warning', T: 'warning', D: 'error', U: 'error' }
148type Count = { text: string; color?: string }
149const counts = (f: FileChange): Count[] =>
150  f.isUntracked
151    ? []
152    : f.added === null
153      ? [{ text: 'bin' }]
154      : [
155          ...(f.added ? [{ text: `+${f.added}`, color: 'success' }] : []),
156          ...(f.removed ? [{ text: `-${f.removed}`, color: 'error' }] : []),
157        ]
158const countsWidth = (c: Count[]) => c.reduce((w, { text }) => w + text.length, Math.max(c.length - 1, 0))
159
160const dirname = (p: string) => p.slice(0, p.lastIndexOf('/')) || '/'
161const basename = (p: string) => p.slice(p.lastIndexOf('/') + 1)
162
163const quote = (s: string) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replaceAll("'", `'\\''`)}'`)
164
165async function openInEditor($: EngineInterface, editor: string, root: string, path: string) {
166  const herdr = async (...args: string[]) => {
167    const r = await $.process.run(['herdr', ...args], { timeoutMs: 10_000 }).catch(() => undefined)
168    if (r?.exitCode !== 0 || r.isStdoutTruncated) throw new Error('herdr failed')
169    return r.stdout
170  }
171  let tab: string | undefined
172  try {
173    const pane = await $.env.get('HERDR_PANE_ID')
174    const moved = pane ? await herdr('pane', 'get', pane).then(out => JSON.parse(out).result?.pane?.workspace_id, () => undefined) : undefined
175    const workspace = (typeof moved === 'string' && moved) || (await $.env.get('HERDR_WORKSPACE_ID'))
176    if (!workspace) throw new Error('no workspace')
177    const tabs = JSON.parse(await herdr('tab', 'list', '--workspace', workspace)).result?.tabs
178    const number = (label: unknown) => (typeof label === 'string' ? Number(/^E(\d+)$/.exec(label)?.[1] ?? 0) : 0)
179    const label = `E${(Array.isArray(tabs) ? Math.max(0, ...tabs.map(tab => number(tab?.label))) : 0) + 1}`
180    const { result } = JSON.parse(await herdr('tab', 'create', '--workspace', workspace, '--cwd', root, '--label', label, '--no-focus'))
181    tab = result.tab.tab_id
182    await herdr('pane', 'run', result.root_pane.pane_id, `${editor} ${quote(path.startsWith('-') ? `./${path}` : path)}`)
183    await herdr('tab', 'focus', tab!)
184  } catch {
185    if (tab) await herdr('tab', 'close', tab).catch(() => undefined)
186    await $.ui.status('開けませんでした')
187  }
188}
189
190export const register: Register = (on, options) => {
191  const editor = (options as { editor?: string } | undefined)?.editor || 'nvim'
192  on('session.start', async ($, e, next) => {
193    await $.command.register({ name: 'unmerged', description: 'このセッションで触ったリポジトリのマージ前の差分を表示する' })
194    await track($, [await $.session.cwd()])
195    if (await read($, isOpen)) startPolling($)
196    return next(e)
197  })
198
199  on('command.run', { command: 'unmerged' }, async $ => {
200    await refresh($)
201    await update($, isOpen, () => true)
202    startPolling($)
203    await $.ui.open({ id: PANE, title: 'Unmerged' })
204    return { text: 'Unmerged pane opened.' }
205  })
206
207  on('ui.close', { id: PANE }, async ($, e, next) => {
208    await update($, isOpen, () => false)
209    poll?.cancel()
210    poll = undefined
211    return next(e)
212  })
213
214  on('tool.call', async ($, e, next) => {
215    const ran = await next(e)
216    if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') {
217      await track($, [dirname(e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path)])
218    } else if (e.tool === 'Bash') {
219      await track($, dirsInCommand(e.command, await $.session.cwd(), (await $.env.get('HOME')) ?? ''))
220    } else {
221      return ran
222    }
223    if (await read($, isOpen)) await refresh($)
224    return ran
225  })
226
227  on('prompt.submit', async ($, e, next) => {
228    const at = await read($, asked)
229    if (!at || carrying || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')) return next(e)
230    carrying = true
231    try {
232      const context = e.context ?? []
233      const ask = await askText($, at, CONTEXT_LIMIT - context.reduce((n, c) => n + c.length, 0))
234      const isSent = ask.text !== undefined
235      const result = isSent ? await next({ ...e, context: [...context, ask.text!] }) : undefined
236      if (!isSent || result?.drop === undefined) {
237        await update($, asked, now => (isSame(now, at) ? null : now))
238        await $.ui.status(isSent ? undefined : `${ask.name} の差分を添付できませんでした`)
239      }
240      return result ?? (await next(e))
241    } finally {
242      carrying = false
243    }
244  })
245
246  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
247    const at = await read($, asked)
248    if (!at || e.props.hasSurvey) return next(e)
249    const { Box, Text, Button } = $.ui.resolve(e)
250    return (
251      <Box flexDirection="row" gap={1}>
252        <Text>{`📎 ${askedName(at)}${at.range ? '' : ' '}を次の送信に添付`}</Text>
253        <Button key="unask" label="外す" onPress={() => update($, asked, () => null)} />
254      </Box>
255    )
256  })
257
258  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
259    const { Box, Text, Button, Code } = $.ui.resolve(e)
260    const snaps = (await read($, snapshots)) ?? []
261    const sel = await read($, selected)
262    const at = await read($, asked)
263    const opened = (await read($, expanded)) ?? {}
264    const isAsked = isSame(at, sel)
265    const canOpen = (await $.env.get('HERDR_ENV')) === '1'
266    const fit = sel?.diff ? fitHunks(sel.diff, CODE_LIMIT) : undefined
267    const isExpanded = (root: string) => opened[root] ?? (snaps.length < 2 || sel?.root === root)
268    const selectedDiff = sel && (
269      <Box flexDirection="column">
270        <Box flexDirection="row" gap={1}>
271          <Button
272            key="ask"
273            label={isAsked ? '添付を外す' : '添付'}
274            onPress={async () => {
275              await $.ui.status(undefined)
276              if (isAsked) return update($, asked, () => null)
277              const picked = await $.ui.selection()
278              const range = sel.diff && picked && !picked.requestId ? pickLines(sel.diff, picked.text) : undefined
279              await update($, asked, () => ({ root: sel.root, path: sel.path, ...(range && { range }) }))
280            }}
281          />
282          <Text bold>
283            {basename(sel.root)}/{sel.path}
284          </Text>
285        </Box>
286        {sel.diff === null ? (
287          <Text dimColor>読み込み中…</Text>
288        ) : fit?.source ? (
289          <Box flexDirection="column">
290            {fit.source.split(/(?=^@@ )/m).map((hunk, index) => {
291              const range = hunkRange(hunk)
292              const isAskedHunk = !!range && isSame(at, sel) && at?.range?.source === range.source
293              return (
294                <Box key={`hunk-box:${index}`} flexDirection="column">
295                  <Button
296                    key={`hunk:${index}`}
297                    label={isAskedHunk ? '添付を外す' : '添付'}
298                    onPress={async () => {
299                      await $.ui.status(undefined)
300                      if (!range) return
301                      await update($, asked, now =>
302                        isSame(now, sel) && now?.range?.source === range.source
303                          ? null
304                          : { root: sel.root, path: sel.path, range },
305                      )
306                    }}
307                  />
308                  <Code source={hunk} format="diff" path={sel.path} />
309                </Box>
310              )
311            })}
312            {fit.isCut && <Text dimColor>(長いので以降のハンクを省略しました)</Text>}
313          </Box>
314        ) : fit ? (
315          <Text dimColor>差分が大きすぎて表示できません</Text>
316        ) : (
317          <Text dimColor>表示できる差分がありません (バイナリ・大きすぎる差分など)</Text>
318        )}
319      </Box>
320    )
321
322    return (
323      <Box flexDirection="column">
324        {snaps.length === 0 && <Text dimColor>マージ前の変更はありません</Text>}
325        {snaps.map(snap => {
326          const isOpenRepo = isExpanded(snap.root)
327          const width = Math.max(...snap.files.map(f => countsWidth(counts(f))))
328          const marks = isOpenRepo ? '' : `${at?.root === snap.root ? ' 📎' : ''}${sel?.root === snap.root ? ' (表示中)' : ''}`
329          const task = delegatedTasks.get(snap.root)
330          const delegatedMark = delegatedTasks.has(snap.root) ? ` · 委譲${task ? `: ${task}` : ''}` : ''
331          return (
332            <Box key={`group:${snap.root}`} flexDirection="column" marginBottom={isOpenRepo ? 1 : 0}>
333              <Button
334                key={`repo:${snap.root}`}
335                plain
336                label={`${isOpenRepo ? '▾' : '▸'} ${basename(snap.root)} (${snap.files.length})${marks}${delegatedMark}`}
337                onPress={() => update($, expanded, now => ({ ...now, [snap.root]: !isOpenRepo }))}
338              />
339              {isOpenRepo && (
340                <Text dimColor>
341                  {snap.branch} · {snap.ahead} commits ahead of {snap.base}
342                </Text>
343              )}
344              {isOpenRepo &&
345                snap.files.map(f => {
346                  const id = `${snap.root}:${f.isUntracked ? 'untracked' : 'tracked'}:${f.path}`
347                  return (
348                    <Box key={`row:${id}`} flexDirection="column">
349                      <Box flexDirection="row" gap={1}>
350                        <Text color={STATUS_COLORS[f.status]} dimColor={f.isUntracked}>
351                          {f.status}
352                        </Text>
353                        {width > 0 && (
354                          <Box width={width} flexShrink={0} flexDirection="row" gap={1}>
355                            {counts(f).map(c => (
356                              <Text key={c.text} color={c.color} dimColor={!c.color}>
357                                {c.text}
358                              </Text>
359                            ))}
360                          </Box>
361                        )}
362                        <Button
363                          key={`file:${id}`}
364                          plain
365                          label={`${at?.root === snap.root && at.path === f.path ? '📎 ' : ''}${f.path}`}
366                          onPress={() =>
367                            sel?.root === snap.root && sel.path === f.path && sel.isUntracked === f.isUntracked
368                              ? unselect($)
369                              : select($, snap, f)
370                          }
371                        />
372                        {canOpen && f.status !== 'D' && (
373                          <Button key={`open:${id}`} plain label="[開く]" onPress={() => openInEditor($, editor, snap.root, f.path)} />
374                        )}
375                      </Box>
376                      {sel?.root === snap.root && sel.path === f.path && sel.isUntracked === f.isUntracked && selectedDiff}
377                    </Box>
378                  )
379                })}
380            </Box>
381          )
382        })}
383      </Box>
384    )
385  })
386}
387
hooks/git.ts 166 lines
1import type { FileChange, RepoSnapshot } from '../types'
2
3export type Git = (dir: string, args: string[]) => Promise<string | undefined>
4
5const PATH_ARG = `("[^"]+"|'[^']+'|[^\\s;&|)]+)`
6
7export function dirsInCommand(cmd: string, cwd: string, home: string): string[] {
8  const bases = [cwd]
9  const dirs: string[] = []
10  const resolve = (arg: string) => {
11    const p = arg.replace(/^(["'])(.*)\1$/, '$2').replace(/^~(?=\/|$)/, home)
12    return p.startsWith('/') ? [p] : bases.map(b => `${b}/${p}`)
13  }
14  for (const segment of cmd.split(/&&|\|\||[;|\n]/)) {
15    const cd = new RegExp(`^\\s*\\(*\\s*cd\\s+${PATH_ARG}`).exec(segment)?.[1]
16    if (cd) bases.push(...resolve(cd))
17    for (const m of segment.matchAll(new RegExp(`(?:^|\\s)(?:-C\\s+|--cwd[\\s=]+)${PATH_ARG}`, 'g'))) dirs.push(...resolve(m[1]!))
18  }
19  return [...new Set([...bases.slice(1), ...dirs])]
20}
21
22export function parseNumstat(out: string, statuses = new Map<string, string>()): FileChange[] {
23  return out
24    .split('\0')
25    .filter(Boolean)
26    .map(record => {
27      const [added, removed, ...rest] = record.split('\t')
28      const path = rest.join('\t')
29      return {
30        path,
31        status: statuses.get(path) ?? 'M',
32        added: added === '-' ? null : Number(added),
33        removed: removed === '-' ? null : Number(removed),
34        isUntracked: false,
35      }
36    })
37}
38
39export function parseNameStatus(out: string): Map<string, string> {
40  const fields = out.split('\0')
41  const statuses = new Map<string, string>()
42  for (let i = 0; i + 1 < fields.length; i += 2) {
43    if (fields[i]) statuses.set(fields[i + 1]!, fields[i]![0]!)
44  }
45  return statuses
46}
47
48export function fitHunks(diff: string, limit: number): { source: string; isCut: boolean } | undefined {
49  const at = diff.search(/^@@ /m)
50  if (at === -1) return undefined
51  let source = ''
52  const hunks = diff.slice(at).split(/(?=^@@ )/m)
53  for (const hunk of hunks) {
54    if (source.length + hunk.length > limit) break
55    source += hunk
56  }
57  return { source, isCut: source.length < diff.length - at }
58}
59
60async function baseRef(git: Git, root: string): Promise<string | undefined> {
61  const origin = (await git(root, ['rev-parse', '--abbrev-ref', 'origin/HEAD']))?.trim()
62  if (origin) return origin
63  for (const name of ['main', 'master']) {
64    if (await git(root, ['rev-parse', '--verify', '--quiet', name])) return name
65  }
66  return undefined
67}
68
69export async function snapshot(git: Git, root: string): Promise<RepoSnapshot | undefined> {
70  const base = await baseRef(git, root)
71  const mergeBase = base && (await git(root, ['merge-base', 'HEAD', base]))?.trim()
72  if (!base || !mergeBase) return undefined
73  const statuses = parseNameStatus((await git(root, ['diff', '--name-status', '-z', '--no-renames', mergeBase])) ?? '')
74  const tracked = parseNumstat((await git(root, ['diff', '--numstat', '-z', '--no-renames', mergeBase])) ?? '', statuses)
75  const untracked = ((await git(root, ['ls-files', '-z', '--others', '--exclude-standard'])) ?? '')
76    .split('\0')
77    .filter(Boolean)
78    .map(path => ({ path, status: '?', added: null, removed: null, isUntracked: true }))
79  const files = [...tracked, ...untracked]
80  if (files.length === 0) return undefined
81  return {
82    root,
83    base,
84    mergeBase,
85    files,
86    branch: (await git(root, ['branch', '--show-current']))?.trim() || '(detached)',
87    ahead: Number((await git(root, ['rev-list', '--count', `${mergeBase}..HEAD`]))?.trim() ?? 0),
88  }
89}
90
91type DiffLine = { hunk: number; kind: string; text: string; old: number; new: number }
92
93function diffLines(diff: string): DiffLine[] {
94  const at = diff.search(/^@@ /m)
95  if (at === -1) return []
96  const lines: DiffLine[] = []
97  diff
98    .slice(at)
99    .split(/(?=^@@ )/m)
100    .forEach((hunk, i) => {
101      const [header = '', ...body] = hunk.split('\n')
102      const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(header)
103      if (!m) return
104      let old = Number(m[1])
105      let now = Number(m[2])
106      for (const line of body) {
107        const kind = line[0]
108        if (kind !== ' ' && kind !== '+' && kind !== '-') continue
109        lines.push({ hunk: i, kind, text: line.slice(1), old, new: now })
110        if (kind !== '+') old++
111        if (kind !== '-') now++
112      }
113    })
114  return lines
115}
116
117const GUTTER = /^\s*(\d+\s+){0,2}[+-]?\s?/
118
119function isSameLine(picked: string, line: string): boolean {
120  const p = picked.trim()
121  const l = line.trim()
122  if (!l) return !/[^\d\s+-]/.test(p)
123  if (!p) return false
124  const bare = p.replace(GUTTER, '').trim()
125  return p.includes(l) || (bare.length > 0 && l.includes(bare))
126}
127
128export type PickedLines = { source: string; from: number; to: number; isOld: boolean }
129
130export function hunkRange(hunk: string): PickedLines | undefined {
131  const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(hunk)
132  if (!m) return undefined
133  const old = Number(m[1])
134  const olds = m[2] === undefined ? 1 : Number(m[2])
135  const now = Number(m[3])
136  const news = m[4] === undefined ? 1 : Number(m[4])
137  const source = hunk.endsWith('\n') ? hunk : `${hunk}\n`
138  return news > 0 ? { source, from: now, to: now + news - 1, isOld: false } : { source, from: old, to: old + olds - 1, isOld: true }
139}
140
141export function pickLines(diff: string, picked: string): PickedLines | undefined {
142  const lines = diffLines(diff)
143  const want = picked
144    .split('\n')
145    .filter(l => !l.startsWith('@@'))
146    .join('\n')
147    .replace(/^\s*\n|\n\s*$/g, '')
148    .split('\n')
149  if (!want.join('').trim()) return undefined
150  const start = lines.findIndex((_, i) => want.every((w, j) => lines[i + j] && isSameLine(w, lines[i + j]!.text)))
151  if (start === -1) return undefined
152  const chosen = lines.slice(start, start + want.length)
153  let source = ''
154  for (const hunk of new Set(chosen.map(l => l.hunk))) {
155    const part = chosen.filter(l => l.hunk === hunk)
156    const olds = part.filter(l => l.kind !== '+').length
157    const news = part.filter(l => l.kind !== '-').length
158    const old = olds ? part[0]!.old : part[0]!.old - 1
159    const now = news ? part[0]!.new : part[0]!.new - 1
160    source += `@@ -${old},${olds} +${now},${news} @@\n${part.map(l => l.kind + l.text + '\n').join('')}`
161  }
162  const added = chosen.filter(l => l.kind !== '-')
163  const side = added.length ? added.map(l => l.new) : chosen.map(l => l.old)
164  return { source, from: side[0]!, to: side[side.length - 1]!, isOld: added.length === 0 }
165}
166
types/index.d.ts 14 lines
1export type FileChange = { path: string; status: string; added: number | null; removed: number | null; isUntracked: boolean }
2
3export type RepoSnapshot = { root: string; branch: string; base: string; ahead: number; mergeBase: string; files: FileChange[] }
4
5export type Asked = { root: string; path: string; range?: { source: string; from: number; to: number; isOld: boolean } }
6
7export type Selection = { root: string; path: string; isUntracked: boolean; diff: string | null }
8
9declare module 'claude-code' {
10  interface PluginState {
11    'unmerged': { repos: string[]; snapshots: RepoSnapshot[]; selected: Selection | null; isOpen: boolean; asked: Asked | null; expanded: Record<string, boolean> }
12  }
13}
14