Files
deepseek-harness/packages/experimental/client-ui-forge/src/parse.ts
T
Yichen Jiang e99272d04e feat(forge): bind change requests from gh pr view/edit/checkout and the checkout's branch
- forge-session recognizes `gh pr view`, `gh pr edit`, and `gh pr checkout`
  as binding evidence with the new `referenced` origin. It binds the URL
  given as the subcommand's first argument and the URL the output prints
  for that change request: gh's `url:` field, a URL alone on a line before
  a description, or a JSON `url` member. Links in a description do not
  bind. Pending calls carry this evidence, and the projection's state
  version moves to 2, so existing Sessions fold their logs again.
- forge-session adds `discoverBranch(session, remote, branch)`, the lookup
  that follows a push, for a branch that a caller reports.
- The panel reports the checkout's upstream branch when a provider
  recognizes its remote, once per remote and branch until a refresh, so a
  Session on a branch with an open change request shows it without a push.
- The empty panel names every way a change request becomes linked.
2026-10-20 14:31:10 +08:00

102 lines
4.0 KiB
TypeScript

/** Parsers for the porcelain output of the read-only Git commands the panel runs. */
/** One remote with the URLs `git remote -v` prints for it. */
export interface GitRemote {
/** Remote name such as `origin`. */
readonly name: string
/** Distinct URLs, fetch URL first. */
readonly urls: readonly string[]
}
/**
* Read the first line that `git rev-parse` prints for a directory query such as `--show-toplevel`.
* @param output - stdout of the command.
* @returns the directory.
* @throws when the output is empty.
*/
export function parseTopLevel(output: string): string {
const [root = ''] = output.split(/\r?\n/)
if (root.length === 0) throw new Error('git rev-parse printed no directory')
return root
}
/**
* Read the first line of a one-name output such as `git symbolic-ref --short HEAD`.
* @param output - stdout of the command.
* @returns the name, or null when the command printed none.
*/
export function parseName(output: string): string | null {
const [name = ''] = output.split(/\r?\n/)
return name.length === 0 ? null : name
}
/**
* Order worktree base branches: the checked-out branch first, then the other
* listed branches in listed order.
* @param current - checked-out branch, or null when HEAD is detached.
* @param output - stdout of `git for-each-ref --format=%(refname:lstrip=2) refs/heads/`.
* @param limit - most names to return.
* @returns distinct branch names, at most `limit`.
*/
export function baseBranches(current: string | null, output: string, limit: number): string[] {
const listed = output.split(/\r?\n/).filter(name => name.length > 0 && name !== current)
return (current === null ? listed : [current, ...listed]).slice(0, limit)
}
/**
* Group the lines of `git remote -v` by remote.
* @param output - stdout of the command.
* @returns remotes in first-listed order.
*/
export function parseRemotes(output: string): GitRemote[] {
const remotes = new Map<string, string[]>()
for (const line of output.split(/\r?\n/)) {
const [, name = '', url = '', direction = ''] = /^(\S+)\t(.+) \((fetch|push)\)$/.exec(line) ?? []
if (name.length === 0) continue
let urls = remotes.get(name)
if (urls === undefined) {
urls = []
remotes.set(name, urls)
}
if (urls.includes(url)) continue
if (direction === 'fetch') urls.unshift(url)
else urls.push(url)
}
return [...remotes].map(([name, urls]) => ({ name, urls }))
}
/**
* Split an upstream branch such as `origin/feature/x` into its remote and the
* branch on that remote. The remote is the longest remote name followed by `/`
* at the start of the upstream branch.
* @param upstream - upstream branch such as `origin/main`, or null.
* @param remotes - remotes in listed order.
* @returns the remote and branch, or undefined without an upstream or a remote it starts with.
*/
export function upstreamBranch(
upstream: string | null,
remotes: readonly GitRemote[],
): { readonly remote: GitRemote; readonly branch: string } | undefined {
if (upstream === null) return undefined
const remote = remotes
.filter(candidate => upstream.startsWith(`${candidate.name}/`))
.reduce<GitRemote | undefined>(
(longest, candidate) => longest === undefined || candidate.name.length > longest.name.length ? candidate : longest,
undefined,
)
return remote === undefined ? undefined : { remote, branch: upstream.slice(remote.name.length + 1) }
}
/**
* Order remotes for code host recognition: the upstream's remote first, then
* `origin`, then the rest in listed order.
* @param upstream - upstream branch such as `origin/main`, or null.
* @param remotes - remotes in listed order.
* @returns the same remotes in recognition order.
*/
export function remoteOrder(upstream: string | null, remotes: readonly GitRemote[]): GitRemote[] {
const tracked = upstreamBranch(upstream, remotes)?.remote
const rank = (remote: GitRemote): number => remote === tracked ? 0 : remote.name === 'origin' ? 1 : 2
return [...remotes].sort((left, right) => rank(left) - rank(right))
}