Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/git/read/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,7 @@ export {
parseMergeTreeNames
} from './merge'
export { getPatchIds } from './patch-ids'
export { getRefs, parseRefs } from './refs'
export { getRemoteCloneUrl, getRemoteWebUrl, toWebUrl } from './remotes'
export { getSquashLandings } from './squash-landings'
export { getIndexPath, listTrackedFiles } from './tracked-files'
78 changes: 78 additions & 0 deletions src/main/git/read/refs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
import { execFileSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { getRefs, parseRefs } from './refs'

describe('parseRefs', () => {
it('shortens names, peels annotated tags and skips symbolic refs', () => {
const out = [
'refs/heads/main\x00aaa\x00\x00100\x00',
'refs/remotes/origin/HEAD\x00aaa\x00\x00100\x00refs/remotes/origin/main',
'refs/remotes/origin/main\x00aaa\x00\x00100\x00',
'refs/tags/v1\x00tagobj\x00ccc\x00200\x00',
'refs/tags/light\x00ddd\x00\x00300\x00',
''
].join('\n')
expect(parseRefs(out)).toEqual([
{ kind: 'local', name: 'main', hash: 'aaa', date: 100 },
{ kind: 'remote', name: 'origin/main', hash: 'aaa', date: 100 },
{ kind: 'tag', name: 'v1', hash: 'ccc', date: 200 },
{ kind: 'tag', name: 'light', hash: 'ddd', date: 300 }
])
})
})

// Integration: the real `git` binary against a throwaway, hermetic repo.
let repo: string
let configHome: string

function git(args: string[]): string {
return execFileSync('git', args, {
cwd: repo,
encoding: 'utf8',
env: {
...process.env,
GIT_AUTHOR_NAME: 'Test Author',
GIT_AUTHOR_EMAIL: 'author@example.com',
GIT_COMMITTER_NAME: 'Test Author',
GIT_COMMITTER_EMAIL: 'author@example.com'
}
}).trim()
}

beforeAll(() => {
configHome = mkdtempSync(join(tmpdir(), 'gitgrove-config-'))
const emptyConfig = join(configHome, 'gitconfig')
writeFileSync(emptyConfig, '')
process.env.GIT_CONFIG_GLOBAL = emptyConfig
process.env.GIT_CONFIG_SYSTEM = emptyConfig

repo = mkdtempSync(join(tmpdir(), 'gitgrove-refs-'))
git(['init', '-q', '-b', 'main'])
git(['config', 'commit.gpgsign', 'false'])
git(['config', 'tag.gpgsign', 'false'])
writeFileSync(join(repo, 'a.txt'), 'a\n')
git(['add', '.'])
git(['commit', '-q', '-m', 'first'])
git(['branch', 'feature/x'])
git(['tag', '-a', 'v1.0', '-m', 'release'])
})

afterAll(() => {
rmSync(repo, { recursive: true, force: true })
rmSync(configHome, { recursive: true, force: true })
})

describe('getRefs', () => {
it('lists branches and tags, annotated tags peeled to their commit', async () => {
const head = git(['rev-parse', 'HEAD'])
const refs = await getRefs(repo)
const byName = new Map(refs.map((r) => [r.name, r]))
expect(byName.get('main')).toMatchObject({ kind: 'local', hash: head })
expect(byName.get('feature/x')).toMatchObject({ kind: 'local', hash: head })
expect(byName.get('v1.0')).toMatchObject({ kind: 'tag', hash: head })
expect(byName.get('v1.0')!.date).toBeGreaterThan(0)
})
})
50 changes: 50 additions & 0 deletions src/main/git/read/refs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Every branch and tag in one read — what the command palette searches. One
// `for-each-ref` over refs/heads, refs/remotes and refs/tags; cheap even with
// tens of thousands of refs (it reads packed-refs, no history walk).

import type { RefEntry } from '@shared/types'
import { runGit } from './core'

const PREFIXES: [string, RefEntry['kind']][] = [
['refs/heads/', 'local'],
['refs/remotes/', 'remote'],
['refs/tags/', 'tag']
]

/**
* Parse `for-each-ref` records of `refname NUL objectname NUL *objectname NUL
* creatordate:unix NUL symref`, one per line (refnames can't hold NUL or a
* newline, so this is exact). Symbolic refs (`origin/HEAD`) are pointers, not
* branches, and drop out. Pure + exported for tests.
*/
export function parseRefs(out: string): RefEntry[] {
const refs: RefEntry[] = []
for (const line of out.split('\n')) {
if (!line) continue
const [refname, object, peeled, date, symref] = line.split('\0')
if (symref) continue
const prefix = PREFIXES.find(([p]) => refname.startsWith(p))
if (!prefix) continue
refs.push({
kind: prefix[1],
name: refname.slice(prefix[0].length),
// An annotated tag names a tag object; `*objectname` is its commit.
hash: peeled || object,
date: Number(date) || 0
})
}
return refs
}

/** All local branches, remote branches and tags, most recently touched first. */
export async function getRefs(repoPath: string): Promise<RefEntry[]> {
const out = await runGit(repoPath, [
'for-each-ref',
'--sort=-creatordate',
'--format=%(refname)%00%(objectname)%00%(*objectname)%00%(creatordate:unix)%00%(symref)',
'refs/heads',
'refs/remotes',
'refs/tags'
])
return parseRefs(out)
}
29 changes: 29 additions & 0 deletions src/main/git/read/tracked-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// The repository's tracked files and where its index lives — the raw material
// of the command palette's file search (main/search/file-index.ts).

import { isAbsolute, join } from 'node:path'
import { runGit } from './core'

/**
* Every tracked path, repo-relative and `/`-separated. `ls-files` reads the
* index only — no working-tree walk — so it stays fast on huge repos. NUL-
* delimited (`-z`) so any filename parses exactly. A conflicted path has one
* index entry per stage; the sorted output lets consecutive repeats collapse.
*/
export async function listTrackedFiles(repoPath: string): Promise<string[]> {
const out = await runGit(repoPath, ['ls-files', '-z'])
const paths: string[] = []
for (const path of out.split('\0')) {
if (path && path !== paths[paths.length - 1]) paths.push(path)
}
return paths
}

/**
* Absolute path of the repo's index file. Asked of git rather than assumed to
* be `.git/index`: in a linked worktree `.git` is a file pointing elsewhere.
*/
export async function getIndexPath(repoPath: string): Promise<string> {
const path = (await runGit(repoPath, ['rev-parse', '--git-path', 'index'])).trim()
return isAbsolute(path) ? path : join(repoPath, path)
}
2 changes: 2 additions & 0 deletions src/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { registerHistoryHandlers } from './history'
import { registerIntegrationHandlers } from './integrate'
import { registerMaintenanceHandlers } from './maintenance'
import { registerRepoHandlers } from './repo'
import { registerSearchHandlers } from './search'
import { registerStagingHandlers } from './staging'
import { registerStashHandlers } from './stash'
import { registerSyncHandlers } from './sync'
Expand All @@ -39,6 +40,7 @@ export function registerIpc(ctx: IpcContext): void {

registerRepoHandlers(deps)
registerHistoryHandlers()
registerSearchHandlers()
registerStagingHandlers(deps)
registerSyncHandlers(deps)
registerAccountHandlers(deps)
Expand Down
16 changes: 16 additions & 0 deletions src/main/ipc/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Command-palette reads: every branch/tag, and the fuzzy file search over the
// repo's tracked files (ranked in main — see search/file-index.ts).

import { IPC } from '@shared/ipc'
import { ipcMain } from 'electron'
import { getRefs } from '../git/read'
import { searchFiles } from '../search/file-index'

export function registerSearchHandlers(): void {
ipcMain.handle(IPC.refs, (_e, repoPath: string) => getRefs(repoPath))
// Keyed by the calling window, so each window's newer query supersedes only
// its own older one.
ipcMain.handle(IPC.searchFiles, (e, repoPath: string, query: string, limit: number) =>
searchFiles(repoPath, query, limit, String(e.sender.id))
)
}
Loading
Loading