From 6f7ee72be5a10c6979e02c38db2d986be8b28b62 Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 10 Jul 2026 13:40:23 -0400 Subject: [PATCH] feat(desktop): base-branch picker for new worktree dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar "New worktree" button branched off whatever HEAD you were on — now a filterable Popover+Command combobox lets you pick any local or remote-tracking branch as the base, defaulting to origin/HEAD. Backend listBaseBranches() queries refs/heads + refs/remotes via for-each-ref, flags origin/HEAD (falling back to the local default for no-remote repos). Mirrored on the Python REST API side (base_branch_list + /api/git/base-branches route). --- .../desktop/electron/git-worktree-ops.test.ts | 51 ++++++ apps/desktop/electron/git-worktree-ops.ts | 47 +++++ apps/desktop/electron/main.ts | 6 +- apps/desktop/electron/preload.ts | 1 + .../sidebar/projects/base-branch-picker.tsx | 160 ++++++++++++++++++ .../sidebar/projects/workspace-header.tsx | 48 ++++-- apps/desktop/src/global.d.ts | 14 ++ apps/desktop/src/i18n/en.ts | 3 + apps/desktop/src/i18n/ja.ts | 3 + apps/desktop/src/i18n/types.ts | 3 + apps/desktop/src/i18n/zh-hant.ts | 3 + apps/desktop/src/i18n/zh.ts | 3 + apps/desktop/src/lib/desktop-git.ts | 4 + apps/desktop/src/store/projects.ts | 15 +- hermes_cli/web_git.py | 43 +++++ hermes_cli/web_server.py | 5 + 16 files changed, 390 insertions(+), 19 deletions(-) create mode 100644 apps/desktop/src/app/chat/sidebar/projects/base-branch-picker.tsx diff --git a/apps/desktop/electron/git-worktree-ops.test.ts b/apps/desktop/electron/git-worktree-ops.test.ts index 2d4617c254ca..da32c0794d5e 100644 --- a/apps/desktop/electron/git-worktree-ops.test.ts +++ b/apps/desktop/electron/git-worktree-ops.test.ts @@ -8,6 +8,7 @@ import test from 'node:test' import { addWorktree, ensureGitRepo, + listBaseBranches, listBranches, parseWorktrees, sanitizeBranch, @@ -210,3 +211,53 @@ test('addWorktree: existing default branch switches the main checkout, not .work fs.rmSync(dir, { recursive: true, force: true }) } }) + +test('listBaseBranches: lists local branches and flags the default', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-branches-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + const trunk = git('branch', '--show-current') + execFileSync('git', ['branch', 'feature'], { cwd: dir }) + + const branches = await listBaseBranches(dir, 'git') + const names = branches.map(b => b.name).sort() + + assert.deepEqual(names, [trunk, 'feature'].sort()) + // No remote → all local. + assert.equal(branches.every(b => !b.isRemote), true) + // The trunk is flagged as the default. + assert.equal(branches.find(b => b.name === trunk).isDefault, true) + assert.equal(branches.find(b => b.name === 'feature').isDefault, false) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('listBaseBranches: empty on a non-repo path', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-nonrepo-')) + + try { + assert.deepEqual(await listBaseBranches(dir, 'git'), []) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('addWorktree: base param branches off a specified local branch', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-base-add-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + execFileSync('git', ['branch', 'staging'], { cwd: dir }) + + const result = await addWorktree(dir, { base: 'staging', branch: 'new-from-staging', name: 'new-from-staging' }, 'git') + + assert.equal(result.branch, 'new-from-staging') + assert.equal(git('-C', result.path, 'merge-base', 'HEAD', 'staging').length > 0, true) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/electron/git-worktree-ops.ts b/apps/desktop/electron/git-worktree-ops.ts index 61d37bbe6f69..dcde29b682c3 100644 --- a/apps/desktop/electron/git-worktree-ops.ts +++ b/apps/desktop/electron/git-worktree-ops.ts @@ -337,9 +337,56 @@ async function switchBranch(repoPath, branch, gitBin) { return { branch: target } } +// Branches the new worktree can be based on: local heads + remote-tracking +// refs. Listed most-recently-committed first; the remote's default branch +// (origin/HEAD) is flagged so the UI can preselect it. Empty on a non-repo / +// remote backend where the probe can't run. +async function listBaseBranches(repoPath, gitBin) { + let resolved + + try { + resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Base branch list' }) + } catch { + return [] + } + + try { + const out = await runGit( + gitBin, + ['for-each-ref', '--format=%(refname:short)\t%(committerdate:iso)', '--sort=-committerdate', 'refs/heads', 'refs/remotes'], + resolved + ) + + const remoteDefault = await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], resolved) + const localDefault = await defaultBranch(gitBin, resolved) + + return out + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .map(line => { + const [name] = line.split('\t') + + return { + name, + isRemote: name.startsWith('origin/'), + // origin/HEAD when a remote exists; otherwise the local default + // (main/master/init.defaultBranch) so a no-remote repo still flags + // its trunk. + isDefault: Boolean( + (remoteDefault && name === remoteDefault) || (!remoteDefault && localDefault && name === localDefault) + ) + } + }) + } catch { + return [] + } +} + export { addWorktree, ensureGitRepo, + listBaseBranches, listBranches, listWorktrees, parseWorktrees, diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index ca7efd8fbdcf..36a2198f4c7d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -84,7 +84,7 @@ import { reviewUnstage } from './git-review-ops' import { gitRootForIpc } from './git-root' -import { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops' +import { addWorktree, listBaseBranches, listBranches, listWorktrees, removeWorktree, switchBranch } from './git-worktree-ops' import { DATA_URL_READ_MAX_BYTES, DEFAULT_FETCH_TIMEOUT_MS, @@ -8527,6 +8527,10 @@ ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) => ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary())) +ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) => + listBaseBranches(repoPath, resolveGitBinary()) +) + // Compact repo status (branch, ahead/behind, change counts + files) for the // composer coding rail. Returns null on a non-repo / remote backend so the rail // hides cleanly rather than erroring. diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 35d31e2d294b..6c1ebc5bf642 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -117,6 +117,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options), branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch), branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath), + baseBranchList: repoPath => ipcRenderer.invoke('hermes:git:baseBranchList', repoPath), repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath), fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath), scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options), diff --git a/apps/desktop/src/app/chat/sidebar/projects/base-branch-picker.tsx b/apps/desktop/src/app/chat/sidebar/projects/base-branch-picker.tsx new file mode 100644 index 000000000000..c3870be01357 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/projects/base-branch-picker.tsx @@ -0,0 +1,160 @@ +import { useStore } from '@nanostores/react' +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import type { HermesGitBaseBranch } from '@/global' +import { useI18n } from '@/i18n' +import { $repoStatus } from '@/store/coding-status' +import { listBaseBranches } from '@/store/projects' + +// Filterable combobox for picking the base branch of a new worktree. Lists +// local + remote-tracking branches, defaults to the default branch +// (origin/HEAD, or local main/master when no remote). The current session's +// branch is sorted to the top so it's one click away. The parent owns the +// selected value via `value` / `onValueChange`. +export function BaseBranchPicker({ + disabled, + repoPath, + onValueChange, + value +}: { + disabled?: boolean + repoPath: string + onValueChange: (value: string) => void + value: string +}) { + const { t } = useI18n() + const p = t.sidebar.projects + const repoStatus = useStore($repoStatus) + const [branches, setBranches] = useState([]) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + + const currentBranch = repoStatus?.detached ? null : (repoStatus?.branch ?? null) + + const load = useCallback(async () => { + if (!repoPath) { + return + } + + setLoading(true) + + try { + const list = await listBaseBranches(repoPath) + setBranches(list) + + // Default to the remote default (origin/HEAD). Fall back to the local + // default branch (main/master) when no remote exists. The value is + // always a concrete branch — never undefined. + const defaultBranch = list.find(b => b.isDefault) + + if (defaultBranch) { + onValueChange(defaultBranch.name) + } else { + onValueChange(list[0]?.name ?? '') + } + } catch { + setBranches([]) + } finally { + setLoading(false) + } + }, [repoPath, onValueChange]) + + // Load on mount so the default branch fills in before the user opens the + // popover — otherwise the button reads "branch off " with nothing after it. + useEffect(() => { + if (branches.length === 0 && !loading) { + void load() + } + }, [branches.length, loading, load]) + + // Pin the current session's branch to the top, keep the rest in git's + // most-recently-committed order. + const sorted = useMemo(() => { + if (!currentBranch) { + return branches + } + + const idx = branches.findIndex(b => b.name === currentBranch) + + if (idx <= 0) { + return branches + } + + return [branches[idx], ...branches.slice(0, idx), ...branches.slice(idx + 1)] + }, [branches, currentBranch]) + + // The i18n function returns { before, after } so the branch name can be + // wrapped in its own styled (underlined) span — works for any word order. + const parts = p.branchOff() + + return ( +
+ { + if (next && branches.length === 0 && !loading) { + void load() + } + + setOpen(next) + }} + open={open} + > + + + + + (searchValue.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}> + + + {p.baseBranchNone} + + {sorted.map(branch => ( + { + onValueChange(branch.name) + setOpen(false) + }} + value={branch.name} + > +
+ + {branch.isDefault && ( + + )} + {branch.name} + {value === branch.name && ( + + )} +
+
+ ))} +
+
+
+
+
+
+ ) +} diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx index 1a32f68b2f5c..e184ac871a0d 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx @@ -30,6 +30,8 @@ import { copyPath, listRepoBranches, revealPath, startWorkInRepo, switchBranchIn import { SidebarCount, SidebarRowLead } from '../chrome' +import { BaseBranchPicker } from './base-branch-picker' + // Branch/worktree labels routinely share a long prefix (`bb/coding-context-…`), // so plain end-truncation (`truncate`) hides exactly the suffix that tells two // lanes apart — both render as "bb/coding-context…". Keep the tail pinned and @@ -142,6 +144,8 @@ export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemov // "New worktree": prompt for a branch name, then git spins up a fresh worktree // for that branch under the repo (the lightest way) and we open a new session // inside it. Naming is explicit — no auto-generated `hermes/work-` trees. +// The base branch defaults to the remote default (origin/HEAD); the user can +// pick any local or remote-tracking branch via a filterable combobox. export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onStarted: (path: string) => void }) { const { t } = useI18n() const s = t.sidebar @@ -152,6 +156,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS const [convertMode, setConvertMode] = useState(false) const [branches, setBranches] = useState([]) const [branchesLoading, setBranchesLoading] = useState(false) + const [selectedBase, setSelectedBase] = useState('') const loadBranches = useCallback(async () => { if (!repoPath) { @@ -181,7 +186,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS try { // Pass the typed value as both the dir slug source and the branch, so the // branch is exactly what the user named (the dir is slugified git-side). - const result = await startWorkInRepo(repoPath, { branch, name: branch }) + const result = await startWorkInRepo(repoPath, { base: selectedBase || undefined, branch, name: branch }) if (result) { onStarted(result.path) @@ -238,6 +243,7 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS onClick={() => { setConvertMode(false) setName('') + setSelectedBase('') setOpen(true) }} type="button" @@ -278,22 +284,30 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS ) : ( - { - if (event.key === 'Enter') { - event.preventDefault() - void submit() - } else if (event.key === 'Escape') { - setOpen(false) - } - }} - onValueChange={setName} - placeholder={p.branchPlaceholder} - sanitize={gitRef} - value={name} - /> + <> + { + if (event.key === 'Enter') { + event.preventDefault() + void submit() + } else if (event.key === 'Escape') { + setOpen(false) + } + }} + onValueChange={setName} + placeholder={p.branchPlaceholder} + sanitize={gitRef} + value={name} + /> + + )} {convertMode ? ( diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index b7d0fd8cd1d9..0ac5ace5213a 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -130,6 +130,10 @@ declare global { branchSwitch: (repoPath: string, branch: string) => Promise<{ branch: string }> // Local branches for the "convert a branch into a worktree" picker. branchList: (repoPath: string) => Promise + // Local + remote-tracking branches for the "base branch" picker in the + // new-worktree dialog. The remote default (origin/HEAD) is flagged so + // the UI can preselect it. + baseBranchList: (repoPath: string) => Promise // Compact working-tree status for the composer coding rail. Null on a // non-repo / remote backend (where the Electron probe can't run). repoStatus: (repoPath: string) => Promise @@ -668,6 +672,16 @@ export interface HermesGitBranch { worktreePath: null | string } +// A branch the new worktree can be based on: local heads + remote-tracking +// refs. `isRemote` distinguishes `origin/main` from a local `main` (the UI +// may show a remote glyph); `isDefault` flags origin/HEAD so the dialog can +// preselect it. +export interface HermesGitBaseBranch { + name: string + isRemote: boolean + isDefault: boolean +} + // A single changed path from `git status --porcelain=v2`, classified by state // so the coding rail / switcher can group + open the right diff. export interface HermesRepoStatusFile { diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index efeff291cbd6..456f9cf388a4 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1585,6 +1585,9 @@ export const en: Translations = { newWorktreeTitle: 'New worktree', newWorktreeDesc: 'Name the branch for this worktree.', branchPlaceholder: 'e.g. my-feature', + branchOff: () => ({ after: '', before: 'branch off ' }), + baseBranchPlaceholder: 'Search branches…', + baseBranchNone: 'No branches found', startWorkFailed: 'Could not create worktree', convertBranch: 'Convert a branch…', convertBranchTitle: 'Convert a branch', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 431ab28a49e3..2ff315235655 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1527,6 +1527,9 @@ export const ja = defineLocale({ newWorktreeTitle: '新しいワークツリー', newWorktreeDesc: 'このワークツリーのブランチ名を入力してください。', branchPlaceholder: '例: my-feature', + branchOff: () => ({ after: ' から分岐', before: '' }), + baseBranchPlaceholder: 'ブランチを検索…', + baseBranchNone: 'ブランチが見つかりません', startWorkFailed: 'ワークツリーを作成できませんでした', convertBranch: 'ブランチを変換…', convertBranchTitle: 'ブランチを変換', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 3a9de3fd430a..66709b4fae35 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1312,6 +1312,9 @@ export interface Translations { newWorktreeTitle: string newWorktreeDesc: string branchPlaceholder: string + branchOff: () => { after: string; before: string } + baseBranchPlaceholder: string + baseBranchNone: string startWorkFailed: string convertBranch: string convertBranchTitle: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 12995a2e306e..d5a04e34f487 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1479,6 +1479,9 @@ export const zhHant = defineLocale({ newWorktreeTitle: '新增工作樹', newWorktreeDesc: '為這個工作樹命名分支。', branchPlaceholder: '例如 my-feature', + branchOff: () => ({ after: ' 分支', before: '從 ' }), + baseBranchPlaceholder: '搜尋分支…', + baseBranchNone: '未找到分支', startWorkFailed: '無法建立工作樹', convertBranch: '轉換分支…', convertBranchTitle: '轉換分支', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 62d7e978fab6..83000e9e91c6 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1761,6 +1761,9 @@ export const zh: Translations = { newWorktreeTitle: '新建工作树', newWorktreeDesc: '为这个工作树命名分支。', branchPlaceholder: '例如 my-feature', + branchOff: () => ({ after: ' 分支', before: '从 ' }), + baseBranchPlaceholder: '搜索分支…', + baseBranchNone: '未找到分支', startWorkFailed: '无法创建工作树', convertBranch: '转换分支…', convertBranchTitle: '转换分支', diff --git a/apps/desktop/src/lib/desktop-git.ts b/apps/desktop/src/lib/desktop-git.ts index 1a12c3b8abe3..ddc14df84dae 100644 --- a/apps/desktop/src/lib/desktop-git.ts +++ b/apps/desktop/src/lib/desktop-git.ts @@ -1,4 +1,5 @@ import type { + HermesGitBaseBranch, HermesGitBranch, HermesGitWorktree, HermesRepoStatus, @@ -58,6 +59,9 @@ const remoteGit: GitBridge = { branchList: async repoPath => (await gitGet<{ branches: HermesGitBranch[] }>('branches', { path: repoPath })).branches, + baseBranchList: async repoPath => + (await gitGet<{ branches: HermesGitBaseBranch[] }>('base-branches', { path: repoPath })).branches, + repoStatus: repoPath => gitGet('status', { path: repoPath }), fileDiff: async (repoPath, filePath) => diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 869ca2cad0d4..a78ac06ee1d4 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -1,7 +1,7 @@ import { atom } from 'nanostores' import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups' -import type { HermesGitBranch } from '@/global' +import type { HermesGitBaseBranch, HermesGitBranch } from '@/global' import { translateNow } from '@/i18n' import { desktopDefaultCwd, selectDesktopPaths, writeDesktopFileText } from '@/lib/desktop-fs' import { desktopGit } from '@/lib/desktop-git' @@ -709,6 +709,19 @@ export async function listRepoBranches(repoPath: string): Promise { + const git = desktopGit() + + if (!git?.baseBranchList || !repoPath) { + return [] + } + + return git.baseBranchList(repoPath) +} + export async function switchBranchInRepo(repoPath: string, branch: string): Promise { const git = desktopGit() diff --git a/hermes_cli/web_git.py b/hermes_cli/web_git.py index 292f6c811443..6f0dcfd885ab 100644 --- a/hermes_cli/web_git.py +++ b/hermes_cli/web_git.py @@ -644,3 +644,46 @@ def branch_switch(cwd: str, branch: str) -> dict: raise RuntimeError("Branch name is required.") _git_ok(cwd, ["switch", target]) return {"branch": target} + + +def base_branch_list(cwd: str) -> list[dict]: + """Local heads + remote-tracking refs for the base-branch picker. + + The remote default (origin/HEAD) is flagged so the UI can preselect it. + """ + out = _git_out( + cwd, + [ + "for-each-ref", + "--format=%(refname:short)\t%(committerdate:iso)", + "--sort=-committerdate", + "refs/heads", + "refs/remotes", + ], + ) + if not out: + return [] + remote_default = _git_out( + cwd, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"] + ).strip() + local_default = _default_branch(cwd) if not remote_default else "" + result: list[dict] = [] + for line in out.split("\n"): + line = line.strip() + if not line: + continue + name = line.split("\t")[0] + result.append( + { + "name": name, + "isRemote": name.startswith("origin/"), + # origin/HEAD when a remote exists; otherwise the local + # default (main/master/init.defaultBranch) so a no-remote + # repo still flags its trunk. + "isDefault": bool( + (remote_default and name == remote_default) + or (not remote_default and local_default and name == local_default) + ), + } + ) + return result diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 9666d53db886..7365d241c09f 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2333,6 +2333,11 @@ async def git_branches_route(path: str): return {"branches": await _git_op(_web_git.branch_list, _git_path(path))} +@app.get("/api/git/base-branches") +async def git_base_branches_route(path: str): + return {"branches": await _git_op(_web_git.base_branch_list, _git_path(path))} + + @app.get("/api/git/review/list") async def git_review_list_route(path: str, scope: str = "uncommitted", base: Optional[str] = None): return await _git_op(_web_git.review_list, _git_path(path), scope, base)