Package the portable DSH profiles, local plugins, desktop integration, Firefox wrapper, and ownership-aware installer lifecycle. Bundle checksum-pinned OpenVSCode, editor extensions, translation assets, and cloudflared so unreliable upstream artifact downloads cannot break a clean installation.
546 lines
28 KiB
JavaScript
546 lines
28 KiB
JavaScript
/**
|
|
* dsh-touched-git — host half.
|
|
*
|
|
* Tracks the set of git-revisioned directories an agent session has "touched",
|
|
* exposed through three model-facing tools and shown in a right-side panel.
|
|
*
|
|
* Tools:
|
|
* add_touched_git_directory — the agent calls this whenever it starts
|
|
* working in a git-revisioned directory.
|
|
* remove_touched_git_directory — the agent calls this ONLY after the user
|
|
* explicitly says a directory is no longer
|
|
* tracked.
|
|
* get_touched_git_directories — the agent reads the list; invalid earlier
|
|
* add calls are returned as error entries.
|
|
*
|
|
* Routes (loopback-only):
|
|
* GET /touched-git/list?sessionId= — entries for the panel.
|
|
* GET /touched-git/status?sessionId=&path= — `git status` for one entry.
|
|
* @module dsh-touched-git
|
|
*/
|
|
|
|
import { execFile } from 'node:child_process'
|
|
import { mkdir, readFile, realpath, rename, stat, writeFile } from 'node:fs/promises'
|
|
import { homedir } from 'node:os'
|
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { promisify } from 'node:util'
|
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
import { EditorWorkspaces } from './editor-runtime.js'
|
|
import { createEditorOpenHandler } from './editor-route.js'
|
|
import { registerGitWriteGuard } from './git-write-guard.js'
|
|
import { MutationStore } from './mutation-store.js'
|
|
import { createMutationHandlers } from './mutation-routes.js'
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
const VENDOR_DIR = join(dirname(fileURLToPath(import.meta.url)), 'vendor')
|
|
const STORE_FILE = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'touched-git', 'store.json')
|
|
|
|
const GUIDANCE = [
|
|
'Track the git-revisioned directories you work in for this session.',
|
|
'Whenever you START working in a directory that is inside a git repository or linked worktree',
|
|
'(after a cd, switching worktrees/branches, or beginning a new sub-task in another repo),',
|
|
'immediately call add_touched_git_directory with that directory.',
|
|
'Only call remove_touched_git_directory after the user explicitly tells you to stop tracking a',
|
|
"directory (e.g. \"we don't track that directory anymore\") — never remove one on your own.",
|
|
'Call get_touched_git_directories when you want to review the directories you have touched;',
|
|
'it also surfaces any earlier add calls that were invalid so you can correct them.',
|
|
'The write and edit tools block mutations inside Git repositories or linked worktrees that',
|
|
'are not registered in the current session. Register the exact repository or worktree first.',
|
|
'Writes outside Git are allowed with an explicit warning. Do not evade a Git-scope denial',
|
|
'through bash, another tool, or an alternate path; correct the session registration instead.',
|
|
].join(' ')
|
|
|
|
export const name = 'dsh-touched-git'
|
|
export const inject = ['webServer', 'tools', 'systemPrompt', 'fs']
|
|
|
|
const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' }
|
|
|
|
function writeJson(response, status, body) {
|
|
response.writeHead(status, JSON_HEADERS)
|
|
response.end(JSON.stringify(body))
|
|
}
|
|
|
|
// ---- loopback trust fence (desktop only) ----
|
|
function isIPv4Loopback(address) {
|
|
const parts = address.split('.')
|
|
return parts.length === 4 && parts[0] === '127' && parts.every(p => /^\d{1,3}$/.test(p) && Number(p) <= 255)
|
|
}
|
|
function isLoopbackAddress(address) {
|
|
if (address === undefined) return false
|
|
const a = address.toLowerCase()
|
|
if (a === '::1') return true
|
|
if (a.startsWith('::ffff:')) return isIPv4Loopback(a.slice('::ffff:'.length))
|
|
return isIPv4Loopback(a)
|
|
}
|
|
function isLoopbackHostname(h) { return h === 'localhost' || h === '[::1]' || isIPv4Loopback(h) }
|
|
function isLoopbackRequest(request) {
|
|
if (!isLoopbackAddress(request.socket?.remoteAddress)) return false
|
|
const host = request.headers.host
|
|
if (typeof host !== 'string') return false
|
|
let hostUrl
|
|
try { hostUrl = new URL(`http://${host}`) } catch { return false }
|
|
if (!isLoopbackHostname(hostUrl.hostname)) return false
|
|
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
const origin = request.headers.origin
|
|
if (origin === undefined) return true
|
|
try { return new URL(origin).host === hostUrl.host } catch { return false }
|
|
}
|
|
|
|
/** Resolve a directory to its canonical path + git repository root. Throws a coded error otherwise. */
|
|
export async function resolveDirectoryRepository(directory) {
|
|
let workingDirectory
|
|
try {
|
|
workingDirectory = await realpath(directory)
|
|
const info = await stat(workingDirectory)
|
|
if (!info.isDirectory()) throw new Error('not a directory')
|
|
} catch {
|
|
throw Object.assign(new Error('directory does not resolve on disk'), { code: 'cwd-unavailable' })
|
|
}
|
|
try {
|
|
const result = await execFileAsync('git', ['rev-parse', '--show-toplevel'], {
|
|
cwd: workingDirectory, encoding: 'utf8', timeout: 10_000, windowsHide: true,
|
|
})
|
|
const repository = await realpath(result.stdout.trim())
|
|
return { workingDirectory, repository }
|
|
} catch {
|
|
throw Object.assign(new Error('directory is not inside a git repository'), { code: 'not-a-repository' })
|
|
}
|
|
}
|
|
|
|
/** Parse `git status --porcelain=v1 --branch` into a small structured shape. */
|
|
export async function gitStatus(repository) {
|
|
const result = await execFileAsync('git', ['-C', repository, 'status', '--porcelain=v1', '--branch'], {
|
|
encoding: 'utf8', timeout: 10_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
|
|
})
|
|
const lines = result.stdout.split('\n').filter(Boolean)
|
|
let branch = ''
|
|
const files = []
|
|
for (const line of lines) {
|
|
if (line.startsWith('## ')) { branch = line.slice(3); continue }
|
|
files.push({ xy: line.slice(0, 2), path: line.slice(3) })
|
|
}
|
|
const ns = await numstatMap(repository, ['diff', 'HEAD', '--numstat']).catch(() => new Map())
|
|
for (const f of files) { const s = ns.get(f.path); if (s) { f.add = s.add; f.del = s.del } }
|
|
return { branch, files, clean: files.length === 0 }
|
|
}
|
|
|
|
/** Unified diff for one file in a repository (all uncommitted changes vs HEAD; whole file for untracked). */
|
|
export async function gitFileDiff(repository, file) {
|
|
let headDiff = ''
|
|
try {
|
|
const r = await execFileAsync('git', ['-C', repository, 'diff', 'HEAD', '--', file], {
|
|
encoding: 'utf8', timeout: 10_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true,
|
|
})
|
|
headDiff = r.stdout
|
|
} catch { /* repo may have no commits yet; fall through to --no-index */ }
|
|
if (headDiff.trim() !== '') return { diff: headDiff }
|
|
// Untracked (or no HEAD): render the whole file as additions.
|
|
try {
|
|
const r = await execFileAsync('git', ['-C', repository, 'diff', '--no-index', '--', '/dev/null', join(repository, file)], {
|
|
encoding: 'utf8', timeout: 10_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true,
|
|
})
|
|
return { diff: r.stdout }
|
|
} catch (error) {
|
|
// `git diff --no-index` exits 1 WITH the diff in stdout when the files differ.
|
|
if (error && typeof error.stdout === 'string' && error.stdout.trim() !== '') return { diff: error.stdout }
|
|
return { diff: '' }
|
|
}
|
|
}
|
|
|
|
/** Linear commit history (first-parent) in git2json shape (newest first) for @gitgraph/js import. */
|
|
export async function gitLog(repository, limit = 200) {
|
|
const SEP = '\x1f'
|
|
const fmt = ['%H', '%P', '%an', '%ae', '%at', '%D', '%s'].join(SEP)
|
|
const r = await execFileAsync('git', ['-C', repository, 'log', '--first-parent', '-n', String(limit), '--pretty=format:' + fmt], {
|
|
encoding: 'utf8', timeout: 15_000, maxBuffer: 16 * 1024 * 1024, windowsHide: true,
|
|
})
|
|
const commits = []
|
|
for (const line of r.stdout.split('\n')) {
|
|
if (line.trim() === '') continue
|
|
const [hash, parents, an, ae, at, refs, subject] = line.split(SEP)
|
|
// Keep only the first parent so the graph is a single straight line.
|
|
const first = parents ? parents.split(' ').filter(Boolean)[0] : undefined
|
|
commits.push({
|
|
hash,
|
|
parents: first ? [first] : [],
|
|
author: { name: an || '', email: ae || '', timestamp: (Number(at) || 0) * 1000 },
|
|
refs: refs ? refs.split(', ').map(s => s.trim()).filter(Boolean) : [],
|
|
subject: subject || '',
|
|
})
|
|
}
|
|
return commits
|
|
}
|
|
|
|
/** path -> { add, del } line counts from a `git ... --numstat` invocation. */
|
|
async function numstatMap(repository, args) {
|
|
const r = await execFileAsync('git', ['-C', repository, ...args], {
|
|
encoding: 'utf8', timeout: 12_000, maxBuffer: 16 * 1024 * 1024, windowsHide: true,
|
|
})
|
|
const map = new Map()
|
|
for (const line of r.stdout.split('\n')) {
|
|
if (line.trim() === '') continue
|
|
const cols = line.split('\t')
|
|
if (cols.length < 3) continue
|
|
const add = cols[0] === '-' ? null : Number(cols[0])
|
|
const del = cols[1] === '-' ? null : Number(cols[1])
|
|
map.set(cols[cols.length - 1], { add, del })
|
|
}
|
|
return map
|
|
}
|
|
|
|
/** Files changed in one commit, in the same {xy, path} shape as `git status`. */
|
|
export async function gitCommitFiles(repository, ref) {
|
|
const r = await execFileAsync('git', ['-C', repository, 'diff-tree', '--no-commit-id', '--name-status', '-r', '-M', ref], {
|
|
encoding: 'utf8', timeout: 10_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true,
|
|
})
|
|
const files = []
|
|
for (const line of r.stdout.split('\n')) {
|
|
if (line.trim() === '') continue
|
|
const parts = line.split('\t')
|
|
files.push({ xy: `${(parts[0] || '?')[0]} `, path: parts[parts.length - 1] })
|
|
}
|
|
const ns = await numstatMap(repository, ['diff-tree', '--no-commit-id', '-r', '-M', '--numstat', ref]).catch(() => new Map())
|
|
for (const f of files) { const s = ns.get(f.path); if (s) { f.add = s.add; f.del = s.del } }
|
|
return { branch: `commit ${ref.slice(0, 10)}`, files, clean: files.length === 0 }
|
|
}
|
|
|
|
/** Patch for one file at one commit (no commit-message header). */
|
|
export async function gitFileDiffAtRef(repository, ref, file) {
|
|
const r = await execFileAsync('git', ['-C', repository, 'diff-tree', '--no-commit-id', '-p', '-r', '-M', ref, '--', file], {
|
|
encoding: 'utf8', timeout: 10_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true,
|
|
})
|
|
return { diff: r.stdout }
|
|
}
|
|
|
|
/** Commit metadata (subject + full body + author) for the status header. */
|
|
export async function gitCommitMeta(repository, ref) {
|
|
const SEP = '\x1f'
|
|
const r = await execFileAsync('git', ['-C', repository, 'show', '-s', '--date=short', '--format=' + ['%H', '%an', '%ae', '%ad', '%s', '%b'].join(SEP), ref], {
|
|
encoding: 'utf8', timeout: 10_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
|
|
})
|
|
const parts = r.stdout.split(SEP)
|
|
return { hash: parts[0] || ref, author: { name: parts[1] || '', email: parts[2] || '' }, date: parts[3] || '', subject: parts[4] || '', body: (parts[5] || '').trim() }
|
|
}
|
|
|
|
/** Whole diff: every file in a commit, or the whole working tree vs HEAD. */
|
|
export async function gitWholeDiff(repository, ref) {
|
|
const args = ref
|
|
? ['-C', repository, 'diff-tree', '--no-commit-id', '-p', '-r', '-M', ref]
|
|
: ['-C', repository, 'diff', 'HEAD']
|
|
const r = await execFileAsync('git', args, { encoding: 'utf8', timeout: 20_000, maxBuffer: 32 * 1024 * 1024, windowsHide: true })
|
|
return { diff: r.stdout }
|
|
}
|
|
|
|
/** A commit hash we accept as a ref (never an option/flag). */
|
|
function isCommitRef(ref) { return typeof ref === 'string' && /^[0-9a-fA-F]{4,64}$/.test(ref) }
|
|
|
|
/** Reject path segments that escape the repository. */
|
|
function isSafeRelative(file) {
|
|
if (typeof file !== 'string' || file === '' || isAbsolute(file)) return false
|
|
return !file.split(/[/\\]/).includes('..')
|
|
}
|
|
|
|
function nowIso() { return new Date().toISOString() }
|
|
|
|
/** Per-session store of touched git directories, persisted atomically to STORE_FILE. */
|
|
export class TouchedGitStore {
|
|
constructor(filePath = STORE_FILE) {
|
|
this.filePath = filePath
|
|
this.sessions = new Map() // sessionId -> Map(id -> entry)
|
|
this.ready = this.load()
|
|
this.pendingWrite = Promise.resolve()
|
|
}
|
|
async load() {
|
|
try {
|
|
const parsed = JSON.parse(await readFile(this.filePath, 'utf8'))
|
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return
|
|
for (const [sessionId, list] of Object.entries(parsed)) {
|
|
if (!Array.isArray(list)) continue
|
|
const map = new Map()
|
|
for (const e of list) if (e && typeof e.id === 'string') map.set(e.id, e)
|
|
this.sessions.set(sessionId, map)
|
|
}
|
|
} catch { /* fresh */ }
|
|
}
|
|
async list(sessionId) {
|
|
await this.ready
|
|
return [...(this.sessions.get(sessionId)?.values() ?? [])]
|
|
}
|
|
isRegistered(sessionId, repository) {
|
|
return [...(this.sessions.get(sessionId)?.values() ?? [])].some(entry => entry.ok && entry.repository === repository)
|
|
}
|
|
async upsert(sessionId, entry) {
|
|
await this.ready
|
|
let map = this.sessions.get(sessionId)
|
|
if (map === undefined) { map = new Map(); this.sessions.set(sessionId, map) }
|
|
map.set(entry.id, entry)
|
|
await this.flush()
|
|
return entry
|
|
}
|
|
async remove(sessionId, predicate) {
|
|
await this.ready
|
|
const map = this.sessions.get(sessionId)
|
|
if (map === undefined) return []
|
|
const removed = []
|
|
for (const [id, entry] of [...map]) if (predicate(entry)) { map.delete(id); removed.push(entry) }
|
|
if (map.size === 0) this.sessions.delete(sessionId)
|
|
await this.flush()
|
|
return removed
|
|
}
|
|
async flush() {
|
|
this.pendingWrite = this.pendingWrite.then(() => this.persist())
|
|
await this.pendingWrite
|
|
}
|
|
async persist() {
|
|
await mkdir(dirname(this.filePath), { recursive: true })
|
|
const obj = {}
|
|
for (const [sessionId, map] of this.sessions) obj[sessionId] = [...map.values()]
|
|
const tmp = `${this.filePath}.tmp-${process.pid}`
|
|
await writeFile(tmp, `${JSON.stringify(obj, null, 2)}\n`, 'utf8')
|
|
await rename(tmp, this.filePath)
|
|
}
|
|
}
|
|
|
|
function sessionIdOf(execution) {
|
|
const sessionId = execution.agent?.session.header.id
|
|
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
throw new Error('an active agent session is required')
|
|
}
|
|
return sessionId
|
|
}
|
|
|
|
function absoluteArgPath(execution, raw) {
|
|
const base = execution.agent?.session.header.cwd
|
|
const trimmed = String(raw ?? '').trim()
|
|
return isAbsolute(trimmed) ? trimmed : resolve(base || process.cwd(), trimmed)
|
|
}
|
|
|
|
function registerTools(context, store) {
|
|
const add = context.tools.register(defineTool({
|
|
name: 'add_touched_git_directory',
|
|
description: 'Record a git-revisioned directory this session is now working in. Call it whenever you start editing/building/committing in a repository or linked worktree, including after a cd or worktree switch. Invalid directories are still recorded as errors so you can review them later with get_touched_git_directories.',
|
|
parameters: {
|
|
path: { type: 'string', required: true, description: 'Absolute path, or a path relative to the session cwd, inside a git repository or linked worktree.' },
|
|
},
|
|
output: {
|
|
schema: { type: 'object', additionalProperties: true },
|
|
render: (_arguments, value) => [{
|
|
type: 'text',
|
|
text: value.ok
|
|
? `Tracking git directory: ${value.repository} (${value.tracked} tracked)`
|
|
: `Could not track ${value.path}: ${value.error?.message ?? 'invalid directory'} (recorded as an error)`,
|
|
}],
|
|
},
|
|
execute: async (args, execution) => {
|
|
execution.signal.throwIfAborted()
|
|
const sessionId = sessionIdOf(execution)
|
|
const absolutePath = absoluteArgPath(execution, args.path)
|
|
try {
|
|
const { workingDirectory, repository } = await resolveDirectoryRepository(absolutePath)
|
|
const entry = { id: repository, path: absolutePath, workingDirectory, repository, ok: true, addedAt: nowIso() }
|
|
await store.upsert(sessionId, entry)
|
|
return { ok: true, repository, workingDirectory, tracked: (await store.list(sessionId)).length }
|
|
} catch (error) {
|
|
const err = { code: error?.code || 'error', message: error?.message || String(error) }
|
|
const entry = { id: absolutePath, path: absolutePath, ok: false, error: err, addedAt: nowIso() }
|
|
await store.upsert(sessionId, entry)
|
|
return { ok: false, path: absolutePath, error: err }
|
|
}
|
|
},
|
|
}))
|
|
|
|
const remove = context.tools.register(defineTool({
|
|
name: 'remove_touched_git_directory',
|
|
description: 'Stop tracking a directory. ONLY call this after the user explicitly asks to remove/untrack a directory (e.g. "we don\'t track that directory anymore"). Never remove a directory on your own initiative.',
|
|
parameters: {
|
|
path: { type: 'string', required: true, description: 'The directory (or its repository root) to stop tracking. Matches by repository root or the recorded path.' },
|
|
},
|
|
output: {
|
|
schema: { type: 'object', additionalProperties: true },
|
|
render: (_arguments, value) => [{
|
|
type: 'text',
|
|
text: value.removed
|
|
? `Stopped tracking ${value.removed} director${value.removed === 1 ? 'y' : 'ies'}: ${value.entries.join(', ')}`
|
|
: 'No matching tracked directory to remove.',
|
|
}],
|
|
},
|
|
execute: async (args, execution) => {
|
|
execution.signal.throwIfAborted()
|
|
const sessionId = sessionIdOf(execution)
|
|
const absolutePath = absoluteArgPath(execution, args.path)
|
|
let repository
|
|
try { ({ repository } = await resolveDirectoryRepository(absolutePath)) } catch { /* may be an invalid entry */ }
|
|
const removed = await store.remove(sessionId, (e) =>
|
|
e.id === absolutePath || e.path === absolutePath || (repository !== undefined && e.repository === repository))
|
|
return { removed: removed.length, entries: removed.map(e => e.repository || e.path) }
|
|
},
|
|
}))
|
|
|
|
const get = context.tools.register(defineTool({
|
|
name: 'get_touched_git_directories',
|
|
description: 'List the git-revisioned directories tracked for this session, so you can review where you have been working. Entries whose earlier add call was invalid are returned with an error field.',
|
|
parameters: {},
|
|
output: {
|
|
schema: { type: 'object', additionalProperties: true },
|
|
render: (_arguments, value) => [{
|
|
type: 'text',
|
|
text: [
|
|
`Tracked git directories (${value.count}):`,
|
|
...value.directories.map(d => `- ${d.repository}`),
|
|
...(value.errors.length ? ['Invalid earlier adds:', ...value.errors.map(e => `- ${e.path}: ${e.error?.message ?? 'error'}`)] : []),
|
|
].join('\n'),
|
|
}],
|
|
},
|
|
execute: async (_args, execution) => {
|
|
execution.signal.throwIfAborted()
|
|
const sessionId = sessionIdOf(execution)
|
|
const entries = await store.list(sessionId)
|
|
const valid = entries.filter(e => e.ok)
|
|
const errors = entries.filter(e => !e.ok).map(e => ({ path: e.path, error: e.error }))
|
|
return {
|
|
count: entries.length,
|
|
directories: valid.map(e => ({ repository: e.repository, workingDirectory: e.workingDirectory, addedAt: e.addedAt })),
|
|
errors,
|
|
}
|
|
},
|
|
}))
|
|
|
|
return [add, remove, get]
|
|
}
|
|
|
|
function requestSessionId(requestUrl, host) {
|
|
try { return new URL(requestUrl ?? '/', `http://${host}`).searchParams.get('sessionId')?.trim() || '' } catch { return '' }
|
|
}
|
|
function requestParam(requestUrl, host, key) {
|
|
try { return new URL(requestUrl ?? '/', `http://${host}`).searchParams.get(key)?.trim() || '' } catch { return '' }
|
|
}
|
|
|
|
export function apply(context) {
|
|
const store = new TouchedGitStore()
|
|
const editor = new EditorWorkspaces()
|
|
const editorOpenHandler = createEditorOpenHandler({ store, resolveDirectoryRepository, isLoopbackRequest, editor, writeJson })
|
|
const mutations = new MutationStore(join(dirname(STORE_FILE), 'mutations.json'))
|
|
const mutationHandlers = createMutationHandlers({ mutations, store, editor, resolveDirectoryRepository, isLoopbackRequest, writeJson })
|
|
|
|
const listHandler = async (request, response) => {
|
|
if ((request.method ?? 'GET').toUpperCase() !== 'GET') return writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
|
|
if (!isLoopbackRequest(request)) return writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
|
|
const sessionId = requestSessionId(request.url, request.headers.host)
|
|
if (sessionId === '') return writeJson(response, 400, { ok: false, error: { code: 'invalid-session', message: 'missing session id' } })
|
|
const stored = await store.list(sessionId)
|
|
// Re-validate each tracked directory so the panel reflects reality.
|
|
const entries = await Promise.all(stored.map(async (e) => {
|
|
if (!e.ok) return e
|
|
try {
|
|
await resolveDirectoryRepository(e.repository)
|
|
return e
|
|
} catch (error) {
|
|
return { ...e, ok: false, error: { code: error?.code || 'error', message: error?.message || String(error) } }
|
|
}
|
|
}))
|
|
writeJson(response, 200, { ok: true, value: { entries } })
|
|
}
|
|
|
|
const statusHandler = async (request, response) => {
|
|
if ((request.method ?? 'GET').toUpperCase() !== 'GET') return writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
|
|
if (!isLoopbackRequest(request)) return writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
|
|
const sessionId = requestSessionId(request.url, request.headers.host)
|
|
const path = requestParam(request.url, request.headers.host, 'path')
|
|
if (sessionId === '' || path === '') return writeJson(response, 400, { ok: false, error: { code: 'invalid-request', message: 'missing session id or path' } })
|
|
// Only allow status for a directory this session actually tracks.
|
|
const tracked = (await store.list(sessionId)).some(e => e.repository === path || e.path === path || e.id === path)
|
|
if (!tracked) return writeJson(response, 403, { ok: false, error: { code: 'not-tracked', message: 'path is not a tracked directory' } })
|
|
const ref = requestParam(request.url, request.headers.host, 'ref')
|
|
try {
|
|
const { repository } = await resolveDirectoryRepository(path)
|
|
// A commit ref -> that commit's changed files + message; otherwise the working tree.
|
|
let status
|
|
if (ref !== '' && isCommitRef(ref)) {
|
|
status = await gitCommitFiles(repository, ref)
|
|
try { status.commit = await gitCommitMeta(repository, ref) } catch { /* meta optional */ }
|
|
} else {
|
|
status = await gitStatus(repository)
|
|
}
|
|
writeJson(response, 200, { ok: true, value: { repository, ...status } })
|
|
} catch (error) {
|
|
writeJson(response, 200, { ok: false, error: { code: error?.code || 'error', message: error?.message || String(error) } })
|
|
}
|
|
}
|
|
|
|
const logHandler = async (request, response) => {
|
|
if ((request.method ?? 'GET').toUpperCase() !== 'GET') return writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
|
|
if (!isLoopbackRequest(request)) return writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
|
|
const sessionId = requestSessionId(request.url, request.headers.host)
|
|
const path = requestParam(request.url, request.headers.host, 'path')
|
|
if (sessionId === '' || path === '') return writeJson(response, 400, { ok: false, error: { code: 'invalid-request', message: 'missing session id or path' } })
|
|
const tracked = (await store.list(sessionId)).some(e => e.repository === path || e.path === path || e.id === path)
|
|
if (!tracked) return writeJson(response, 403, { ok: false, error: { code: 'not-tracked', message: 'path is not a tracked directory' } })
|
|
const limit = Math.max(1, Math.min(2000, Number(requestParam(request.url, request.headers.host, 'limit')) || 20))
|
|
try {
|
|
const { repository } = await resolveDirectoryRepository(path)
|
|
const commits = await gitLog(repository, limit)
|
|
writeJson(response, 200, { ok: true, value: { commits } })
|
|
} catch (error) {
|
|
writeJson(response, 200, { ok: false, error: { code: error?.code || 'error', message: error?.message || String(error) } })
|
|
}
|
|
}
|
|
|
|
const diffHandler = async (request, response) => {
|
|
if ((request.method ?? 'GET').toUpperCase() !== 'GET') return writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
|
|
if (!isLoopbackRequest(request)) return writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
|
|
const sessionId = requestSessionId(request.url, request.headers.host)
|
|
const path = requestParam(request.url, request.headers.host, 'path')
|
|
const file = requestParam(request.url, request.headers.host, 'file')
|
|
if (sessionId === '' || path === '') return writeJson(response, 400, { ok: false, error: { code: 'invalid-request', message: 'missing session id or path' } })
|
|
if (file !== '' && !isSafeRelative(file)) return writeJson(response, 400, { ok: false, error: { code: 'invalid-file', message: 'file must be a repo-relative path' } })
|
|
const tracked = (await store.list(sessionId)).some(e => e.repository === path || e.path === path || e.id === path)
|
|
if (!tracked) return writeJson(response, 403, { ok: false, error: { code: 'not-tracked', message: 'path is not a tracked directory' } })
|
|
const ref = requestParam(request.url, request.headers.host, 'ref')
|
|
const commitRef = ref !== '' && isCommitRef(ref) ? ref : ''
|
|
try {
|
|
const { repository } = await resolveDirectoryRepository(path)
|
|
let out
|
|
if (file === '') out = await gitWholeDiff(repository, commitRef) // whole commit / working tree
|
|
else if (commitRef) out = await gitFileDiffAtRef(repository, commitRef, file)
|
|
else out = await gitFileDiff(repository, file)
|
|
writeJson(response, 200, { ok: true, value: { file, diff: out.diff } })
|
|
} catch (error) {
|
|
writeJson(response, 200, { ok: false, error: { code: error?.code || 'error', message: error?.message || String(error) } })
|
|
}
|
|
}
|
|
|
|
const staticHandler = (fileName, contentType) => async (request, response) => {
|
|
if ((request.method ?? 'GET').toUpperCase() !== 'GET') return writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
|
|
try {
|
|
const buffer = await readFile(join(VENDOR_DIR, fileName))
|
|
response.writeHead(200, { 'content-type': contentType, 'cache-control': 'public, max-age=86400' })
|
|
response.end(buffer)
|
|
} catch {
|
|
response.writeHead(404, { 'content-type': 'text/plain' })
|
|
response.end('not found')
|
|
}
|
|
}
|
|
|
|
context.effect(() => {
|
|
const disposers = [
|
|
context.webServer.register({ kind: 'exact', path: '/touched-git/list', handler: listHandler }),
|
|
context.webServer.register({ kind: 'exact', path: '/touched-git/editor/open', handler: editorOpenHandler }),
|
|
context.webServer.register({ kind: 'exact', path: '/touched-git/mutation', handler: mutationHandlers.describeHandler }),
|
|
context.webServer.register({ kind: 'exact', path: '/touched-git/editor/open-file', handler: mutationHandlers.openFileHandler }),
|
|
registerGitWriteGuard(context, { store, onSuccessfulMutation: mutation => mutations.record(mutation) }),
|
|
context.webServer.register({ kind: 'exact', path: '/touched-git/status', handler: statusHandler }),
|
|
context.webServer.register({ kind: 'exact', path: '/touched-git/log', handler: logHandler }),
|
|
context.webServer.register({ kind: 'exact', path: '/touched-git/diff', handler: diffHandler }),
|
|
context.webServer.register({ kind: 'exact', path: '/touched-git/vendor/gitgraph.js', handler: staticHandler('gitgraph.umd.min.js', 'application/javascript; charset=utf-8') }),
|
|
context.webServer.register({ kind: 'exact', path: '/touched-git/vendor/diff2html.js', handler: staticHandler('diff2html.min.js', 'application/javascript; charset=utf-8') }),
|
|
context.webServer.register({ kind: 'exact', path: '/touched-git/vendor/diff2html.css', handler: staticHandler('diff2html.min.css', 'text/css; charset=utf-8') }),
|
|
...registerTools(context, store),
|
|
context.systemPrompt.section({ name: 'plugin:touched-git', order: 146, text: GUIDANCE }),
|
|
]
|
|
return async () => {
|
|
for (const dispose of disposers.reverse()) dispose()
|
|
await editor.dispose()
|
|
}
|
|
}, 'dsh-touched-git: tools + routes + guidance')
|
|
}
|