spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

diff: the pane reads the diff again after a rebase that finished (#98374)Browse files
Alice T'Poteat authored 23 days ago · GitHub committed 23 days ago parent f5f6025 · commit 9778ad7c09ee
15 changed files+182 -8compared with f5f6025
Mmods/diff/hooks/git/probes/index.ts
Mmods/diff/hooks/git/probes/is-transient.ts
Amods/diff/hooks/git/probes/rebase-marks/index.ts
Amods/diff/hooks/git/probes/rebase-marks/rebase-marks.ts
Mmods/diff/hooks/git/probes/transient-state-files/transient-state-files.ts
Amods/diff/tests/fixtures/fetch-diff/apply-mailing.ts
Amods/diff/tests/fixtures/fetch-diff/apply-rebasing.ts
Mmods/diff/tests/fixtures/fetch-diff/index.ts
Amods/diff/tests/fixtures/fetch-diff/mailing.ts
Amods/diff/tests/fixtures/fetch-diff/rebase-applying.ts
Amods/diff/tests/fixtures/fetch-diff/rebase-head-left.ts
Amods/diff/tests/fixtures/fetch-diff/rebase-linked.ts
Amods/diff/tests/fixtures/fetch-diff/rebase-merging.ts
Amods/diff/tests/fixtures/fetch-diff/rebase-paused.ts
Mmods/diff/tests/git/fetch-diff.test.ts
@@ -14,6 +14,7 @@
1414 export * from './merge-base-candidates-of'
1515 export * from './missing-base-of'
1616 export * from './overlay-unstaged.js'
17+export * from './rebase-marks'
1718 export * from './repository-lines'
1819 export * from './repository-of'
1920 export * from './stamp-if-file'
@@ -1,22 +1,38 @@
11 import type Types from '../types'
2+import { REBASE_MARKS } from './rebase-marks'
23 import { TRANSIENT_STATE_FILES } from './transient-state-files'
34
45 /**
56 * Whether the repository is mid-merge, mid-rebase, mid-cherry-pick or
6- * mid-revert, from one listing of the git directory.
7+ * mid-revert, from a listing of the git directory.
78 *
8- * A state file counts only as a real file; a symbolic link by that name is
9- * never touched.
9+ * A state file counts only as a real file and a rebase's folder only as a
10+ * real directory, listed once more to tell a rebase's from `git am`'s; a
11+ * symbolic link by any of those names is never touched.
1012 *
1113 * @param entryKindsOf the host's directory listing
1214 * @param gitDir the repository's own git directory, absolute
13- * @returns whether any of TRANSIENT_STATE_FILES is present as a file
15+ * @returns whether a state file, or a rebase's file beside its folder, is there
1416 */
1517 export async function isTransient(
1618 entryKindsOf: Types.GitDeps['entryKindsOf'],
1719 gitDir: string,
1820 ): Promise<boolean> {
1921 const entries = await entryKindsOf(gitDir)
22+ const isStopped = entries?.get(REBASE_MARKS.head) === 'file'
23+ const isApplying = isStopped && entries?.get(REBASE_MARKS.apply) === 'dir'
2024
21- return TRANSIENT_STATE_FILES.some(file => entries?.get(file) === 'file')
25+ const applied = isApplying
26+ ? await entryKindsOf(`${gitDir}/${REBASE_MARKS.apply}`)
27+ : null
28+
29+ const isRebasing =
30+ isStopped &&
31+ (entries?.get(REBASE_MARKS.merge) === 'dir' ||
32+ applied?.get(REBASE_MARKS.rebasing) === 'file')
33+
34+ return (
35+ isRebasing ||
36+ TRANSIENT_STATE_FILES.some(file => entries?.get(file) === 'file')
37+ )
2238 }
@@ -1,0 +1,3 @@
1+export * from './rebase-marks.js'
2+
3+export * as default from '.'
@@ -1,0 +1,14 @@
1+/**
2+ * What in the git directory says a rebase stopped mid-way: `head`, the file
3+ * naming the commit it stopped at, beside a folder the rebase still keeps.
4+ *
5+ * `head` alone is no sign: git leaves it behind a rebase that finished.
6+ * The folder is `merge`, or `apply` while it holds the file `rebasing`
7+ * (without it the folder is a stopped `git am`'s).
8+ */
9+export const REBASE_MARKS = {
10+ head: 'REBASE_HEAD',
11+ merge: 'rebase-merge',
12+ apply: 'rebase-apply',
13+ rebasing: 'rebasing',
14+} as const
@@ -1,10 +1,9 @@
11 /**
2- * The git-directory files that mean a merge, rebase, cherry-pick or revert
3- * is under way: the working tree then holds incoming changes, unread.
2+ * The git-directory files that mean a merge, cherry-pick or revert is under
3+ * way: the working tree then holds incoming changes, unread.
44 */
55 export const TRANSIENT_STATE_FILES = [
66 'MERGE_HEAD',
7- 'REBASE_HEAD',
87 'CHERRY_PICK_HEAD',
98 'REVERT_HEAD',
109 ] as const
@@ -1,0 +1,9 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * What the folder rebase-apply holds while `git am` owns it: the file
5+ * `applying`, and no `rebasing`.
6+ */
7+export const APPLY_MAILING: readonly (readonly [string, Git.EntryKind])[] = [
8+ ['applying', 'file'],
9+]
@@ -1,0 +1,9 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * What the folder rebase-apply holds while a rebase owns it: the file
5+ * `rebasing`.
6+ */
7+export const APPLY_REBASING: readonly (readonly [string, Git.EntryKind])[] = [
8+ ['rebasing', 'file'],
9+]
@@ -1,12 +1,20 @@
1+export * from './apply-mailing.js'
2+export * from './apply-rebasing.js'
13 export * from './before-session-ms.js'
24 export * from './deep-directories.js'
35 export * from './during-session-ms.js'
46 export * from './fetch-repository.js'
57 export * from './files-listing.js'
68 export * from './git-session-start-ms.js'
9+export * from './mailing.js'
710 export * from './merge-head-linked.js'
811 export * from './merging.js'
912 export * from './one-staged.js'
13+export * from './rebase-applying.js'
14+export * from './rebase-head-left.js'
15+export * from './rebase-linked.js'
16+export * from './rebase-merging.js'
17+export * from './rebase-paused.js'
1018 export * from './vs-head.js'
1119
1220 export * as default from '.'
@@ -1,0 +1,9 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * A git dir listing while `git am` stopped on a conflict: the folder it
5+ * shares with a rebase by name, and no REBASE_HEAD.
6+ */
7+export const MAILING: readonly (readonly [string, Git.EntryKind])[] = [
8+ ['rebase-apply', 'dir'],
9+]
@@ -1,0 +1,10 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * A git dir listing while a rebase that applies patches stopped on a
5+ * conflict; its folder holds APPLY_REBASING.
6+ */
7+export const REBASE_APPLYING: readonly (readonly [string, Git.EntryKind])[] = [
8+ ['REBASE_HEAD', 'file'],
9+ ['rebase-apply', 'dir'],
10+]
@@ -1,0 +1,9 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * A git dir listing after a rebase that finished: git left REBASE_HEAD behind
5+ * and removed the rebase's folder.
6+ */
7+export const REBASE_HEAD_LEFT: readonly (readonly [string, Git.EntryKind])[] = [
8+ ['REBASE_HEAD', 'file'],
9+]
@@ -1,0 +1,10 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * A git dir listing whose rebase-merge is a symbolic link beside a
5+ * REBASE_HEAD, so no rebase is under way as far as the fetch is concerned.
6+ */
7+export const REBASE_LINKED: readonly (readonly [string, Git.EntryKind])[] = [
8+ ['REBASE_HEAD', 'file'],
9+ ['rebase-merge', 'other'],
10+]
@@ -1,0 +1,10 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * A git dir listing while a rebase stopped on a conflict: the commit it
5+ * stopped at beside the folder the rebase keeps.
6+ */
7+export const REBASE_MERGING: readonly (readonly [string, Git.EntryKind])[] = [
8+ ['REBASE_HEAD', 'file'],
9+ ['rebase-merge', 'dir'],
10+]
@@ -1,0 +1,9 @@
1+import type Git from '../../../hooks/git'
2+
3+/**
4+ * A git dir listing while a rebase waits at a `break`: its folder, and no
5+ * REBASE_HEAD, which git writes only where a commit stopped it.
6+ */
7+export const REBASE_PAUSED: readonly (readonly [string, Git.EntryKind])[] = [
8+ ['rebase-merge', 'dir'],
9+]
@@ -77,6 +77,22 @@
7777 'ls-files': Fixtures.GIT_TIMED_OUT,
7878 })
7979
80+ const fetchedIn = (gitDir: Listing, applyDir: Listing = []) =>
81+ Git.fetchDiff(
82+ depsOf(trackedNoUntrackedOf(), {
83+ entryKindsOf: dir =>
84+ Promise.resolve(
85+ listingOf(
86+ new Map([
87+ ['/repo/.git', gitDir],
88+ ['/repo/.git/rebase-apply', applyDir],
89+ ]).get(dir) ?? Fixtures.FILES_LISTING,
90+ ),
91+ ),
92+ }),
93+ 'session',
94+ )
95+
8096 test('session mode: rows tagged by mtime, untracked merged', async () => {
8197 const outcome = await Git.fetchDiff(
8298 depsOf(
@@ -374,6 +390,48 @@
374390 expect(outcome.kind === 'data' && outcome.data.files).toHaveLength(1)
375391 })
376392
393+ test('a rebase stopped on a conflict: unavailable', async () => {
394+ expect(await fetchedIn(Fixtures.REBASE_MERGING)).toEqual({
395+ kind: 'unavailable',
396+ })
397+ })
398+
399+ test('a rebase applying patches, stopped: unavailable', async () => {
400+ expect(
401+ await fetchedIn(Fixtures.REBASE_APPLYING, Fixtures.APPLY_REBASING),
402+ ).toEqual({ kind: 'unavailable' })
403+ })
404+
405+ test('a REBASE_HEAD a finished rebase left is no rebase', async () => {
406+ expect(await fetchedIn(Fixtures.REBASE_HEAD_LEFT)).toMatchObject({
407+ kind: 'data',
408+ })
409+ })
410+
411+ test('git am beside a REBASE_HEAD left behind is no rebase', async () => {
412+ expect(
413+ await fetchedIn(Fixtures.REBASE_APPLYING, Fixtures.APPLY_MAILING),
414+ ).toMatchObject({ kind: 'data' })
415+ })
416+
417+ test('a rebase folder that is a symbolic link is no rebase', async () => {
418+ expect(await fetchedIn(Fixtures.REBASE_LINKED)).toMatchObject({
419+ kind: 'data',
420+ })
421+ })
422+
423+ test('git am stopped on a conflict reads as it did', async () => {
424+ expect(
425+ await fetchedIn(Fixtures.MAILING, Fixtures.APPLY_MAILING),
426+ ).toMatchObject({ kind: 'data' })
427+ })
428+
429+ test('a rebase waiting at a break reads as it did', async () => {
430+ expect(await fetchedIn(Fixtures.REBASE_PAUSED)).toMatchObject({
431+ kind: 'data',
432+ })
433+ })
434+
377435 test('a path under a symlinked directory: unlisted, undated', async () => {
378436 const listed: string[] = []
379437 const probed: string[] = []