|
|
@@ -1,11 +1,9 @@
|
|
|
/** Git working-tree snapshots, tree diffs, and ignore checks through the subprocess capability. */
|
|
|
-import { createHash } from 'node:crypto'
|
|
|
-import { copyFile, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
|
|
|
+import { copyFile, mkdtemp, rm } from 'node:fs/promises'
|
|
|
import { tmpdir } from 'node:os'
|
|
|
-import { join, relative } from 'node:path'
|
|
|
+import { join } from 'node:path'
|
|
|
import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
|
|
|
import { parseNumstat, type NumstatEntry } from './numstat.ts'
|
|
|
-import { isInside, toPosix } from './paths.ts'
|
|
|
|
|
|
/** Milliseconds a git child gets to exit after termination starts; a fixed lifecycle constant. */
|
|
|
const TERMINATE_GRACE_MS = 2_000
|
|
|
@@ -90,64 +88,33 @@ function ok(result: GitRunResult, what: string): GitRunResult {
|
|
|
return result
|
|
|
}
|
|
|
|
|
|
-/**
|
|
|
- * The git repository a Session working directory snapshots into: the
|
|
|
- * enclosing repository when one exists, otherwise a shadow repository whose
|
|
|
- * git directory lives under the Harness home while its work tree is the
|
|
|
- * working directory itself.
|
|
|
- */
|
|
|
+/** The repository enclosing a Session working directory. */
|
|
|
export interface GitWorkspace {
|
|
|
- kind: 'repository' | 'shadow'
|
|
|
- /** Work-tree root: the repository top level, or the working directory for a shadow. */
|
|
|
+ /** Repository top-level directory, the root every diff path is relative to. */
|
|
|
root: string
|
|
|
/** Absolute git directory holding the index and objects. */
|
|
|
gitDir: string
|
|
|
- /** Environment that addresses the repository for every command. */
|
|
|
- env: Readonly<Record<string, string>>
|
|
|
-}
|
|
|
-
|
|
|
-/** Where and how shadow repositories are created. */
|
|
|
-export interface ShadowRepositoryOptions {
|
|
|
- /** Directory that holds one shadow git directory per working directory. */
|
|
|
- home: string
|
|
|
- /** `info/exclude` patterns written on every snapshot. */
|
|
|
- excludes: readonly string[]
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Locate the repository for a working directory, creating the shadow
|
|
|
- * repository when the directory is not inside one.
|
|
|
+ * Locate the repository enclosing a working directory.
|
|
|
* @param git - command runner.
|
|
|
* @param cwd - absolute Session working directory.
|
|
|
- * @param shadow - shadow repository placement and excludes.
|
|
|
* @param signal - cancellation.
|
|
|
- * @returns the addressed repository.
|
|
|
+ * @returns the repository, or null when the directory is not inside one.
|
|
|
*/
|
|
|
-export async function locateGitWorkspace(
|
|
|
- git: GitRunner, cwd: string, shadow: ShadowRepositoryOptions, signal: AbortSignal,
|
|
|
-): Promise<GitWorkspace> {
|
|
|
+export async function locateGitWorkspace(git: GitRunner, cwd: string, signal: AbortSignal): Promise<GitWorkspace | null> {
|
|
|
const found = await git.run(['rev-parse', '--show-toplevel', '--absolute-git-dir'], { cwd, signal })
|
|
|
- if (found.exitCode === 0) {
|
|
|
- const [root, gitDir] = found.stdout.split('\n') as [string, string]
|
|
|
- return { kind: 'repository', root, gitDir, env: {} }
|
|
|
- }
|
|
|
- const gitDir = join(shadow.home, createHash('sha256').update(cwd).digest('hex').slice(0, 16))
|
|
|
- const env = { GIT_DIR: gitDir, GIT_WORK_TREE: cwd }
|
|
|
- await mkdir(gitDir, { recursive: true })
|
|
|
- ok(await git.run(['init', '-q'], { cwd, env, signal }), `git init of shadow repository ${gitDir}`)
|
|
|
- const excludes = [...shadow.excludes]
|
|
|
- if (isInside(cwd, shadow.home)) excludes.push(`/${toPosix(relative(cwd, shadow.home))}/`)
|
|
|
- await mkdir(join(gitDir, 'info'), { recursive: true })
|
|
|
- await writeFile(join(gitDir, 'info', 'exclude'), `${excludes.join('\n')}\n`)
|
|
|
- return { kind: 'shadow', root: cwd, gitDir, env }
|
|
|
+ if (found.exitCode !== 0) return null
|
|
|
+ const [root, gitDir] = found.stdout.split('\n') as [string, string]
|
|
|
+ return { root, gitDir }
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Write the complete work tree, including untracked and modified files but
|
|
|
- * not ignored ones, as a tree object through a private index. The
|
|
|
- * repository's own index seeds the stat cache and is never modified, and an
|
|
|
- * in-progress merge keeps its unmerged entries; a shadow repository keeps the
|
|
|
- * refreshed index for its next snapshot.
|
|
|
+ * not ignored ones, as a tree object through a private index seeded from the
|
|
|
+ * repository's index. That index, the work tree, and every ref stay unchanged;
|
|
|
+ * an in-progress merge keeps its unmerged entries.
|
|
|
* @param git - command runner.
|
|
|
* @param workspace - addressed repository.
|
|
|
* @param signal - cancellation.
|
|
|
@@ -157,14 +124,11 @@ export async function snapshotTree(git: GitRunner, workspace: GitWorkspace, sign
|
|
|
const scratch = await mkdtemp(join(tmpdir(), 'dsh-workspace-changes-'))
|
|
|
try {
|
|
|
const index = join(scratch, 'index')
|
|
|
- const persisted = join(workspace.gitDir, 'index')
|
|
|
- // A missing persisted index (a fresh shadow repository) starts from scratch.
|
|
|
- await copyFile(persisted, index).then(() => undefined, () => undefined)
|
|
|
- const env = { ...workspace.env, GIT_INDEX_FILE: index }
|
|
|
+ // A repository without an index yet (fresh `git init`) starts from scratch.
|
|
|
+ await copyFile(join(workspace.gitDir, 'index'), index).then(() => undefined, () => undefined)
|
|
|
+ const env = { GIT_INDEX_FILE: index }
|
|
|
ok(await git.run(['add', '--all', '--ignore-errors'], { cwd: workspace.root, env, signal }), `git add in ${workspace.root}`)
|
|
|
- const tree = ok(await git.run(['write-tree'], { cwd: workspace.root, env, signal }), 'git write-tree').stdout.trim()
|
|
|
- if (workspace.kind === 'shadow') await rename(index, persisted)
|
|
|
- return tree
|
|
|
+ return ok(await git.run(['write-tree'], { cwd: workspace.root, env, signal }), 'git write-tree').stdout.trim()
|
|
|
} finally {
|
|
|
await rm(scratch, { recursive: true, force: true })
|
|
|
}
|
|
|
@@ -177,16 +141,14 @@ export async function snapshotTree(git: GitRunner, workspace: GitWorkspace, sign
|
|
|
* @param before - turn-start tree id.
|
|
|
* @param after - turn-end tree id.
|
|
|
* @param signal - cancellation.
|
|
|
- * @returns changed files relative to the work-tree root.
|
|
|
+ * @returns changed files relative to the repository root.
|
|
|
* @throws when git fails or the output exceeded the cap.
|
|
|
*/
|
|
|
export async function diffTrees(
|
|
|
git: GitRunner, workspace: GitWorkspace, before: string, after: string, signal: AbortSignal,
|
|
|
): Promise<NumstatEntry[]> {
|
|
|
if (before === after) return []
|
|
|
- const result = ok(await git.run(['diff-tree', '-r', '-M', '-z', '--numstat', before, after], {
|
|
|
- cwd: workspace.root, env: workspace.env, signal,
|
|
|
- }), 'git diff-tree')
|
|
|
+ const result = ok(await git.run(['diff-tree', '-r', '-M', '-z', '--numstat', before, after], { cwd: workspace.root, signal }), 'git diff-tree')
|
|
|
if (result.truncated) throw new Error('git diff-tree output exceeded the configured cap')
|
|
|
return parseNumstat(result.stdout)
|
|
|
}
|
|
|
@@ -197,7 +159,7 @@ export async function diffTrees(
|
|
|
* counts as covered by snapshots.
|
|
|
* @param git - command runner.
|
|
|
* @param workspace - addressed repository.
|
|
|
- * @param paths - slash-separated paths relative to the work-tree root.
|
|
|
+ * @param paths - slash-separated paths relative to the repository root.
|
|
|
* @param signal - cancellation.
|
|
|
* @returns the ignored members of `paths`.
|
|
|
*/
|
|
|
@@ -205,9 +167,7 @@ export async function ignoredPaths(
|
|
|
git: GitRunner, workspace: GitWorkspace, paths: readonly string[], signal: AbortSignal,
|
|
|
): Promise<Set<string>> {
|
|
|
if (paths.length === 0) return new Set()
|
|
|
- const result = await git.run(['check-ignore', '-z', '--stdin'], {
|
|
|
- cwd: workspace.root, env: workspace.env, stdin: `${paths.join('\0')}\0`, signal,
|
|
|
- })
|
|
|
+ const result = await git.run(['check-ignore', '-z', '--stdin'], { cwd: workspace.root, stdin: `${paths.join('\0')}\0`, signal })
|
|
|
if (result.exitCode === 1) return new Set()
|
|
|
return new Set(ok(result, 'git check-ignore').stdout.split('\0').filter(path => path !== ''))
|
|
|
}
|