SLOPSHOPPER

drift

Folder, branch, ahead/behind and uncommitted changes above the prompt, with a /drift pane for changes and branches

newpanebandguardcommandtoast
v0.2.2MITupdated 2026-10-06PickleBoxer/drift
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · drift
│ ┃ drift ✕ › fix the failing auth test and add an audit log call │ ┃ [ Changes (0) ] [ Branches ] [ Open in Finde │ ┃ ⏺ Read(src/auth.ts) │ ┃ Working tree clean. ⎿ 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 │ │ › /drift │ ⎿ drift: on detached at nothing, no upstream. working tree clean. │ │ ❐ ⎇ no commits ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
❐ ⎇ no commits ⟨Claude Code's own drawing⟩
Pane · drift
[ Changes (0) ] [ Branches ] [ Open in Finder ] [ Refresh ] Working tree clean.
README

drift

A Claude Code mod that shows how far your working tree has drifted from the last commit: the folder, the branch, how far it is ahead of or behind its upstream, and the uncommitted changes, above the prompt.

drift's band above the Claude Code prompt in the terminal, with Nerd Font icons

With the default icons:

❐ PickleBoxer/dotfiles   ⎇ main ↑2 ↓1   ± 4 files +83 -1
  • Band above the prompt: the repository as owner/repo from the origin remote (the folder name when there is none), the branch (or the commit when HEAD is detached), commits ahead and behind the upstream, and the changed files with lines added and removed. Untracked files count as added lines.
  • Press a part of the band: the folder opens in Finder, the branch opens the Branches tab, the changes open the Changes tab. Click it where your terminal reports clicks, or press ctrl+x tab to focus the band, then Tab and Enter.
  • /drift: a pane with two tabs. Changes (c) lists every changed file with its status and line counts, followed by the diff: every file stacked, or straight away the one file when only one changed. Pressing a file shows only its diff, All files (a) goes back. Branches (b) lists local branches with their upstream, ahead and behind counts and last commit, and when you last fetched. /drift branches opens that tab directly. Open in Finder (o) and Refresh (r) are there too.
  • Switching branches: press a branch, then Switch (y) or Cancel (n). drift refuses while tracked files have uncommitted changes, and shows git's own error when the switch fails.

The Desktop app already shows the folder, branch and changes above the prompt, so drift draws no band there.

Install

Requires Claude Code v2.1.287 or later.

/plugin marketplace add PickleBoxer/claude-plugins
/plugin install drift@pickleboxer

Where the numbers come from

  • Branch, upstream, ahead and behind, and the changed files come from git status --porcelain=v2.
  • Line counts come from git diff --numstat HEAD, so staged and unstaged changes count together. Untracked files are read and their lines counted, up to 200 files of at most 1 MB each. Binary files show as binary.
  • Ahead and behind compare with the upstream as of your last fetch. drift never fetches by itself, so it causes no network traffic and no SSH prompts.
  • When it refreshes: at session start, after every Edit, Write or Bash call, at the end of each turn, and every 5 seconds to catch changes made outside Claude. git runs with optional locks off, so the polling never blocks a git command you run yourself.

Configuration

terminalIcons picks the band's icons: plain (default) draws ❐ ⎇ ±, which work in any font, nerd draws Nerd Font icons for the folder, the branch and the diff. Change it in /config, or in ~/.claude/settings.json:

{
  "pluginConfigs": {
    "drift@pickleboxer": { "terminalIcons": "nerd" }
  }
}

showOwner (default on) shows the owner before the repository name, as owner/repo. Turn it off to show the repository name alone:

{
  "pluginConfigs": {
    "drift@pickleboxer": { "showOwner": false }
  }
}

Development

claude --plugin-dir .          # loads the mod and reloads it on save
claude plugin validate .claude-plugin/plugin.json
claude plugin test .

License

MIT

Source 3 files
hooks/register.tsx 472 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FileChange, Repo, Tab } from '../types'
5import { BRANCH_FORMAT, basename, countLines, parseBranches, parseNumstat, parseRemote, parseStatus, since, totals, withCounts } from './git'
6
7const PANE = 'drift'
8const TICK_MS = 5000
9// Untracked files counted line by line, and the largest one read
10const MAX_UNTRACKED = 200
11const MAX_UNTRACKED_BYTES = 1024 * 1024
12const MAX_DIFF_LINES = 1000
13// Tools that can change the working tree or move HEAD
14const WRITERS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'])
15
16const repoAtom = atom({ plugin: 'drift', key: 'repo' } as const, null)
17const branchesAtom = atom({ plugin: 'drift', key: 'branches' } as const, [])
18const tabAtom = atom({ plugin: 'drift', key: 'tab' } as const, 'changes')
19const selectedAtom = atom({ plugin: 'drift', key: 'selected' } as const, null)
20const diffAtom = atom({ plugin: 'drift', key: 'diff' } as const, null)
21const confirmAtom = atom({ plugin: 'drift', key: 'confirm' } as const, null)
22const noticeAtom = atom({ plugin: 'drift', key: 'notice' } as const, null)
23
24// Plain symbols for any font, and Nerd Font icons: fa-folder, powerline branch, oct-diff
25const ICONS = {
26  plain: { folder: '❐', branch: '⎇', diff: '±' },
27  nerd: { folder: '', branch: '', diff: '' },
28}
29
30const LETTER_COLOR: Record<string, string> = { A: 'green', '?': 'green', D: 'red', U: 'red', R: 'cyan', C: 'cyan' }
31
32type Git = { ok: boolean; stdout: string; stderr: string }
33
34async function git($: EngineInterface, cwd: string, args: string[]): Promise<Git> {
35  try {
36    // Optional locks off, so polling never fights a git command the person runs
37    const { exitCode, stdout, stderr } = await $.process.run(['git', '--no-optional-locks', ...args], { cwd, timeoutMs: 10000 })
38
39    return { ok: exitCode === 0, stdout, stderr }
40  } catch {
41    return { ok: false, stdout: '', stderr: 'git could not run' }
42  }
43}
44
45async function untrackedLines($: EngineInterface, root: string, path: string): Promise<number | null> {
46  try {
47    const stat = await $.fs.stat(`${root}/${path}`)
48
49    if (stat.kind !== 'file' || stat.size > MAX_UNTRACKED_BYTES) {
50      return null
51    }
52
53    return countLines(await $.fs.read(`${root}/${path}`))
54  } catch {
55    return null
56  }
57}
58
59async function fetchedAt($: EngineInterface, cwd: string): Promise<number | null> {
60  const gitDir = await git($, cwd, ['rev-parse', '--absolute-git-dir'])
61
62  try {
63    return gitDir.ok ? (await $.fs.stat(`${gitDir.stdout.trim()}/FETCH_HEAD`)).mtimeMs : null
64  } catch {
65    return null
66  }
67}
68
69async function loadRepo($: EngineInterface): Promise<Repo | null> {
70  const cwd = await $.session.cwd()
71  const top = await git($, cwd, ['rev-parse', '--show-toplevel'])
72
73  if (!top.ok) {
74    return null
75  }
76
77  const root = top.stdout.trim()
78  const status = parseStatus((await git($, root, ['status', '--porcelain=v2', '--branch', '--untracked-files=all', '-z'])).stdout)
79  // Before the first commit there is no HEAD, so count what is staged
80  const numstat = await git($, root, status.commit ? ['diff', '--numstat', '-z', 'HEAD'] : ['diff', '--numstat', '-z', '--cached'])
81  const counts = parseNumstat(numstat.stdout)
82  let untracked = 0
83
84  for (const file of status.files) {
85    if (file.state === 'untracked' && untracked++ < MAX_UNTRACKED) {
86      const lines = await untrackedLines($, root, file.path)
87      counts[file.path] = { added: lines, deleted: lines === null ? null : 0 }
88    }
89  }
90
91  const files = withCounts(status.files, counts)
92  const origin = await git($, root, ['remote', 'get-url', 'origin'])
93  const remote = origin.ok ? parseRemote(origin.stdout) : null
94
95  return {
96    root,
97    name: remote?.name ?? basename(root),
98    owner: remote?.owner ?? null,
99    branch: status.branch,
100    commit: status.commit,
101    upstream: status.upstream,
102    ahead: status.ahead,
103    behind: status.behind,
104    files,
105    ...totals(files),
106    fetchedAt: await fetchedAt($, root),
107  }
108}
109
110let isRefreshing = false
111
112async function refresh($: EngineInterface): Promise<void> {
113  if (isRefreshing) {
114    return
115  }
116
117  isRefreshing = true
118
119  try {
120    const before = await read($, repoAtom)
121    const repo = await loadRepo($)
122    await update($, repoAtom, () => repo)
123
124    // Keep a diff on screen current, without rereading it when nothing changed
125    if ((await read($, diffAtom)) !== null && JSON.stringify(before?.files) !== JSON.stringify(repo?.files)) {
126      const selected = await read($, selectedAtom)
127      const isGone = selected !== null && !repo?.files.some(file => file.path === selected)
128      await (selected === null || isGone ? loadAllDiffs($) : loadDiff($, selected))
129    }
130  } finally {
131    isRefreshing = false
132  }
133}
134
135async function loadBranches($: EngineInterface): Promise<void> {
136  const repo = await read($, repoAtom)
137  const output = repo ? await git($, repo.root, ['for-each-ref', 'refs/heads', `--format=${BRANCH_FORMAT}`]) : null
138
139  await update($, branchesAtom, () => (output?.ok ? parseBranches(output.stdout) : []))
140}
141
142function diffColor(line: string): string | undefined {
143  if (line.startsWith('+++') || line.startsWith('---')) {
144    return undefined
145  }
146
147  return line.startsWith('+') ? 'green' : line.startsWith('-') ? 'red' : line.startsWith('@@') ? 'cyan' : undefined
148}
149
150async function fileDiff($: EngineInterface, repo: Repo, file: FileChange): Promise<string> {
151  const base = repo.commit ? ['HEAD'] : ['--cached']
152  const args =
153    file.state === 'untracked'
154      ? ['diff', '--no-color', '--no-index', '--', '/dev/null', file.path]
155      : ['diff', '--no-color', ...base, '--', ...(file.from ? [file.from] : []), file.path]
156  // --no-index exits 1 when the files differ, so read the output either way
157  const { stdout, stderr } = await git($, repo.root, args)
158
159  return stdout || stderr
160}
161
162// One file's diff, or with no path every file's, stacked in the order of the list
163async function loadDiff($: EngineInterface, path: string | null): Promise<void> {
164  const repo = await read($, repoAtom)
165
166  if (!repo) {
167    return
168  }
169
170  const files = path === null ? repo.files.slice(0, MAX_UNTRACKED) : repo.files.filter(file => file.path === path)
171  const parts: string[] = []
172
173  for (const file of files) {
174    parts.push(await fileDiff($, repo, file))
175  }
176
177  await update($, selectedAtom, () => path)
178  await update($, diffAtom, () => parts.join('').trimEnd() || 'No textual changes.')
179}
180
181// What the Changes tab opens on: the single changed file, or all of them
182async function loadAllDiffs($: EngineInterface): Promise<void> {
183  const repo = await read($, repoAtom)
184  const only = repo?.files.length === 1 ? (repo.files[0]?.path ?? null) : null
185
186  await loadDiff($, only)
187}
188
189async function openPane($: EngineInterface, tab: Tab): Promise<void> {
190  await update($, tabAtom, () => tab)
191  await update($, noticeAtom, () => null)
192
193  if (tab === 'branches') {
194    await loadBranches($)
195  } else {
196    await loadAllDiffs($)
197  }
198
199  await $.ui.open({ id: PANE, title: 'drift', closeOnEscape: true, focus: true })
200}
201
202async function openFolder($: EngineInterface): Promise<void> {
203  const repo = await read($, repoAtom)
204
205  if (repo) {
206    await $.process.run(['open', repo.root])
207  }
208}
209
210async function askSwitch($: EngineInterface, branch: string): Promise<void> {
211  const repo = await read($, repoAtom)
212  const changed = repo?.files.filter(file => file.state !== 'untracked').length ?? 0
213
214  if (changed > 0) {
215    await update($, confirmAtom, () => null)
216    await update($, noticeAtom, () => `Commit or stash your ${changed} changed file${changed === 1 ? '' : 's'} before switching to ${branch}.`)
217
218    return
219  }
220
221  await update($, noticeAtom, () => null)
222  await update($, confirmAtom, () => branch)
223}
224
225async function switchBranch($: EngineInterface): Promise<void> {
226  const branch = await read($, confirmAtom)
227  const repo = await read($, repoAtom)
228
229  if (!branch || !repo) {
230    return
231  }
232
233  const result = await git($, repo.root, ['switch', branch])
234
235  await update($, confirmAtom, () => null)
236  await update($, noticeAtom, () => (result.ok ? null : result.stderr.trim() || `Could not switch to ${branch}.`))
237  await refresh($)
238  await loadBranches($)
239
240  if (result.ok) {
241    $.ui.toast(`Switched to ${branch}`)
242  }
243}
244
245async function summary($: EngineInterface): Promise<string> {
246  const repo = await read($, repoAtom)
247
248  if (!repo) {
249    return 'Not a git repository.'
250  }
251
252  const head = repo.branch ?? `detached at ${repo.commit ?? 'nothing'}`
253  const track = repo.upstream ? `, ${repo.ahead} ahead and ${repo.behind} behind ${repo.upstream}` : ', no upstream'
254  const changes = repo.files.length
255    ? `${repo.files.length} changed file${repo.files.length === 1 ? '' : 's'}, +${repo.added} -${repo.deleted}`
256    : 'working tree clean'
257
258  return `${repo.owner ? `${repo.owner}/` : ''}${repo.name} on ${head}${track}. ${changes}.`
259}
260
261export const register: Register = (on, options) => {
262  const icons = ICONS[options.terminalIcons === 'nerd' ? 'nerd' : 'plain']
263  const showOwner = options.showOwner !== false
264
265  on('session.start', async ($, e, next) => {
266    await $.command.register({
267      name: 'drift',
268      description: 'Show uncommitted changes and branches of this repository',
269      argumentHint: '[changes|branches]',
270    })
271    await refresh($)
272    $.clock.every(TICK_MS, () => void refresh($))
273
274    return next(e)
275  })
276
277  on('tool.call', async ($, e, next) => {
278    const result = await next(e)
279
280    if (WRITERS.has(e.tool)) {
281      void refresh($)
282    }
283
284    return result
285  })
286
287  on('turn.complete', async ($, e, next) => {
288    void refresh($)
289
290    return next(e)
291  })
292
293  on('command.run', { command: 'drift' }, async ($, e) => {
294    await refresh($)
295    await openPane($, e.args.trim() === 'branches' ? 'branches' : 'changes')
296
297    // Shown where nothing draws, such as the VS Code panel or claude -p
298    return { text: await summary($) }
299  })
300
301  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
302    const { Box, Button, Text } = $.ui.resolve(e)
303    const repo = await read($, repoAtom)
304    const tab = await read($, tabAtom)
305    const notice = await read($, noticeAtom)
306
307    if (!repo) {
308      return <Text dimColor>Not a git repository.</Text>
309    }
310
311    const header = (
312      <Box key="tabs" flexDirection="row" gap={1}>
313        <Button
314          key="tab-changes"
315          label={`Changes (${repo.files.length})`}
316          hotkey="c"
317          variant={tab === 'changes' ? 'primary' : undefined}
318          onPress={() => void openPane($, 'changes')}
319        />
320        <Button
321          key="tab-branches"
322          label="Branches"
323          hotkey="b"
324          variant={tab === 'branches' ? 'primary' : undefined}
325          onPress={() => void openPane($, 'branches')}
326        />
327        <Button key="finder" label="Open in Finder" hotkey="o" onPress={() => void openFolder($)} />
328        <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void refresh($).then(() => loadBranches($))} />
329      </Box>
330    )
331
332    if (tab === 'branches') {
333      const branches = await read($, branchesAtom)
334      const confirm = await read($, confirmAtom)
335      const now = await $.clock.now()
336
337      return (
338        <Box flexDirection="column" gap={1}>
339          {header}
340          <Box key="branches" flexDirection="column">
341            {branches.map(branch => (
342              <Box key={`row-${branch.name}`} flexDirection="row" gap={1}>
343                <Text color={branch.isCurrent ? 'green' : undefined}>{branch.isCurrent ? '●' : ' '}</Text>
344                {branch.isCurrent ? (
345                  <Text bold>{branch.name}</Text>
346                ) : (
347                  <Button key={`branch:${branch.name}`} plain label={branch.name} onPress={() => void askSwitch($, branch.name)} />
348                )}
349                {branch.ahead > 0 && <Text color="green">↑{branch.ahead}</Text>}
350                {branch.behind > 0 && <Text color="yellow">↓{branch.behind}</Text>}
351                <Text dimColor wrap="truncate">
352                  {branch.isGone ? `${branch.upstream} gone` : (branch.upstream ?? 'no upstream')} · {branch.updated}
353                </Text>
354              </Box>
355            ))}
356          </Box>
357          {confirm && (
358            <Box key="confirm" flexDirection="row" gap={1}>
359              <Text>Switch to</Text>
360              <Text bold>{confirm}</Text>
361              <Text>?</Text>
362              <Button key="confirm-switch" label="Switch" hotkey="y" variant="primary" autoFocus onPress={() => void switchBranch($)} />
363              <Button key="confirm-cancel" label="Cancel" hotkey="n" onPress={() => void update($, confirmAtom, () => null)} />
364            </Box>
365          )}
366          {notice && <Text color="yellow">{notice}</Text>}
367          <Text dimColor>
368            {repo.fetchedAt ? `Last fetched ${since(repo.fetchedAt, now)}` : 'Never fetched'}. drift never fetches by itself.
369          </Text>
370        </Box>
371      )
372    }
373
374    const selected = await read($, selectedAtom)
375    const diff = await read($, diffAtom)
376    const all = (diff ?? '').split('\n')
377    const lines = all.slice(0, MAX_DIFF_LINES)
378
379    return (
380      <Box flexDirection="column" gap={1}>
381        {header}
382        {repo.files.length === 0 && <Text dimColor>Working tree clean.</Text>}
383        <Box key="files" flexDirection="column">
384          {repo.files.map(file => (
385            <Box key={`row-${file.path}`} flexDirection="row" gap={1}>
386              <Text color={LETTER_COLOR[file.letter] ?? 'yellow'}>{file.letter}</Text>
387              <Button
388                key={`file:${file.path}`}
389                plain
390                label={file.from ? `${file.from} → ${file.path}` : file.path}
391                dimColor={selected !== null && selected !== file.path}
392                onPress={() => void loadDiff($, file.path)}
393              />
394              {file.added === null ? (
395                <Text dimColor>binary</Text>
396              ) : (
397                <Text>
398                  <Text color="green">+{file.added}</Text> <Text color="red">-{file.deleted}</Text>
399                </Text>
400              )}
401              <Text dimColor>{file.state}</Text>
402            </Box>
403          ))}
404        </Box>
405        {selected !== null && repo.files.length > 1 && (
406          <Button key="all-files" label="All files" hotkey="a" onPress={() => void loadDiff($, null)} />
407        )}
408        {diff && repo.files.length > 0 && (
409          <Box key="diff" flexDirection="column">
410            {lines.map((line, index) => (
411              <Text
412                key={`line-${index}`}
413                wrap="truncate"
414                color={diffColor(line)}
415                bold={line.startsWith('diff ')}
416                dimColor={/^(index |new file|deleted file|similarity|rename |--- |\+\+\+ )/.test(line)}
417              >
418                {line || ' '}
419              </Text>
420            ))}
421            {all.length > MAX_DIFF_LINES && <Text dimColor>{all.length - MAX_DIFF_LINES} more lines. Press a file to see only its diff.</Text>}
422          </Box>
423        )}
424      </Box>
425    )
426  })
427
428  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
429    const repo = await read($, repoAtom)
430
431    // The desktop app draws its own header with the same details
432    if (e.surface !== 'terminal' || e.props.hasSurvey || !repo) {
433      return next(e)
434    }
435
436    const { Box, Button, Text } = $.ui.resolve(e)
437    const isCompact = e.props.bodyColumns < 80
438    const head = repo.branch ?? repo.commit ?? 'no commits'
439    const count = repo.files.length
440    const below = await next(e)
441
442    return (
443      <Box flexDirection="column">
444        <Box flexDirection="row" gap={2} paddingLeft={1}>
445          <Box key="folder" flexDirection="row" gap={1}>
446            <Text color="cyan">{icons.folder}</Text>
447            <Box flexDirection="row">
448              {showOwner && repo.owner && !isCompact && <Text dimColor>{repo.owner}/</Text>}
449              <Button key="folder" plain label={repo.name} onPress={() => void openFolder($)} />
450            </Box>
451          </Box>
452          <Box key="branch" flexDirection="row" gap={1}>
453            <Text color="magenta">{icons.branch}</Text>
454            <Button key="branch" plain label={head} onPress={() => void openPane($, 'branches')} />
455            {repo.ahead > 0 && <Text color="green">↑{repo.ahead}</Text>}
456            {repo.behind > 0 && <Text color="yellow">↓{repo.behind}</Text>}
457          </Box>
458          {count > 0 && (
459            <Box key="diff" flexDirection="row" gap={1}>
460              <Text color="yellow">{icons.diff}</Text>
461              <Button key="diff" plain label={isCompact ? `${count}` : `${count} file${count === 1 ? '' : 's'}`} onPress={() => void openPane($, 'changes')} />
462              <Text color="green">+{repo.added}</Text>
463              <Text color="red">-{repo.deleted}</Text>
464            </Box>
465          )}
466        </Box>
467        {below}
468      </Box>
469    )
470  })
471}
472
hooks/git.ts 187 lines
1import type { Branch, FileChange, FileState } from '../types'
2
3export type Status = {
4  branch: string | null
5  commit: string | null
6  upstream: string | null
7  ahead: number
8  behind: number
9  files: FileChange[]
10}
11
12export type Counts = Record<string, { added: number | null; deleted: number | null }>
13
14// Fixed fields before the path in each `git status --porcelain=v2` entry
15const FIELDS: Record<string, number> = { '1': 8, '2': 9, u: 10 }
16
17function rest(entry: string, fields: number): string {
18  return entry.split(' ').slice(fields).join(' ')
19}
20
21function stateOf(xy: string): FileState {
22  const [index = '.', worktree = '.'] = xy
23
24  if (index !== '.' && worktree !== '.') {
25    return 'both'
26  }
27
28  return index !== '.' ? 'staged' : 'unstaged'
29}
30
31function letterOf(xy: string): string {
32  const [index = '.', worktree = '.'] = xy
33
34  return index !== '.' ? index : worktree
35}
36
37// Reads `git status --porcelain=v2 --branch -z`
38export function parseStatus(output: string): Status {
39  const status: Status = { branch: null, commit: null, upstream: null, ahead: 0, behind: 0, files: [] }
40  const entries = output.split('\0')
41
42  for (let i = 0; i < entries.length; i++) {
43    const entry = entries[i] ?? ''
44    const kind = entry[0]
45
46    if (entry.startsWith('# branch.oid ')) {
47      const oid = entry.slice(13)
48      status.commit = oid === '(initial)' ? null : oid.slice(0, 7)
49    } else if (entry.startsWith('# branch.head ')) {
50      const head = entry.slice(14)
51      status.branch = head === '(detached)' ? null : head
52    } else if (entry.startsWith('# branch.upstream ')) {
53      status.upstream = entry.slice(18)
54    } else if (entry.startsWith('# branch.ab ')) {
55      const [ahead = '+0', behind = '-0'] = entry.slice(12).split(' ')
56      status.ahead = Math.abs(Number(ahead))
57      status.behind = Math.abs(Number(behind))
58    } else if (kind === '?') {
59      status.files.push({ path: entry.slice(2), from: null, letter: '?', state: 'untracked', added: null, deleted: null })
60    } else if (kind === '1' || kind === '2' || kind === 'u') {
61      const xy = entry.slice(2, 4)
62      const path = rest(entry, FIELDS[kind] ?? 8)
63      // A rename's previous path is the next NUL-separated entry
64      const from = kind === '2' ? (entries[++i] ?? null) : null
65      const isConflict = kind === 'u'
66
67      status.files.push({
68        path,
69        from,
70        letter: isConflict ? 'U' : letterOf(xy),
71        state: isConflict ? 'conflict' : stateOf(xy),
72        added: null,
73        deleted: null,
74      })
75    }
76  }
77
78  return status
79}
80
81// Reads `git diff --numstat -z`, keyed by the file's current path
82export function parseNumstat(output: string): Counts {
83  const counts: Counts = {}
84  const parts = output.split('\0')
85
86  for (let i = 0; i < parts.length; i++) {
87    const [added, deleted, path] = (parts[i] ?? '').split('\t')
88
89    if (added === undefined || deleted === undefined || path === undefined) {
90      continue
91    }
92
93    // A rename has an empty path, followed by the old and the new path
94    const key = path === '' ? ((i += 2), parts[i] ?? '') : path
95    const isBinary = added === '-'
96
97    counts[key] = { added: isBinary ? null : Number(added), deleted: isBinary ? null : Number(deleted) }
98  }
99
100  return counts
101}
102
103// Lines in a new file, or null when it looks binary
104export function countLines(text: string): number | null {
105  if (text.includes('\0')) {
106    return null
107  }
108
109  if (text === '') {
110    return 0
111  }
112
113  return text.split('\n').length - (text.endsWith('\n') ? 1 : 0)
114}
115
116export function withCounts(files: FileChange[], counts: Counts): FileChange[] {
117  return files.map(file => {
118    const count = counts[file.path]
119
120    return count ? { ...file, ...count } : file
121  })
122}
123
124export function totals(files: FileChange[]): { added: number; deleted: number } {
125  return files.reduce(
126    (sum, file) => ({ added: sum.added + (file.added ?? 0), deleted: sum.deleted + (file.deleted ?? 0) }),
127    { added: 0, deleted: 0 },
128  )
129}
130
131export const BRANCH_FORMAT = '%(HEAD)%09%(refname:short)%09%(upstream:short)%09%(upstream:track,nobracket)%09%(committerdate:relative)'
132
133// Reads `git for-each-ref refs/heads --format=BRANCH_FORMAT`
134export function parseBranches(output: string): Branch[] {
135  return output
136    .split('\n')
137    .filter(line => line.includes('\t'))
138    .map(line => {
139      const [head = '', name = '', upstream = '', track = '', updated = ''] = line.split('\t')
140
141      return {
142        name,
143        upstream: upstream || null,
144        ahead: Number(/ahead (\d+)/.exec(track)?.[1] ?? 0),
145        behind: Number(/behind (\d+)/.exec(track)?.[1] ?? 0),
146        isGone: track === 'gone',
147        isCurrent: head === '*',
148        updated,
149      }
150    })
151}
152
153export function since(then: number, now: number): string {
154  const minutes = Math.max(0, Math.round((now - then) / 60000))
155
156  if (minutes < 1) {
157    return 'just now'
158  }
159
160  if (minutes < 60) {
161    return `${minutes} min ago`
162  }
163
164  const hours = Math.round(minutes / 60)
165
166  return hours < 48 ? `${hours} h ago` : `${Math.round(hours / 24)} days ago`
167}
168
169// The owner and repository of a remote URL: scp-like SSH, ssh:// or https://
170export function parseRemote(url: string): { owner: string; name: string } | null {
171  const path = url
172    .trim()
173    .replace(/^[a-z+]+:\/\/[^/]+\//i, '')
174    .replace(/^[^@/:]+@[^:]+:/, '')
175    .replace(/\.git$/, '')
176    .replace(/\/+$/, '')
177  const parts = path.split('/').filter(Boolean)
178  const name = parts.pop()
179  const owner = parts.pop()
180
181  return owner && name && !url.startsWith('/') ? { owner, name } : null
182}
183
184export function basename(path: string): string {
185  return path.replace(/\/+$/, '').split('/').pop() ?? path
186}
187
types/index.d.ts 65 lines
1// How a file differs from HEAD: staged, unstaged, both, untracked or conflicted
2export type FileState = 'staged' | 'unstaged' | 'both' | 'untracked' | 'conflict'
3
4export type FileChange = {
5  path: string
6  // The previous path of a rename or copy
7  from: string | null
8  // One status letter: M, A, D, R, C, T, U or ?
9  letter: string
10  state: FileState
11  // Null for binary files
12  added: number | null
13  deleted: number | null
14}
15
16export type Repo = {
17  root: string
18  name: string
19  // The owner on the origin remote's host, such as a GitHub user or organization
20  owner: string | null
21  // Null when HEAD is detached
22  branch: string | null
23  // Short commit id, null before the first commit
24  commit: string | null
25  upstream: string | null
26  ahead: number
27  behind: number
28  files: FileChange[]
29  added: number
30  deleted: number
31  // When the last fetch finished, from FETCH_HEAD
32  fetchedAt: number | null
33}
34
35export type Branch = {
36  name: string
37  upstream: string | null
38  ahead: number
39  behind: number
40  // The upstream branch was deleted on the remote
41  isGone: boolean
42  isCurrent: boolean
43  // When its last commit was made, as git words it ("3 days ago")
44  updated: string
45}
46
47export type Tab = 'changes' | 'branches'
48
49declare module 'claude-code' {
50  interface PluginState {
51    drift: {
52      repo: Repo | null
53      branches: Branch[]
54      tab: Tab
55      // The file whose diff the Changes tab shows
56      selected: string | null
57      diff: string | null
58      // The branch waiting for the person to confirm the switch
59      confirm: string | null
60      // A refusal or error shown in the pane
61      notice: string | null
62    }
63  }
64}
65