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

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.

With the default icons:
❐ PickleBoxer/dotfiles ⎇ main ↑2 ↓1 ± 4 files +83 -1
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.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.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.
Requires Claude Code v2.1.287 or later.
/plugin marketplace add PickleBoxer/claude-plugins
/plugin install drift@pickleboxer
git status --porcelain=v2.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.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 }
}
}
claude --plugin-dir . # loads the mod and reloads it on save
claude plugin validate .claude-plugin/plugin.json
claude plugin test .
MIT
hooks/register.tsx 472 lines1import { 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}
472hooks/git.ts 187 lines1import 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}
187types/index.d.ts 65 lines1// 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