- Shift-chosen commits must follow each other as first parents; the picker says why it keeps a run that leaves the line, and the Host refuses one with forgePanel/commits-not-linear for the Branch scope and change requests. - The Branch scope lists its newest maxCommits commits with --no-show-signature, so log.showSignature cannot corrupt the listing. - Viewed marks merge with the stored entry on each write and follow the storage event, so tabs of one Session keep each other's marks. - .gitattributes patterns follow Git's wildmatch rules, checked against git check-attr; change digests include file mode lines. - GitHub commit pagination refuses a repeated cursor or a changed count; commits validates the repository before its host like the other reads. - The commit list is read once per head, base, or refresh; the lens menu hides the count of the disabled Tests lens.
554 lines
30 KiB
TypeScript
554 lines
30 KiB
TypeScript
/** Local changes through real Git repositories: fork points, the four scopes, untracked files, new-side text, and the card's size. */
|
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import { FsError } from '@deepseek-ai/dsh-fs'
|
|
import { gitIn, inspectCheckout } from '../src/git.ts'
|
|
import type { Inspection } from '../src/git.ts'
|
|
import {
|
|
aheadOf, BaseMissing, baseBranches, chosenForkPoint, CommitsMissing, CommitsNotLinear, DiffTooLarge, forkPoint, localChanges,
|
|
localCommits, localDiff, localText, NoForkPoint,
|
|
} from '../src/local-diff.ts'
|
|
import type { DiffConfig, ForkPoint } from '../src/local-diff.ts'
|
|
import { FileLayers } from '../src/layers.ts'
|
|
import type { ForgeLocalScope, ForgeSessionWorktree } from '../src/types.ts'
|
|
import { git, gitContext, plainSession, repository, sandboxRecord, scratch } from './harness.ts'
|
|
|
|
const CONFIG: DiffConfig = {
|
|
gitCommand: 'git', gitTimeoutMs: 10_000, graceMs: 500, maxOutputBytes: 1_048_576, maxDiffBytes: 1_048_576, maxUntrackedFiles: 100,
|
|
}
|
|
|
|
/** Text files form the `test` layer and JSON files the `interface` layer. */
|
|
const LAYERS = new FileLayers(['**/untracked.txt'], ['**/*.json'])
|
|
|
|
const cleanups: (() => Promise<void>)[] = []
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks()
|
|
for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
|
|
})
|
|
|
|
async function directory(): Promise<string> {
|
|
const { path, remove } = await scratch('dsh-forge-diff-')
|
|
cleanups.push(remove)
|
|
return path
|
|
}
|
|
|
|
async function context(root: string): Promise<Context> {
|
|
const ctx = await gitContext(sandboxRecord(), root)
|
|
cleanups.push(() => ctx.fiber.dispose())
|
|
return ctx
|
|
}
|
|
|
|
const signal = new AbortController().signal
|
|
|
|
async function configure(repo: string): Promise<void> {
|
|
for (const [key, value] of [['core.autocrlf', 'false'], ['user.name', 'Forge fixture'], ['user.email', '[email protected]'], ['commit.gpgSign', 'false']]) {
|
|
await git(repo, 'config', key as string, value as string)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A clone of a bare `origin` whose `origin/HEAD` names `main`, on branch `feat`
|
|
* with `committed.txt` committed, `staged.txt` staged, `tracked.txt` changed in
|
|
* the working tree, `untracked.txt` untracked, an excluded log file, and an
|
|
* untracked nested repository.
|
|
*/
|
|
async function forked(root: string): Promise<{ readonly repo: string; readonly fork: string }> {
|
|
const seed = await repository(root, 'seed')
|
|
await git(root, 'init', '--quiet', '--bare', '-b', 'main', 'origin.git')
|
|
await git(seed, 'push', '--quiet', join(root, 'origin.git'), 'main')
|
|
await git(root, 'clone', '--quiet', join(root, 'origin.git'), 'repo')
|
|
const repo = join(root, 'repo')
|
|
await configure(repo)
|
|
await git(repo, 'checkout', '--quiet', '-b', 'feat')
|
|
const fork = await git(repo, 'rev-parse', 'HEAD')
|
|
await writeFile(join(repo, 'committed.txt'), 'one\n')
|
|
await git(repo, 'add', 'committed.txt')
|
|
await git(repo, 'commit', '--quiet', '-m', 'committed')
|
|
await writeFile(join(repo, 'staged.txt'), 'staged\n')
|
|
await git(repo, 'add', 'staged.txt')
|
|
await writeFile(join(repo, 'tracked.txt'), 'initial\nchanged\n')
|
|
await writeFile(join(repo, 'untracked.txt'), 'new\nfile\n')
|
|
await writeFile(join(repo, '.git', 'info', 'exclude'), '*.log\n')
|
|
await writeFile(join(repo, 'debug.log'), 'ignored\n')
|
|
await git(repo, 'init', '--quiet', 'nested')
|
|
return { repo, fork }
|
|
}
|
|
|
|
async function inspected(ctx: Context, repo: string): Promise<Inspection> {
|
|
const inspection = await inspectCheckout(ctx, CONFIG, plainSession(repo), repo, signal)
|
|
if (inspection === undefined) throw new Error('not a checkout')
|
|
return inspection
|
|
}
|
|
|
|
describe('forkPoint', () => {
|
|
it('forks the branch scope at the merge base with the default branch of the remote', async () => {
|
|
const { repo, fork } = await forked(await directory())
|
|
const ctx = await context(repo)
|
|
const git = gitIn(ctx, CONFIG, plainSession(repo), repo, signal)
|
|
|
|
await expect(forkPoint(git, await inspected(ctx, repo), null)).resolves.toEqual({ commit: fork, label: 'origin/main', worktree: false })
|
|
})
|
|
|
|
it('skips a remote without a default branch for the next one', async () => {
|
|
const { repo, fork } = await forked(await directory())
|
|
// The upstream's remote comes first, and this one names no default branch.
|
|
await git(repo, 'remote', 'add', 'mirror', 'https://example.invalid/a/b.git')
|
|
await git(repo, 'update-ref', 'refs/remotes/mirror/feat', 'HEAD')
|
|
await git(repo, 'branch', '--quiet', '--set-upstream-to=mirror/feat', 'feat')
|
|
const ctx = await context(repo)
|
|
const inspection = await inspected(ctx, repo)
|
|
|
|
expect(inspection.upstream).toBe('mirror/feat')
|
|
await expect(forkPoint(gitIn(ctx, CONFIG, plainSession(repo), repo, signal), inspection, null)).resolves.toEqual({ commit: fork, label: 'origin/main', worktree: false })
|
|
})
|
|
|
|
it.each([
|
|
['its base branch', 'release', 'release'],
|
|
['the commit it started from', null, 'cccccc0'],
|
|
])('forks a worktree the Session created at its recorded base, named by %s', async (_label, from, label) => {
|
|
const { repo } = await forked(await directory())
|
|
const ctx = await context(repo)
|
|
const created: ForgeSessionWorktree = { path: repo, branch: 'feat', from, baseCommit: `cccccc0${'1'.repeat(33)}`, repositoryRoot: repo }
|
|
|
|
await expect(forkPoint(gitIn(ctx, CONFIG, plainSession(repo), repo, signal), await inspected(ctx, repo), created))
|
|
.resolves.toEqual({ commit: created.baseCommit, label, worktree: true })
|
|
})
|
|
|
|
it('has no fork point without a remote default branch or a shared history', async () => {
|
|
const root = await directory()
|
|
const plain = await repository(root, 'plain')
|
|
const ctx = await context(plain)
|
|
const created: ForgeSessionWorktree = { path: join(root, 'elsewhere'), branch: 'x', from: null, baseCommit: 'a'.repeat(40), repositoryRoot: plain }
|
|
const confined = gitIn(ctx, CONFIG, plainSession(plain), plain, signal)
|
|
await expect(forkPoint(confined, await inspected(ctx, plain), created)).resolves.toBeNull()
|
|
|
|
const { repo } = await forked(await directory())
|
|
await git(repo, 'stash', '--quiet', '--include-untracked')
|
|
await git(repo, 'checkout', '--quiet', '--orphan', 'solo')
|
|
await git(repo, 'commit', '--quiet', '-m', 'solo')
|
|
const orphan = await context(repo)
|
|
const unrelated = gitIn(orphan, CONFIG, plainSession(repo), repo, signal)
|
|
await expect(forkPoint(unrelated, await inspected(orphan, repo), null)).resolves.toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('chosenForkPoint', () => {
|
|
it('forks the branch scope at the merge base of HEAD with a chosen remote-tracking or local branch', async () => {
|
|
const { repo, fork } = await forked(await directory())
|
|
await git(repo, 'branch', 'topic')
|
|
const head = await git(repo, 'rev-parse', 'HEAD')
|
|
const confined = gitIn(await context(repo), CONFIG, plainSession(repo), repo, signal)
|
|
|
|
await expect(chosenForkPoint(confined, 'refs/remotes/origin/main')).resolves.toEqual({ commit: fork, label: 'origin/main', worktree: false })
|
|
await expect(chosenForkPoint(confined, 'refs/heads/topic')).resolves.toEqual({ commit: head, label: 'topic', worktree: false })
|
|
})
|
|
|
|
it.each(['refs/heads/gone', 'refs/heads/feat@{1}', 'refs/remotes/origin/a..b'])('reports the missing base branch %s', async (ref) => {
|
|
const { repo } = await forked(await directory())
|
|
const confined = gitIn(await context(repo), CONFIG, plainSession(repo), repo, signal)
|
|
|
|
await expect(chosenForkPoint(confined, ref)).rejects.toBeInstanceOf(BaseMissing)
|
|
})
|
|
|
|
it('has no fork point with a branch that shares no history with HEAD', async () => {
|
|
const { repo } = await forked(await directory())
|
|
await git(repo, 'stash', '--quiet', '--include-untracked')
|
|
await git(repo, 'checkout', '--quiet', '--orphan', 'solo')
|
|
await git(repo, 'commit', '--quiet', '-m', 'solo')
|
|
const confined = gitIn(await context(repo), CONFIG, plainSession(repo), repo, signal)
|
|
|
|
await expect(chosenForkPoint(confined, 'refs/heads/feat')).resolves.toBeNull()
|
|
})
|
|
|
|
it.each(['HEAD', 'main', 'refs/tags/v1', 'refs/heads/', '--output=x'])('refuses %j, which names no branch', async (ref) => {
|
|
const { repo } = await forked(await directory())
|
|
const confined = gitIn(await context(repo), CONFIG, plainSession(repo), repo, signal)
|
|
|
|
await expect(chosenForkPoint(confined, ref)).rejects.toThrow('is not a branch ref')
|
|
})
|
|
})
|
|
|
|
describe('baseBranches', () => {
|
|
it('lists the automatic fork point, remote-tracking branches without symbolic refs, and local branches but the checked-out one', async () => {
|
|
const { repo } = await forked(await directory())
|
|
await git(repo, 'branch', 'Release-2')
|
|
await git(repo, 'update-ref', 'refs/remotes/origin/release/2.4', 'HEAD')
|
|
await git(repo, 'tag', 'v1')
|
|
const ctx = await context(repo)
|
|
const confined = gitIn(ctx, CONFIG, plainSession(repo), repo, signal)
|
|
const listed = await baseBranches(confined, await inspected(ctx, repo), null, '', 50)
|
|
const names = (branches: readonly { readonly name: string }[]): string[] => branches.map(branch => branch.name).sort()
|
|
|
|
expect(listed.automatic).toEqual({ name: 'origin/main', worktree: false })
|
|
expect(names(listed.remote)).toEqual(['origin/main', 'origin/release/2.4'])
|
|
expect(listed.remote).toContainEqual({ ref: 'refs/remotes/origin/release/2.4', name: 'origin/release/2.4' })
|
|
expect(names(listed.local)).toEqual(['Release-2', 'main'])
|
|
|
|
const searched = await baseBranches(confined, await inspected(ctx, repo), null, ' release ', 1)
|
|
expect(searched.automatic).toEqual({ name: 'origin/main', worktree: false })
|
|
expect([searched.remote, searched.local].map(names)).toEqual([['origin/release/2.4'], ['Release-2']])
|
|
})
|
|
|
|
it('names the start of the worktree the Session created, or no automatic fork point without one', async () => {
|
|
const { repo } = await forked(await directory())
|
|
const ctx = await context(repo)
|
|
const created: ForgeSessionWorktree = { path: repo, branch: 'feat', from: 'main', baseCommit: 'a'.repeat(40), repositoryRoot: repo }
|
|
const confined = gitIn(ctx, CONFIG, plainSession(repo), repo, signal)
|
|
await expect(baseBranches(confined, await inspected(ctx, repo), created, '', 50)).resolves.toMatchObject({ automatic: { name: 'main', worktree: true } })
|
|
|
|
const plain = await repository(await directory(), 'plain')
|
|
const bare = await context(plain)
|
|
await expect(baseBranches(gitIn(bare, CONFIG, plainSession(plain), plain, signal), await inspected(bare, plain), null, '', 50))
|
|
.resolves.toEqual({ automatic: null, remote: [], local: [] })
|
|
})
|
|
})
|
|
|
|
describe('localDiff', () => {
|
|
it.each([
|
|
[{ kind: 'branch', base: null, commits: null }, 'origin/main', ['committed.txt added', 'staged.txt added', 'tracked.txt modified', 'untracked.txt added untracked']],
|
|
[{ kind: 'uncommitted' }, null, ['staged.txt added', 'tracked.txt modified', 'untracked.txt added untracked']],
|
|
[{ kind: 'unstaged' }, null, ['tracked.txt modified', 'untracked.txt added untracked']],
|
|
[{ kind: 'staged' }, null, ['staged.txt added']],
|
|
] as const)('reads the %o scope with untracked files last, leaving out ignored files and nested repositories', async (scope, base, files) => {
|
|
const { repo, fork } = await forked(await directory())
|
|
const ctx = await context(repo)
|
|
const diff = await localDiff(ctx, CONFIG, plainSession(repo), repo, { commit: fork, label: 'origin/main', worktree: false }, scope, LAYERS, signal)
|
|
|
|
expect(diff.base).toBe(base)
|
|
expect(diff.omitted).toBe(0)
|
|
expect(diff.files.map(file => `${file.path} ${file.status}${file.untracked ? ' untracked' : ''}`)).toEqual(files)
|
|
})
|
|
|
|
it('carries the hunks of tracked and untracked files', async () => {
|
|
const { repo, fork } = await forked(await directory())
|
|
const diff = await localDiff(await context(repo), CONFIG, plainSession(repo), repo, { commit: fork, label: 'origin/main', worktree: false }, { kind: 'unstaged' }, LAYERS, signal)
|
|
|
|
expect(diff.files.map(file => [file.additions, file.deletions, file.hunks.flatMap(hunk => hunk.lines)])).toEqual([
|
|
[1, 0, [' initial', '+changed']],
|
|
[2, 0, ['+new', '+file']],
|
|
])
|
|
})
|
|
|
|
it('refuses the branch scope without a fork point', async () => {
|
|
const { repo } = await forked(await directory())
|
|
|
|
await expect(localDiff(await context(repo), CONFIG, plainSession(repo), repo, null, { kind: 'branch', base: null, commits: null }, LAYERS, signal)).rejects.toBeInstanceOf(NoForkPoint)
|
|
})
|
|
|
|
it('compares a branch without commits with the empty tree', async () => {
|
|
const root = await directory()
|
|
await git(root, 'init', '--quiet', '-b', 'main', 'fresh')
|
|
const repo = join(root, 'fresh')
|
|
await writeFile(join(repo, 'first.txt'), 'first\n')
|
|
await git(repo, 'add', 'first.txt')
|
|
const ctx = await context(repo)
|
|
|
|
for (const kind of ['staged', 'uncommitted'] as const) {
|
|
const diff = await localDiff(ctx, CONFIG, plainSession(repo), repo, null, { kind }, LAYERS, signal)
|
|
expect(diff.files.map(file => [file.path, file.status])).toEqual([['first.txt', 'added']])
|
|
}
|
|
})
|
|
|
|
it('reports a patch over maxDiffBytes', async () => {
|
|
const { repo, fork } = await forked(await directory())
|
|
await writeFile(join(repo, 'tracked.txt'), 'line\n'.repeat(400))
|
|
|
|
await expect(localDiff(await context(repo), { ...CONFIG, maxDiffBytes: 512 }, plainSession(repo), repo, { commit: fork, label: 'x', worktree: false }, { kind: 'unstaged' }, LAYERS, signal))
|
|
.rejects.toBeInstanceOf(DiffTooLarge)
|
|
})
|
|
|
|
it('lists at most maxUntrackedFiles untracked files, marking binary and oversized ones and skipping unreadable ones', async () => {
|
|
const { repo, fork } = await forked(await directory())
|
|
await mkdir(join(repo, 'more'))
|
|
await writeFile(join(repo, 'more', 'big.txt'), 'x'.repeat(4096))
|
|
await writeFile(join(repo, 'more', 'blob.bin'), Uint8Array.from([0, 1, 2]))
|
|
await writeFile(join(repo, 'more', 'gone.txt'), 'gone\n')
|
|
await writeFile(join(repo, 'zzz.txt'), 'last\n')
|
|
const ctx = await context(repo)
|
|
const read = ctx.fs.readBytes.bind(ctx.fs)
|
|
vi.spyOn(ctx.fs, 'readBytes').mockImplementation(async (target, readSignal, maxBytes) => {
|
|
if (ctx.fs.processPath(target).endsWith('gone.txt')) throw new FsError('gone', 'FS_NOT_FOUND')
|
|
return await read(target, readSignal, maxBytes)
|
|
})
|
|
const config = { ...CONFIG, maxDiffBytes: 2048, maxUntrackedFiles: 4 }
|
|
const diff = await localDiff(ctx, config, plainSession(repo), repo, { commit: fork, label: 'x', worktree: false }, { kind: 'unstaged' }, LAYERS, signal)
|
|
|
|
expect(diff.files.filter(file => file.untracked).map(file => [file.path, file.binary, file.oversized, file.additions])).toEqual([
|
|
['more/big.txt', false, true, 0],
|
|
['more/blob.bin', true, false, 0],
|
|
['untracked.txt', false, false, 2],
|
|
])
|
|
expect(diff.omitted).toBe(1)
|
|
})
|
|
|
|
it('stops at a read failure that is not a filesystem refusal', async () => {
|
|
const { repo, fork } = await forked(await directory())
|
|
const ctx = await context(repo)
|
|
vi.spyOn(ctx.fs, 'readBytes').mockRejectedValue(new Error('disk on fire'))
|
|
|
|
await expect(localDiff(ctx, CONFIG, plainSession(repo), repo, { commit: fork, label: 'x', worktree: false }, { kind: 'unstaged' }, LAYERS, signal))
|
|
.rejects.toThrow('disk on fire')
|
|
})
|
|
})
|
|
|
|
describe('review layers', () => {
|
|
it('classifies files by the globs and the root .gitattributes of the working tree, or of the index for the staged scope', async () => {
|
|
const { repo, fork } = await forked(await directory())
|
|
const point = { commit: fork, label: 'origin/main', worktree: false }
|
|
await writeFile(join(repo, 'config.json'), '{}\n')
|
|
await writeFile(join(repo, '.gitattributes'), 'tracked.txt linguist-generated\n')
|
|
const ctx = await context(repo)
|
|
const layers = async (scope: ForgeLocalScope): Promise<string[]> =>
|
|
(await localDiff(ctx, CONFIG, plainSession(repo), repo, point, scope, LAYERS, signal)).files.map(file => `${file.path} ${file.layer}`)
|
|
|
|
expect(await layers({ kind: 'unstaged' })).toEqual([
|
|
'tracked.txt test', '.gitattributes implementation', 'config.json interface', 'untracked.txt test',
|
|
])
|
|
await writeFile(join(repo, '.gitattributes'), 'staged.txt linguist-generated\n')
|
|
await git(repo, 'add', '.gitattributes')
|
|
await writeFile(join(repo, '.gitattributes'), '')
|
|
expect(await layers({ kind: 'staged' })).toEqual(['.gitattributes implementation', 'staged.txt test'])
|
|
expect(await layers({ kind: 'uncommitted' })).toEqual([
|
|
'.gitattributes implementation', 'staged.txt implementation', 'tracked.txt implementation', 'config.json interface', 'untracked.txt test',
|
|
])
|
|
})
|
|
|
|
it('reads no attributes for a staged scope without .gitattributes and stops at an unreadable working tree file', async () => {
|
|
const { repo, fork } = await forked(await directory())
|
|
const ctx = await context(repo)
|
|
const point = { commit: fork, label: 'origin/main', worktree: false }
|
|
|
|
await expect(localDiff(ctx, CONFIG, plainSession(repo), repo, point, { kind: 'staged' }, LAYERS, signal))
|
|
.resolves.toMatchObject({ files: [{ path: 'staged.txt', layer: 'implementation' }] })
|
|
await writeFile(join(repo, '.gitattributes'), Uint8Array.from([0x74, 0, 0x74]))
|
|
await expect(localDiff(ctx, CONFIG, plainSession(repo), repo, point, { kind: 'unstaged' }, LAYERS, signal))
|
|
.resolves.toMatchObject({ files: [{ path: 'tracked.txt', layer: 'implementation' }, { path: '.gitattributes' }, { path: 'untracked.txt' }] })
|
|
const read = ctx.fs.readBytes.bind(ctx.fs)
|
|
vi.spyOn(ctx.fs, 'readBytes').mockImplementation(async (target, readSignal, maxBytes) => {
|
|
if (ctx.fs.processPath(target).endsWith('.gitattributes')) throw new FsError('denied', 'FS_PERMISSION_DENIED')
|
|
return await read(target, readSignal, maxBytes)
|
|
})
|
|
await expect(localDiff(ctx, CONFIG, plainSession(repo), repo, point, { kind: 'unstaged' }, LAYERS, signal)).rejects.toThrow('denied')
|
|
})
|
|
})
|
|
|
|
describe('commits of the branch scope', () => {
|
|
/** {@link forked} with two more commits on `feat`, `second.txt` and then `.gitattributes` with a generated `third.json`. */
|
|
async function committed(): Promise<{ readonly repo: string; readonly point: ForkPoint; readonly shas: readonly string[] }> {
|
|
const { repo, fork } = await forked(await directory())
|
|
await git(repo, 'commit', '--quiet', '-m', 'staged')
|
|
await writeFile(join(repo, 'second.txt'), 'two\n')
|
|
await git(repo, 'add', 'second.txt')
|
|
await git(repo, 'commit', '--quiet', '-m', 'second')
|
|
await writeFile(join(repo, '.gitattributes'), 'third.json linguist-generated\n')
|
|
await writeFile(join(repo, 'third.json'), '{}\n')
|
|
await git(repo, 'add', '.gitattributes', 'third.json')
|
|
await git(repo, 'commit', '--quiet', '-m', 'third')
|
|
const shas = (await git(repo, 'rev-list', '--reverse', `${fork}..HEAD`)).split('\n')
|
|
return { repo, point: { commit: fork, label: 'origin/main', worktree: false }, shas }
|
|
}
|
|
|
|
it('lists the commits between the fork point and HEAD oldest first, keeping the newest ones over the limit', async () => {
|
|
const { repo, point, shas } = await committed()
|
|
const ctx = await context(repo)
|
|
const runner = gitIn(ctx, CONFIG, plainSession(repo), repo, signal)
|
|
|
|
const listed = await localCommits(runner, point, 10)
|
|
expect(listed).toMatchObject({ headSha: shas.at(-1), truncated: false })
|
|
expect(listed.commits.map(commit => [commit.title, commit.author])).toEqual([
|
|
['committed', 'Forge fixture'], ['staged', 'Forge fixture'], ['second', 'Forge fixture'], ['third', 'Forge fixture'],
|
|
])
|
|
expect(listed.commits.map(commit => commit.sha)).toEqual(shas)
|
|
expect(listed.commits.map(commit => commit.parent)).toEqual([point.commit, ...shas.slice(0, -1)])
|
|
expect(Date.parse(listed.commits[0]?.authoredAt ?? '')).not.toBeNaN()
|
|
await expect(localCommits(runner, point, 2)).resolves.toMatchObject({ truncated: true, commits: [{ title: 'second' }, { title: 'third' }] })
|
|
await expect(localCommits(runner, point, 4)).resolves.toMatchObject({ truncated: false, commits: { length: 4 } })
|
|
})
|
|
|
|
it('lists signed commits while the repository asks Git to show signatures', async () => {
|
|
const { repo, point, shas } = await committed()
|
|
const head = shas.at(-1) ?? ''
|
|
// A signature Git cannot verify still makes `log.showSignature` print verification text before each signed commit.
|
|
const tree = await git(repo, 'rev-parse', 'HEAD^{tree}')
|
|
const signed = join(repo, '.git', 'signed-commit')
|
|
await writeFile(signed, [
|
|
`tree ${tree}`, `parent ${head}`, 'author Forge fixture <[email protected]> 1700000000 +0000',
|
|
'committer Forge fixture <[email protected]> 1700000000 +0000',
|
|
'gpgsig -----BEGIN SSH SIGNATURE-----', ' AAAA', ' -----END SSH SIGNATURE-----', '', 'signed', '',
|
|
].join('\n'))
|
|
const sha = await git(repo, 'hash-object', '-t', 'commit', '-w', signed)
|
|
await git(repo, 'reset', '--quiet', '--soft', sha)
|
|
await git(repo, 'config', 'log.showSignature', 'true')
|
|
await git(repo, 'config', 'gpg.ssh.program', join(repo, 'missing-ssh-keygen'))
|
|
const ctx = await context(repo)
|
|
|
|
const listed = await localCommits(gitIn(ctx, CONFIG, plainSession(repo), repo, signal), point, 10)
|
|
expect(listed.commits.map(commit => [commit.sha, commit.title])).toEqual([
|
|
...shas.map((commit, index) => [commit, ['committed', 'staged', 'second', 'third'][index]]), [sha, 'signed'],
|
|
])
|
|
})
|
|
|
|
it('reads the committed changes of consecutive commits with the attributes of the last one, without working tree changes', async () => {
|
|
const { repo, point, shas } = await committed()
|
|
const ctx = await context(repo)
|
|
const range = async (from: number, to: number): Promise<string[]> => {
|
|
const diff = await localDiff(ctx, CONFIG, plainSession(repo), repo, point,
|
|
{ kind: 'branch', base: null, commits: { from: shas[from] ?? '', to: shas[to] ?? '' } }, LAYERS, signal)
|
|
expect(diff).toMatchObject({ base: 'origin/main', omitted: 0 })
|
|
return diff.files.map(file => `${file.path} ${file.layer}`)
|
|
}
|
|
|
|
expect(await range(2, 2)).toEqual(['second.txt implementation'])
|
|
expect(await range(1, 3)).toEqual(['.gitattributes implementation', 'second.txt implementation', 'staged.txt implementation', 'third.json test'])
|
|
await expect(localText(ctx, CONFIG, plainSession(repo), repo,
|
|
{ kind: 'branch', base: null, commits: { from: shas[2] ?? '', to: shas[2] ?? '' } }, 'second.txt', signal)).resolves.toBe('two\n')
|
|
})
|
|
|
|
it('compares a first commit without a parent with the empty tree', async () => {
|
|
const { repo, point } = await committed()
|
|
await git(repo, 'checkout', '--quiet', '--orphan', 'other')
|
|
await git(repo, 'rm', '--quiet', '-r', '-f', '--cached', '.')
|
|
await writeFile(join(repo, 'orphan.txt'), 'root\n')
|
|
await git(repo, 'add', 'orphan.txt')
|
|
await git(repo, 'commit', '--quiet', '-m', 'orphan')
|
|
const orphan = await git(repo, 'rev-parse', 'HEAD')
|
|
await git(repo, 'checkout', '--quiet', '--force', 'feat')
|
|
await git(repo, 'merge', '--quiet', '--allow-unrelated-histories', '--no-edit', 'other')
|
|
const ctx = await context(repo)
|
|
|
|
const listed = await localCommits(gitIn(ctx, CONFIG, plainSession(repo), repo, signal), point, 10)
|
|
expect(listed.commits.find(commit => commit.sha === orphan)?.parent).toBeNull()
|
|
const diff = await localDiff(ctx, CONFIG, plainSession(repo), repo, point,
|
|
{ kind: 'branch', base: null, commits: { from: orphan, to: orphan } }, LAYERS, signal)
|
|
expect(diff.files.map(file => [file.path, file.status])).toEqual([['orphan.txt', 'added']])
|
|
})
|
|
|
|
it('refuses a run of commits whose first parents leave it, and reads one that follows them', async () => {
|
|
const { repo, fork } = await forked(await directory())
|
|
await git(repo, 'commit', '--quiet', '-m', 'staged')
|
|
await git(repo, 'checkout', '--quiet', '-b', 'side', fork)
|
|
for (const name of ['side-one', 'side-two']) {
|
|
await writeFile(join(repo, `${name}.txt`), `${name}\n`)
|
|
await git(repo, 'add', `${name}.txt`)
|
|
await git(repo, 'commit', '--quiet', '-m', name)
|
|
}
|
|
await git(repo, 'checkout', '--quiet', 'feat')
|
|
await git(repo, 'merge', '--quiet', '--no-ff', '--no-edit', 'side')
|
|
const point: ForkPoint = { commit: fork, label: 'origin/main', worktree: false }
|
|
const ctx = await context(repo)
|
|
const listed = (await localCommits(gitIn(ctx, CONFIG, plainSession(repo), repo, signal), point, 10)).commits
|
|
const read = (from: string, to: string): Promise<{ readonly files: readonly { readonly path: string }[] }> =>
|
|
localDiff(ctx, CONFIG, plainSession(repo), repo, point, { kind: 'branch', base: null, commits: { from, to } }, LAYERS, signal)
|
|
// Two branches from the fork point list their commits one after the other, so one pair of neighbours is unrelated.
|
|
const leaving = listed.findIndex((commit, index) => index > 0 && commit.parent !== listed[index - 1]?.sha)
|
|
const before = listed[leaving - 1]?.sha ?? ''
|
|
const after = listed[leaving]?.sha ?? ''
|
|
|
|
expect(leaving).toBeGreaterThan(0)
|
|
await expect(read(before, after)).rejects.toBeInstanceOf(CommitsNotLinear)
|
|
const side = listed.filter(commit => commit.title.startsWith('side-'))
|
|
expect((await read(side[0]?.sha ?? '', side[1]?.sha ?? '')).files.map(file => file.path)).toEqual(['side-one.txt', 'side-two.txt'])
|
|
})
|
|
|
|
it('refuses commits that are not full object names, not on the branch, or out of order', async () => {
|
|
const { repo, point, shas } = await committed()
|
|
const ctx = await context(repo)
|
|
const read = (from: string, to: string): Promise<unknown> =>
|
|
localDiff(ctx, CONFIG, plainSession(repo), repo, point, { kind: 'branch', base: null, commits: { from, to } }, LAYERS, signal)
|
|
const [first = '', second = ''] = shas
|
|
|
|
await expect(read('HEAD', second)).rejects.toBeInstanceOf(CommitsMissing)
|
|
await expect(read(first, '--output=x')).rejects.toBeInstanceOf(CommitsMissing)
|
|
await expect(read(point.commit, second)).rejects.toThrow('The chosen commits are no longer on the branch')
|
|
await expect(read(second, first)).rejects.toThrow('The chosen commits are no longer on the branch')
|
|
await expect(localText(ctx, CONFIG, plainSession(repo), repo,
|
|
{ kind: 'branch', base: null, commits: { from: first, to: 'HEAD' } }, 'second.txt', signal)).rejects.toBeInstanceOf(CommitsMissing)
|
|
})
|
|
})
|
|
|
|
describe('localText', () => {
|
|
const branch: ForgeLocalScope = { kind: 'branch', base: null, commits: null }
|
|
|
|
it('reads the working tree, or the index for the staged scope', async () => {
|
|
const { repo } = await forked(await directory())
|
|
await writeFile(join(repo, 'staged.txt'), 'edited after staging\n')
|
|
const ctx = await context(repo)
|
|
|
|
await expect(localText(ctx, CONFIG, plainSession(repo), repo, branch, 'tracked.txt', signal)).resolves.toBe('initial\nchanged\n')
|
|
await expect(localText(ctx, CONFIG, plainSession(repo), repo, { kind: 'staged' }, 'staged.txt', signal)).resolves.toBe('staged\n')
|
|
await expect(localText(ctx, CONFIG, plainSession(repo), repo, { kind: 'unstaged' }, 'staged.txt', signal)).resolves.toBe('edited after staging\n')
|
|
})
|
|
|
|
it.each(['', '/etc/passwd', 'C:\\Windows\\win.ini', '../outside.txt', 'a/../../b', 'a\\..\\b', 'nul\0name'])(
|
|
'refuses the path %j outside the checkout', async (path) => {
|
|
const { repo } = await forked(await directory())
|
|
|
|
await expect(localText(await context(repo), CONFIG, plainSession(repo), repo, branch, path, signal)).rejects.toThrow('is not a path inside the checkout')
|
|
},
|
|
)
|
|
|
|
it('refuses binary content in the working tree and in the index', async () => {
|
|
const { repo } = await forked(await directory())
|
|
await writeFile(join(repo, 'blob.bin'), Uint8Array.from([0x61, 0, 0x62]))
|
|
await git(repo, 'add', 'blob.bin')
|
|
const ctx = await context(repo)
|
|
|
|
await expect(localText(ctx, CONFIG, plainSession(repo), repo, branch, 'blob.bin', signal)).rejects.toThrow('blob.bin is binary')
|
|
await expect(localText(ctx, CONFIG, plainSession(repo), repo, { kind: 'staged' }, 'blob.bin', signal)).rejects.toThrow('blob.bin is binary')
|
|
})
|
|
|
|
it('reports a file over maxDiffBytes from either side, and passes other read failures on', async () => {
|
|
const { repo } = await forked(await directory())
|
|
await writeFile(join(repo, 'staged.txt'), 'x'.repeat(4096))
|
|
await git(repo, 'add', 'staged.txt')
|
|
const ctx = await context(repo)
|
|
const config = { ...CONFIG, maxDiffBytes: 1024 }
|
|
|
|
await expect(localText(ctx, config, plainSession(repo), repo, branch, 'staged.txt', signal)).rejects.toBeInstanceOf(DiffTooLarge)
|
|
await expect(localText(ctx, config, plainSession(repo), repo, { kind: 'staged' }, 'staged.txt', signal)).rejects.toBeInstanceOf(DiffTooLarge)
|
|
await expect(localText(ctx, config, plainSession(repo), repo, branch, 'missing.txt', signal)).rejects.toBeInstanceOf(FsError)
|
|
await expect(localText(ctx, config, plainSession(repo), repo, { kind: 'staged' }, 'missing.txt', signal)).rejects.toThrow(/^git show failed/)
|
|
})
|
|
})
|
|
|
|
describe('aheadOf', () => {
|
|
it('counts the commits the checkout has beyond a head on the same branch, if that head is present', async () => {
|
|
const { repo, fork } = await forked(await directory())
|
|
const runner = gitIn(await context(repo), CONFIG, plainSession(repo), repo, signal)
|
|
|
|
await expect(aheadOf(runner, 'feat', fork)).resolves.toBe(1)
|
|
await expect(aheadOf(runner, 'main', fork)).resolves.toBeNull()
|
|
await expect(aheadOf(runner, 'feat', 'e'.repeat(40))).resolves.toBeNull()
|
|
})
|
|
|
|
it('counts nothing outside a checkout', async () => {
|
|
const outside = await directory()
|
|
|
|
await expect(aheadOf(gitIn(await context(outside), CONFIG, plainSession(outside), outside, signal), 'feat', 'e'.repeat(40))).resolves.toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('localChanges', () => {
|
|
it('measures the branch scope with untracked files last, or nothing without a fork point', async () => {
|
|
const { repo } = await forked(await directory())
|
|
const ctx = await context(repo)
|
|
|
|
await expect(localChanges(ctx, CONFIG, plainSession(repo), await inspected(ctx, repo), null, signal)).resolves.toEqual({
|
|
base: 'origin/main',
|
|
files: [
|
|
{ path: 'committed.txt', additions: 1, deletions: 0, binary: false },
|
|
{ path: 'staged.txt', additions: 1, deletions: 0, binary: false },
|
|
{ path: 'tracked.txt', additions: 1, deletions: 0, binary: false },
|
|
{ path: 'untracked.txt', additions: 2, deletions: 0, binary: false },
|
|
],
|
|
})
|
|
|
|
const plain = await repository(await directory(), 'plain')
|
|
const other = await context(plain)
|
|
await expect(localChanges(other, CONFIG, plainSession(plain), await inspected(other, plain), null, signal)).resolves.toBeNull()
|
|
})
|
|
})
|