@young1lin/dsh-ui-gitworkbench 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +70 -0
- package/LICENSE +21 -0
- package/README.md +395 -0
- package/README_EN.md +74 -0
- package/cordis.patch.yml +7 -0
- package/lib/atomic-json.js +48 -0
- package/lib/client.js +15297 -0
- package/lib/commit-cache.js +68 -0
- package/lib/git-log.js +79 -0
- package/lib/git-ops.js +409 -0
- package/lib/index.js +1143 -0
- package/lib/style-store.js +123 -0
- package/lib/worktree.js +112 -0
- package/package.json +86 -0
- package/scripts/install.ps1 +240 -0
- package/scripts/install.sh +231 -0
- package/src/atomic-json.ts +55 -0
- package/src/client/GitWorkbenchPanel.module.css +1512 -0
- package/src/client/GitWorkbenchPanel.tsx +3446 -0
- package/src/client/commit-graph.ts +140 -0
- package/src/client/diff-model.ts +193 -0
- package/src/client/highlight.ts +257 -0
- package/src/client/index.ts +198 -0
- package/src/client/locales.ts +270 -0
- package/src/client/op-feedback.ts +65 -0
- package/src/client/stage-tree.ts +178 -0
- package/src/client/themes.ts +181 -0
- package/src/client/worktree-view.ts +193 -0
- package/src/commit-cache.ts +69 -0
- package/src/git-log.ts +92 -0
- package/src/git-ops.ts +490 -0
- package/src/index.ts +1172 -0
- package/src/style-store.ts +144 -0
- package/src/types/dsh-client-shim.d.ts +100 -0
- package/src/types/dsh-shim.d.ts +77 -0
- package/src/worktree.ts +142 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-project and global drawer styling: a background image and custom CSS.
|
|
3
|
+
*
|
|
4
|
+
* These live on the host rather than in the browser for two reasons. A project
|
|
5
|
+
* setting belongs to the project, so it must survive a different browser or a
|
|
6
|
+
* cleared origin; and a background image is far larger than a localStorage
|
|
7
|
+
* origin quota is willing to hold.
|
|
8
|
+
*
|
|
9
|
+
* The file is a durable boundary: everything read back is validated here, and
|
|
10
|
+
* anything unrecognized is dropped rather than propagated. The image in
|
|
11
|
+
* particular is interpolated into a CSS `url()` by the client, so it is held to
|
|
12
|
+
* a base64 `data:` URL with no character that could close the function and
|
|
13
|
+
* continue the stylesheet.
|
|
14
|
+
*/
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
|
|
17
|
+
/** One scope's styling. Absent fields are impossible: every entry is complete. */
|
|
18
|
+
export interface StyleEntry {
|
|
19
|
+
/** Custom CSS injected into the page verbatim; empty for none. */
|
|
20
|
+
readonly css: string
|
|
21
|
+
/** Background image as a base64 `data:` URL; empty for none. */
|
|
22
|
+
readonly image: string
|
|
23
|
+
/** Background blur radius in px. */
|
|
24
|
+
readonly blur: number
|
|
25
|
+
/** How much of the palette's own surface colour covers the image, in percent. */
|
|
26
|
+
readonly veil: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StyleFile {
|
|
30
|
+
readonly v: 1
|
|
31
|
+
readonly global: StyleEntry
|
|
32
|
+
/** Keyed by repository root, forward-slashed. */
|
|
33
|
+
readonly projects: Record<string, StyleEntry>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** No image, no CSS, and the defaults the sliders open on. */
|
|
37
|
+
export const DEFAULT_STYLE: StyleEntry = { css: '', image: '', blur: 18, veil: 78 }
|
|
38
|
+
|
|
39
|
+
/** Largest accepted image data URL. A 2560px JPEG lands far below this; the cap
|
|
40
|
+
* exists so a hand-edited file cannot make every drawer open drag megabytes. */
|
|
41
|
+
export const STYLE_IMAGE_MAX = 3_000_000
|
|
42
|
+
|
|
43
|
+
/** Largest accepted custom stylesheet. */
|
|
44
|
+
export const STYLE_CSS_MAX = 200_000
|
|
45
|
+
|
|
46
|
+
/** Largest accepted blur radius, in px. */
|
|
47
|
+
export const STYLE_BLUR_MAX = 60
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Images this plugin will render.
|
|
51
|
+
*
|
|
52
|
+
* Deliberately narrow: the client interpolates the value into `url("…")`, and a
|
|
53
|
+
* base64 alphabet cannot contain a quote, a parenthesis, a backslash or a
|
|
54
|
+
* semicolon, so a stored value can never close the function and append rules of
|
|
55
|
+
* its own. It is also exactly what a canvas `toDataURL` produces, so nothing a
|
|
56
|
+
* user can select through the picker is rejected.
|
|
57
|
+
*/
|
|
58
|
+
const IMAGE_PATTERN = /^data:image\/(?:png|jpeg|webp|gif|avif);base64,[A-Za-z0-9+/]+={0,2}$/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param home - the user's home directory.
|
|
62
|
+
* @returns the style file's path, forward-slashed.
|
|
63
|
+
*/
|
|
64
|
+
export function stylePath(home: string): string {
|
|
65
|
+
return join(home, '.dsh', 'gitworkbench-style.json').replace(/\\/g, '/')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param value - a number from a file or an RPC argument.
|
|
70
|
+
* @param min - lower bound.
|
|
71
|
+
* @param max - upper bound.
|
|
72
|
+
* @param fallback - used when the value is not a finite number.
|
|
73
|
+
* @returns the value clamped into range.
|
|
74
|
+
*/
|
|
75
|
+
function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
|
|
76
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
|
|
77
|
+
return Math.min(Math.max(value, min), max)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Narrow an arbitrary value to a complete, in-range style entry.
|
|
82
|
+
* @param value - parsed JSON or an RPC argument.
|
|
83
|
+
* @returns a valid entry; every rejected field falls back to its default.
|
|
84
|
+
*/
|
|
85
|
+
export function sanitizeEntry(value: unknown): StyleEntry {
|
|
86
|
+
if (typeof value !== 'object' || value === null) return DEFAULT_STYLE
|
|
87
|
+
const record = value as Record<string, unknown>
|
|
88
|
+
const css = typeof record['css'] === 'string' && record['css'].length <= STYLE_CSS_MAX ? record['css'] : ''
|
|
89
|
+
const raw = record['image']
|
|
90
|
+
const image = typeof raw === 'string' && raw.length <= STYLE_IMAGE_MAX && IMAGE_PATTERN.test(raw) ? raw : ''
|
|
91
|
+
return {
|
|
92
|
+
css,
|
|
93
|
+
image,
|
|
94
|
+
blur: clampNumber(record['blur'], 0, STYLE_BLUR_MAX, DEFAULT_STYLE.blur),
|
|
95
|
+
veil: clampNumber(record['veil'], 0, 100, DEFAULT_STYLE.veil),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @returns a style file with nothing configured.
|
|
101
|
+
*/
|
|
102
|
+
export function emptyStyleFile(): StyleFile {
|
|
103
|
+
return { v: 1, global: DEFAULT_STYLE, projects: {} }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param entry - a style entry.
|
|
108
|
+
* @returns whether it configures anything at all; an entry that does not is not
|
|
109
|
+
* worth storing, and storing it would shadow the global scope with nothing.
|
|
110
|
+
*/
|
|
111
|
+
export function isBlankEntry(entry: StyleEntry): boolean {
|
|
112
|
+
return entry.css.length === 0 && entry.image.length === 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parse the style file, dropping anything malformed.
|
|
117
|
+
* @param raw - the file's text.
|
|
118
|
+
* @returns a valid style file; a corrupt one reads as empty rather than failing.
|
|
119
|
+
*/
|
|
120
|
+
export function parseStyle(raw: string): StyleFile {
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(raw) as { v?: unknown; global?: unknown; projects?: unknown }
|
|
123
|
+
if (parsed?.v !== 1) return emptyStyleFile()
|
|
124
|
+
const projects: Record<string, StyleEntry> = {}
|
|
125
|
+
if (typeof parsed.projects === 'object' && parsed.projects !== null) {
|
|
126
|
+
for (const [root, entry] of Object.entries(parsed.projects as Record<string, unknown>)) {
|
|
127
|
+
if (root.length > 0) projects[root] = sanitizeEntry(entry)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { v: 1, global: sanitizeEntry(parsed.global), projects }
|
|
131
|
+
} catch {
|
|
132
|
+
// A half-written or hand-edited file must not take the drawer down with it.
|
|
133
|
+
return emptyStyleFile()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param readText - reads a file's text.
|
|
139
|
+
* @param path - the style file's path.
|
|
140
|
+
* @returns the stored styles, or an empty file when absent or unreadable.
|
|
141
|
+
*/
|
|
142
|
+
export async function loadStyle(readText: (path: string) => Promise<string>, path: string): Promise<StyleFile> {
|
|
143
|
+
try { return parseStyle(await readText(path)) } catch { return emptyStyleFile() }
|
|
144
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient shims for the CLIENT half, so `tsc` can check it WITHOUT
|
|
3
|
+
* `@deepseek-ai/dsh-client-runtime` and `@deepseek-ai/dsh-client-ui-slots`
|
|
4
|
+
* installed — they are peer dependencies resolved from the web profile's module
|
|
5
|
+
* farm at runtime, and this repository never installs them.
|
|
6
|
+
*
|
|
7
|
+
* WHAT THIS IS FOR: catching mistakes in THIS plugin's own code — a stale
|
|
8
|
+
* identifier after a refactor, a prop that no longer exists, a missing field, a
|
|
9
|
+
* type that does not line up. Before it existed the client half was never
|
|
10
|
+
* typechecked at all: `tsconfig.json` includes only the host entry, and tsdown
|
|
11
|
+
* builds with `dts: false` while rolldown does no checking.
|
|
12
|
+
*
|
|
13
|
+
* WHAT THIS IS NOT: a copy of the harness's types. Everything at the dsh
|
|
14
|
+
* boundary below is deliberately loose, so it can confirm a call is shaped
|
|
15
|
+
* roughly right but never that it matches the real signature. The authority is
|
|
16
|
+
* the harness monorepo, and a green check here does NOT prove this plugin still
|
|
17
|
+
* fits the host it mounts into — only running it does. Widen a declaration when
|
|
18
|
+
* it blocks correct code; never narrow one to encode a guess.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
declare module '*.module.css' {
|
|
22
|
+
/** CSS-module class map; the build inlines the stylesheet and exports this. */
|
|
23
|
+
const classes: Record<string, string>
|
|
24
|
+
export default classes
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Host frozen table supplies this at runtime; only `createPortal` is used here. */
|
|
28
|
+
declare module 'react-dom' {
|
|
29
|
+
import type { ReactNode } from 'react'
|
|
30
|
+
export function createPortal(children: ReactNode, container: Element | DocumentFragment): ReactNode
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Bare specifier imported for load ordering only; it contributes no types here. */
|
|
34
|
+
declare module '@deepseek-ai/dsh-client-runtime' {}
|
|
35
|
+
|
|
36
|
+
declare module '@deepseek-ai/dsh-client-runtime/client' {
|
|
37
|
+
/** One slot contribution, as `ctx.slots.register` accepts it. */
|
|
38
|
+
export interface SlotRegistration {
|
|
39
|
+
/** Slot to contribute to. */
|
|
40
|
+
readonly name: string
|
|
41
|
+
/** Identity of this contribution within the slot. */
|
|
42
|
+
readonly id: string
|
|
43
|
+
/** Sort key among the slot's entries. */
|
|
44
|
+
readonly order?: number
|
|
45
|
+
/**
|
|
46
|
+
* Locale namespace. Declaring it makes the framework synthesize the `t` prop;
|
|
47
|
+
* this plugin's namespace is outside dsh's typed map, so the component
|
|
48
|
+
* declares `t` itself rather than receiving a checked type here.
|
|
49
|
+
*/
|
|
50
|
+
readonly locale?: string
|
|
51
|
+
/** Business callbacks handed to the component as props. */
|
|
52
|
+
readonly inject?: () => Record<string, unknown>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The client-side plugin context an `apply` receives. */
|
|
56
|
+
export interface ClientContext {
|
|
57
|
+
/**
|
|
58
|
+
* Register a contribution as a disposable effect.
|
|
59
|
+
* @param apply - performs the registration and returns its disposer.
|
|
60
|
+
* @param label - diagnostic name for the effect.
|
|
61
|
+
* @returns a disposer for the effect itself.
|
|
62
|
+
*/
|
|
63
|
+
effect(apply: () => (() => void) | void, label?: string): () => void
|
|
64
|
+
slots: {
|
|
65
|
+
/**
|
|
66
|
+
* Run `apply` once the named slot exists.
|
|
67
|
+
* @param name - slot being contributed to.
|
|
68
|
+
* @param apply - performs the registration.
|
|
69
|
+
*/
|
|
70
|
+
inject(name: string, apply: () => void): void
|
|
71
|
+
/**
|
|
72
|
+
* Contribute one component to a slot.
|
|
73
|
+
* @param registration - slot, identity, ordering, locale and injected props.
|
|
74
|
+
* @param component - the component rendered for this entry.
|
|
75
|
+
* @returns the disposer.
|
|
76
|
+
*/
|
|
77
|
+
register(registration: SlotRegistration, component: unknown): () => void
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
83
|
+
/**
|
|
84
|
+
* Props every slot component receives from the runtime.
|
|
85
|
+
*
|
|
86
|
+
* The real type is keyed on the slot name; this approximation carries only the
|
|
87
|
+
* members this plugin reads, which is what lets its own usage be checked.
|
|
88
|
+
*/
|
|
89
|
+
export interface PropsRuntime<N extends string = string> {
|
|
90
|
+
/** Session this header belongs to. */
|
|
91
|
+
readonly sessionId: string
|
|
92
|
+
/**
|
|
93
|
+
* Subscribe to a slice of the sessions store. The store's type lives in the
|
|
94
|
+
* harness, so the selector's parameter is annotated at each call site.
|
|
95
|
+
* @param selector - picks the slice to subscribe to.
|
|
96
|
+
* @returns the selected slice.
|
|
97
|
+
*/
|
|
98
|
+
readonly useSessions: <T>(selector: (state: never) => T) => T
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient shims so `tsc` can transpile the host half WITHOUT the @deepseek-ai
|
|
3
|
+
* packages installed locally — they resolve from the web profile's module farm
|
|
4
|
+
* at runtime. Types here are intentionally loose (strict is off); they exist
|
|
5
|
+
* only to let the decorator transform and emit. The real types live in the
|
|
6
|
+
* harness monorepo and govern behaviour once loaded in-profile.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
declare module '@deepseek-ai/cordis' {
|
|
10
|
+
export interface Context {
|
|
11
|
+
tools: {
|
|
12
|
+
/** ToolRuntime.register — returns the dispose callback; loose here, see file header. */
|
|
13
|
+
register(definition: unknown): () => void
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* SystemPrompt registry. Only `context()` is mirrored — the dynamic
|
|
17
|
+
* per-assembly contribution, whose `text` provider is SYNCHRONOUS (see the
|
|
18
|
+
* real `PromptContext` in packages/core/system-prompt/src/index.ts). The
|
|
19
|
+
* `agent` field on the assemble context is merged in by `dsh-agent`.
|
|
20
|
+
*/
|
|
21
|
+
systemPrompt: {
|
|
22
|
+
context(input: {
|
|
23
|
+
name: string
|
|
24
|
+
order: number
|
|
25
|
+
text: (context: { agent?: { session: { id: string } } }) => string
|
|
26
|
+
}): () => void
|
|
27
|
+
}
|
|
28
|
+
/** Mount a child scope once the named services are available; loose here, see file header. */
|
|
29
|
+
inject(services: readonly string[], apply: (scope: Context) => void): void
|
|
30
|
+
subprocess: {
|
|
31
|
+
spawn(spec: {
|
|
32
|
+
argv: readonly string[]
|
|
33
|
+
cwd: string
|
|
34
|
+
stdio: { stdin: unknown; stdout: 'pipe'; stderr: 'pipe' }
|
|
35
|
+
graceMs: number
|
|
36
|
+
signal?: AbortSignal
|
|
37
|
+
/** Merged onto the implementation's scrubbed base environment. */
|
|
38
|
+
env?: Record<string, string> | undefined
|
|
39
|
+
}): {
|
|
40
|
+
stdout: import('node:stream').Readable | undefined
|
|
41
|
+
stderr: import('node:stream').Readable | undefined
|
|
42
|
+
done: Promise<{ exitCode: number | null; signal: string | null }>
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export class Service<T = never> {
|
|
47
|
+
constructor(ctx: Context, name: string)
|
|
48
|
+
readonly ctx: Context
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
declare module '@deepseek-ai/dsh-typert-protocol' {
|
|
53
|
+
import type { Service, Context } from '@deepseek-ai/cordis'
|
|
54
|
+
type Stage3MethodDecorator = (value: Function, context: ClassMethodDecoratorContext) => void
|
|
55
|
+
export class TypertRemoteService<T = never> extends Service<T> {
|
|
56
|
+
protected constructor(ctx: Context, serviceKey: string, options?: { namespace?: string })
|
|
57
|
+
readonly typertRemote: unknown
|
|
58
|
+
}
|
|
59
|
+
export function Remote(): Stage3MethodDecorator
|
|
60
|
+
export function Remote(exportName: string): Stage3MethodDecorator
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
declare module '@deepseek-ai/dsh-tools' {
|
|
64
|
+
/**
|
|
65
|
+
* Loose mirror of the real ToolRunContext (packages/core/tools/src/index.ts):
|
|
66
|
+
* only the agent → session → header.cwd path the worktree tools read. The
|
|
67
|
+
* real type carries far more (signal, deferContext, events, ...); the shim
|
|
68
|
+
* keeps tsc happy while the runtime object provides these fields.
|
|
69
|
+
*/
|
|
70
|
+
export interface ToolRunContext {
|
|
71
|
+
agent?: { id: string; session: { id: string; header: { cwd?: string } } }
|
|
72
|
+
/** Turn-scoped cancellation the executor always supplies; forward it to every subprocess. */
|
|
73
|
+
signal: AbortSignal
|
|
74
|
+
}
|
|
75
|
+
/** Identity-typed defineTool — it only wraps the definition object at runtime. */
|
|
76
|
+
export function defineTool(tool: Record<string, unknown>): unknown
|
|
77
|
+
}
|
package/src/worktree.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// src/worktree.ts — Task 1 delivers only the binding storage; later tasks append.
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { saveJsonAtomic } from './atomic-json.js'
|
|
4
|
+
|
|
5
|
+
export interface WorktreeBinding {
|
|
6
|
+
readonly repoRoot: string
|
|
7
|
+
readonly worktreePath: string
|
|
8
|
+
readonly name: string
|
|
9
|
+
readonly enteredAt: string
|
|
10
|
+
/**
|
|
11
|
+
* Commit the worktree's branch started from. Optional on purpose: bindings
|
|
12
|
+
* written before this field existed stay valid, and a reuse path can fail to
|
|
13
|
+
* recover a historical branch point. A consumer without it falls back to
|
|
14
|
+
* diffing against the worktree's own HEAD.
|
|
15
|
+
*/
|
|
16
|
+
readonly baseCommit?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BindingsFile { readonly v: 1; readonly bindings: Record<string, WorktreeBinding> }
|
|
20
|
+
|
|
21
|
+
const BINDINGS_FIELDS = ['repoRoot', 'worktreePath', 'name', 'enteredAt'] as const
|
|
22
|
+
|
|
23
|
+
function emptyFile(): BindingsFile { return { v: 1, bindings: {} } }
|
|
24
|
+
|
|
25
|
+
function isBinding(value: unknown): value is WorktreeBinding {
|
|
26
|
+
if (typeof value !== 'object' || value === null) return false
|
|
27
|
+
const record = value as Record<string, unknown>
|
|
28
|
+
if (!BINDINGS_FIELDS.every(field => typeof record[field] === 'string' && (record[field] as string).length > 0)) return false
|
|
29
|
+
// Absent is normal; present-but-malformed is corruption, and dropping the
|
|
30
|
+
// whole record beats trusting half of it.
|
|
31
|
+
const base = record['baseCommit']
|
|
32
|
+
return base === undefined || (typeof base === 'string' && base.length > 0)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function bindingsPath(home: string): string {
|
|
36
|
+
return join(home, '.dsh', 'gitworkbench-worktree-bindings.json').replace(/\\/g, '/')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseBindings(raw: string): BindingsFile {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(raw) as { v?: unknown; bindings?: unknown }
|
|
42
|
+
if (parsed?.v !== 1 || typeof parsed.bindings !== 'object' || parsed.bindings === null) return emptyFile()
|
|
43
|
+
const out = emptyFile()
|
|
44
|
+
for (const [sessionId, value] of Object.entries(parsed.bindings as Record<string, unknown>)) {
|
|
45
|
+
if (sessionId.length > 0 && isBinding(value)) out.bindings[sessionId] = value
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
} catch {
|
|
49
|
+
return emptyFile()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function loadBindings(readText: (path: string) => Promise<string>, path: string): Promise<BindingsFile> {
|
|
54
|
+
try { return parseBindings(await readText(path)) } catch { return emptyFile() }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function saveBindings(
|
|
58
|
+
ensureDir: (dir: string) => Promise<void>,
|
|
59
|
+
writeText: (path: string, text: string) => Promise<void>,
|
|
60
|
+
rename: (from: string, to: string) => Promise<void>,
|
|
61
|
+
path: string,
|
|
62
|
+
file: BindingsFile,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
await saveJsonAtomic(ensureDir, writeText, rename, path, file)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- Task 3: RPC return shapes (all JSON-safe; success writes no error key) ----
|
|
68
|
+
|
|
69
|
+
export interface WorktreeOpResult {
|
|
70
|
+
readonly ok: boolean
|
|
71
|
+
/** Present on success. */
|
|
72
|
+
readonly worktreePath?: string
|
|
73
|
+
/** Present on success of enter/exit. */
|
|
74
|
+
readonly branch?: string
|
|
75
|
+
/** Operational guidance for the model, present when meaningful. */
|
|
76
|
+
readonly hint?: string
|
|
77
|
+
/** Present on failure only. */
|
|
78
|
+
readonly error?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- Task 2: worktree name/branch/path derivation + porcelain parsing ----
|
|
82
|
+
|
|
83
|
+
const NAME_PATTERN = /^[A-Za-z0-9._-]{1,40}$/
|
|
84
|
+
|
|
85
|
+
export interface WorktreeEntry { readonly path: string; readonly head: string; readonly branch: string }
|
|
86
|
+
|
|
87
|
+
export function sanitizeName(raw: string | undefined, rng: () => string): string {
|
|
88
|
+
if (raw !== undefined && NAME_PATTERN.test(raw) && raw !== '.' && raw !== '..') return raw
|
|
89
|
+
return `wt-${rng()}`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function branchFor(name: string): string { return `wt/${name}` }
|
|
93
|
+
|
|
94
|
+
/** Longest ref name accepted — well past any real branch, short of a payload. */
|
|
95
|
+
const REF_MAX_LENGTH = 200
|
|
96
|
+
/** The character set git allows in a branch or tag name. */
|
|
97
|
+
const REF_CHARS = /^[A-Za-z0-9._/-]+$/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Decide whether a ref name from an untrusted caller may be passed to git.
|
|
101
|
+
*
|
|
102
|
+
* A ref arrives from the browser as free text and becomes a POSITIONAL argument,
|
|
103
|
+
* so three things are rejected before it gets there: a leading `-`, which git
|
|
104
|
+
* would read as an option rather than a ref; `..`, which is range syntax and
|
|
105
|
+
* would silently change what a comparison covers; and any character outside the
|
|
106
|
+
* set git accepts in a ref name.
|
|
107
|
+
* @param ref - candidate ref name.
|
|
108
|
+
* @returns true when the value is safe to pass to git as a ref.
|
|
109
|
+
*/
|
|
110
|
+
export function isRefName(ref: string): boolean {
|
|
111
|
+
return typeof ref === 'string'
|
|
112
|
+
&& ref.length > 0 && ref.length <= REF_MAX_LENGTH
|
|
113
|
+
&& !ref.startsWith('-')
|
|
114
|
+
&& !ref.includes('..')
|
|
115
|
+
&& REF_CHARS.test(ref)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function worktreeDir(repoRoot: string, name: string): string {
|
|
119
|
+
return `${repoRoot.replace(/\/+$/, '')}/.agents/worktrees/${name}`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function parseWorktreeList(porcelain: string): WorktreeEntry[] {
|
|
123
|
+
const out: WorktreeEntry[] = []
|
|
124
|
+
let path = ''
|
|
125
|
+
let head = ''
|
|
126
|
+
let branch = ''
|
|
127
|
+
const flush = (): void => {
|
|
128
|
+
if (path.length > 0 && head.length > 0 && branch.length > 0) {
|
|
129
|
+
out.push({ path, head, branch })
|
|
130
|
+
}
|
|
131
|
+
path = ''; head = ''; branch = ''
|
|
132
|
+
}
|
|
133
|
+
for (const line of porcelain.split('\n')) {
|
|
134
|
+
if (line.length === 0) { flush(); continue }
|
|
135
|
+
if (line.startsWith('worktree ')) path = line.slice('worktree '.length)
|
|
136
|
+
else if (line.startsWith('HEAD ')) head = line.slice('HEAD '.length)
|
|
137
|
+
else if (line.startsWith('branch refs/heads/')) branch = line.slice('branch refs/heads/'.length)
|
|
138
|
+
else if (line === 'detached') { path = ''; head = ''; branch = '' }
|
|
139
|
+
}
|
|
140
|
+
flush()
|
|
141
|
+
return out
|
|
142
|
+
}
|