@fusionkit/workspace 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/dist/git.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared git invocation used across capture, materialization, pull, the
3
+ * test fixtures, and the compute adapter. `git` is a hard runtime
4
+ * dependency of this package by design — its entire job is git workspace
5
+ * capture — so a missing or failing git surfaces as a clear error rather
6
+ * than a swallowed condition.
7
+ */
8
+ /** Upper bound on captured git output (bundles/diffs can be large). */
9
+ export declare const GIT_MAX_BUFFER_BYTES: number;
10
+ export type GitOptions = {
11
+ /** Return stdout even on non-zero exit instead of throwing. */
12
+ allowFail?: boolean;
13
+ maxBuffer?: number;
14
+ };
15
+ /** Run git and return stdout as text. */
16
+ export declare function gitText(cwd: string, args: string[], options?: GitOptions): string;
17
+ /** Run git and return stdout as raw bytes (for bundles and binary diffs). */
18
+ export declare function gitBinary(cwd: string, args: string[], options?: GitOptions): Buffer;
package/dist/git.js ADDED
@@ -0,0 +1,42 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /**
3
+ * Shared git invocation used across capture, materialization, pull, the
4
+ * test fixtures, and the compute adapter. `git` is a hard runtime
5
+ * dependency of this package by design — its entire job is git workspace
6
+ * capture — so a missing or failing git surfaces as a clear error rather
7
+ * than a swallowed condition.
8
+ */
9
+ /** Upper bound on captured git output (bundles/diffs can be large). */
10
+ export const GIT_MAX_BUFFER_BYTES = 256 * 1024 * 1024;
11
+ function fail(args, detail) {
12
+ throw new Error(`git ${args.join(" ")} failed: ${detail}`);
13
+ }
14
+ /** Run git and return stdout as text. */
15
+ export function gitText(cwd, args, options = {}) {
16
+ const result = spawnSync("git", args, {
17
+ cwd,
18
+ encoding: "utf8",
19
+ maxBuffer: options.maxBuffer ?? GIT_MAX_BUFFER_BYTES
20
+ });
21
+ if (result.error) {
22
+ fail(args, result.error.message);
23
+ }
24
+ if (result.status !== 0 && !options.allowFail) {
25
+ fail(args, result.stderr || result.stdout || `exit ${result.status}`);
26
+ }
27
+ return result.stdout;
28
+ }
29
+ /** Run git and return stdout as raw bytes (for bundles and binary diffs). */
30
+ export function gitBinary(cwd, args, options = {}) {
31
+ const result = spawnSync("git", args, {
32
+ cwd,
33
+ maxBuffer: options.maxBuffer ?? GIT_MAX_BUFFER_BYTES
34
+ });
35
+ if (result.error) {
36
+ fail(args, result.error.message);
37
+ }
38
+ if (result.status !== 0 && !options.allowFail) {
39
+ fail(args, result.stderr.toString() || `exit ${result.status}`);
40
+ }
41
+ return result.stdout;
42
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @fusionkit/workspace — git workspace capture, materialization, output
3
+ * collection, and divergence-safe pull. Shared by the CLI (capture before
4
+ * a run), the runner (materialize inside a session, collect the output),
5
+ * and the handoff SDK (checkpoint the workspace before continuation).
6
+ */
7
+ export { captureWorkspace, collectOutput, materializeWorkspace, pullRun } from "./workspace.js";
8
+ export { gitText } from "./git.js";
9
+ export { parseWorkspaceRelativePath, resolveInsideWorkspace } from "./paths.js";
10
+ export type { CapturedWorkspace, PullResult, WorkspaceOutput } from "./workspace.js";
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @fusionkit/workspace — git workspace capture, materialization, output
3
+ * collection, and divergence-safe pull. Shared by the CLI (capture before
4
+ * a run), the runner (materialize inside a session, collect the output),
5
+ * and the handoff SDK (checkpoint the workspace before continuation).
6
+ */
7
+ export { captureWorkspace, collectOutput, materializeWorkspace, pullRun } from "./workspace.js";
8
+ export { gitText } from "./git.js";
9
+ export { parseWorkspaceRelativePath, resolveInsideWorkspace } from "./paths.js";
@@ -0,0 +1,12 @@
1
+ declare const workspaceRootBrand: unique symbol;
2
+ declare const workspaceRelativePathBrand: unique symbol;
3
+ export type WorkspaceRoot = string & {
4
+ readonly [workspaceRootBrand]: true;
5
+ };
6
+ export type WorkspaceRelativePath = string & {
7
+ readonly [workspaceRelativePathBrand]: true;
8
+ };
9
+ export declare function parseWorkspaceRoot(path: string): WorkspaceRoot;
10
+ export declare function parseWorkspaceRelativePath(path: string): WorkspaceRelativePath;
11
+ export declare function resolveInsideWorkspace(root: WorkspaceRoot | string, relativePath: WorkspaceRelativePath | string): string;
12
+ export {};
package/dist/paths.js ADDED
@@ -0,0 +1,25 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+ import { parseWorkspaceManifestPath } from "@fusionkit/protocol";
3
+ function normalizeSlashes(path) {
4
+ return path.split("\\").join("/");
5
+ }
6
+ export function parseWorkspaceRoot(path) {
7
+ if (path.length === 0)
8
+ throw new Error("workspace root must not be empty");
9
+ return resolve(path);
10
+ }
11
+ export function parseWorkspaceRelativePath(path) {
12
+ return parseWorkspaceManifestPath(normalizeSlashes(path));
13
+ }
14
+ export function resolveInsideWorkspace(root, relativePath) {
15
+ const rootPath = parseWorkspaceRoot(root);
16
+ const rel = parseWorkspaceRelativePath(String(relativePath));
17
+ const resolved = resolve(rootPath, rel);
18
+ const back = relative(rootPath, resolved);
19
+ if (back === ".." ||
20
+ back.startsWith(`..${sep}`) ||
21
+ isAbsolute(back)) {
22
+ throw new Error(`workspace path escapes root: ${relativePath}`);
23
+ }
24
+ return resolved;
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,160 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { test } from "node:test";
7
+ import { sha256Hex } from "@fusionkit/protocol";
8
+ import { captureWorkspace, collectOutput, materializeWorkspace, matchesPattern, pullRun } from "../workspace.js";
9
+ import { resolveInsideWorkspace } from "../paths.js";
10
+ function git(cwd, args) {
11
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
12
+ assert.equal(result.status, 0, `git ${args.join(" ")}: ${result.stderr}`);
13
+ return result.stdout;
14
+ }
15
+ function makeRepo() {
16
+ const dir = mkdtempSync(join(tmpdir(), "warrant-test-repo-"));
17
+ git(dir, ["init", "--quiet", "--initial-branch=main"]);
18
+ git(dir, ["config", "user.email", "test@warrant.local"]);
19
+ git(dir, ["config", "user.name", "warrant-test"]);
20
+ writeFileSync(join(dir, "README.md"), "# fixture\n");
21
+ writeFileSync(join(dir, "src.txt"), "original\n");
22
+ git(dir, ["add", "-A"]);
23
+ git(dir, ["commit", "--quiet", "-m", "init"]);
24
+ return dir;
25
+ }
26
+ test("glob matching", () => {
27
+ assert.equal(matchesPattern(".env", ".env"), true);
28
+ assert.equal(matchesPattern("config/.env.local", ".env.*"), true);
29
+ assert.equal(matchesPattern("keys/server.pem", "*.pem"), true);
30
+ assert.equal(matchesPattern("notes.md", "*.pem"), false);
31
+ assert.equal(matchesPattern("a/b/c.txt", "a/**"), true);
32
+ });
33
+ test("workspace path helpers reject traversal and absolute paths", () => {
34
+ const repo = makeRepo();
35
+ try {
36
+ assert.throws(() => resolveInsideWorkspace(repo, "../outside.txt"));
37
+ assert.throws(() => resolveInsideWorkspace(repo, "/tmp/outside.txt"));
38
+ assert.equal(readFileSync(resolveInsideWorkspace(repo, "README.md"), "utf8"), "# fixture\n");
39
+ }
40
+ finally {
41
+ rmSync(repo, { recursive: true, force: true });
42
+ }
43
+ });
44
+ test("capture denies secrets, includes allowlisted untracked, records denials", () => {
45
+ const repo = makeRepo();
46
+ try {
47
+ writeFileSync(join(repo, "src.txt"), "modified\n");
48
+ writeFileSync(join(repo, "notes.md"), "untracked notes\n");
49
+ writeFileSync(join(repo, ".env"), "SECRET=do-not-capture\n");
50
+ const captured = captureWorkspace(repo, { allowUntracked: ["*.md"] });
51
+ assert.equal(captured.manifest.untrackedFiles.length, 1);
52
+ assert.equal(captured.manifest.untrackedFiles[0]?.path, "notes.md");
53
+ assert.deepEqual(captured.manifest.deniedPaths, [".env"]);
54
+ assert.ok(captured.dirtyDiff, "uncommitted change should produce a diff");
55
+ assert.ok(captured.manifest.dirtyDiffHash);
56
+ const bundleText = captured.bundle.toString("latin1");
57
+ assert.ok(!bundleText.includes("do-not-capture"));
58
+ }
59
+ finally {
60
+ rmSync(repo, { recursive: true, force: true });
61
+ }
62
+ });
63
+ test("materialize reproduces the captured workspace; output diff round-trips", async () => {
64
+ const repo = makeRepo();
65
+ const sessionDir = mkdtempSync(join(tmpdir(), "warrant-test-session-"));
66
+ try {
67
+ writeFileSync(join(repo, "src.txt"), "modified\n");
68
+ writeFileSync(join(repo, "notes.md"), "untracked notes\n");
69
+ const captured = captureWorkspace(repo, { allowUntracked: ["*.md"] });
70
+ const blobs = new Map();
71
+ blobs.set(sha256Hex(captured.bundle), captured.bundle);
72
+ if (captured.dirtyDiff) {
73
+ blobs.set(sha256Hex(captured.dirtyDiff), captured.dirtyDiff);
74
+ }
75
+ for (const file of captured.untracked) {
76
+ blobs.set(file.file.hash, file.content);
77
+ }
78
+ const materialized = await materializeWorkspace(sessionDir, captured.manifest, (hash) => {
79
+ const blob = blobs.get(hash);
80
+ if (!blob)
81
+ throw new Error(`missing blob ${hash}`);
82
+ return Promise.resolve(blob);
83
+ });
84
+ assert.equal(readFileSync(join(materialized, "src.txt"), "utf8"), "modified\n");
85
+ assert.equal(readFileSync(join(materialized, "notes.md"), "utf8"), "untracked notes\n");
86
+ writeFileSync(join(materialized, "agent-output.txt"), "made by agent\n");
87
+ const output = collectOutput(materialized, captured.manifest.baseRef);
88
+ assert.ok(output.diff.length > 0);
89
+ const paths = output.changedFiles.map((f) => f.path).sort();
90
+ assert.deepEqual(paths, ["agent-output.txt", "notes.md", "src.txt"]);
91
+ }
92
+ finally {
93
+ rmSync(repo, { recursive: true, force: true });
94
+ rmSync(sessionDir, { recursive: true, force: true });
95
+ }
96
+ });
97
+ test("materialize rejects manifest paths that escape the workspace", async () => {
98
+ const repo = makeRepo();
99
+ const sessionDir = mkdtempSync(join(tmpdir(), "warrant-test-session-"));
100
+ try {
101
+ const captured = captureWorkspace(repo);
102
+ const manifest = {
103
+ ...captured.manifest,
104
+ untrackedFiles: [
105
+ {
106
+ path: "../escape.txt",
107
+ hash: sha256Hex(Buffer.from("escape", "utf8")),
108
+ bytes: 6
109
+ }
110
+ ]
111
+ };
112
+ const blobs = new Map();
113
+ blobs.set(sha256Hex(captured.bundle), captured.bundle);
114
+ blobs.set(sha256Hex(Buffer.from("escape", "utf8")), Buffer.from("escape", "utf8"));
115
+ await assert.rejects(materializeWorkspace(sessionDir, manifest, (hash) => {
116
+ const blob = blobs.get(hash);
117
+ if (!blob)
118
+ throw new Error(`missing blob ${hash}`);
119
+ return Promise.resolve(blob);
120
+ }));
121
+ }
122
+ finally {
123
+ rmSync(repo, { recursive: true, force: true });
124
+ rmSync(sessionDir, { recursive: true, force: true });
125
+ }
126
+ });
127
+ test("pull applies cleanly at base ref and branches on divergence", () => {
128
+ const repo = makeRepo();
129
+ try {
130
+ const baseRef = git(repo, ["rev-parse", "HEAD"]).trim();
131
+ const diff = Buffer.from([
132
+ "diff --git a/pulled.txt b/pulled.txt",
133
+ "new file mode 100644",
134
+ "index 0000000..7e55434",
135
+ "--- /dev/null",
136
+ "+++ b/pulled.txt",
137
+ "@@ -0,0 +1 @@",
138
+ "+pulled content",
139
+ ""
140
+ ].join("\n"), "utf8");
141
+ const clean = pullRun(repo, "run_clean", baseRef, diff);
142
+ assert.deepEqual(clean, { mode: "applied" });
143
+ assert.equal(readFileSync(join(repo, "pulled.txt"), "utf8"), "pulled content\n");
144
+ // Diverge: commit something else, then pull again.
145
+ writeFileSync(join(repo, "diverged.txt"), "local work\n");
146
+ git(repo, ["add", "-A"]);
147
+ git(repo, ["commit", "--quiet", "-m", "local divergence"]);
148
+ const diverged = pullRun(repo, "run_diverged123", baseRef, diff);
149
+ assert.equal(diverged.mode, "branch");
150
+ if (diverged.mode === "branch") {
151
+ const branches = git(repo, ["branch", "--list", diverged.branch]).trim();
152
+ assert.ok(branches.includes(diverged.branch));
153
+ const show = git(repo, ["show", `${diverged.branch}:pulled.txt`]);
154
+ assert.equal(show, "pulled content\n");
155
+ }
156
+ }
157
+ finally {
158
+ rmSync(repo, { recursive: true, force: true });
159
+ }
160
+ });
@@ -0,0 +1,62 @@
1
+ import type { ManifestFile, WorkspaceManifest } from "@fusionkit/protocol";
2
+ /** Default branch prefix and committer for divergence-safe pulls. */
3
+ export declare const PULL_BRANCH_PREFIX = "warrant/";
4
+ export declare const DEFAULT_PULL_COMMITTER: {
5
+ name: string;
6
+ email: string;
7
+ };
8
+ /** Sentinel content hash recorded for files deleted by a run. */
9
+ export declare const DELETED_FILE_HASH: string;
10
+ export declare const DEFAULT_DENY_PATTERNS: string[];
11
+ export declare function matchesPattern(path: string, pattern: string): boolean;
12
+ export type CapturedWorkspace = {
13
+ manifest: WorkspaceManifest;
14
+ bundle: Buffer;
15
+ dirtyDiff?: Buffer;
16
+ untracked: {
17
+ file: ManifestFile;
18
+ content: Buffer;
19
+ }[];
20
+ };
21
+ export type CaptureOptions = {
22
+ allowUntracked?: string[];
23
+ denyPatterns?: string[];
24
+ };
25
+ export declare function captureWorkspace(repoDir: string, options?: CaptureOptions): CapturedWorkspace;
26
+ export type BlobFetcher = (hash: string) => Promise<Buffer>;
27
+ /** Recreate the captured workspace inside a fresh session directory. */
28
+ export declare function materializeWorkspace(sessionDir: string, manifest: WorkspaceManifest, fetchBlob: BlobFetcher): Promise<string>;
29
+ export type WorkspaceOutput = {
30
+ diff: Buffer;
31
+ changedFiles: {
32
+ path: string;
33
+ contentHash: string;
34
+ }[];
35
+ };
36
+ /** Collect the session's output as a binary diff against the base ref. */
37
+ export declare function collectOutput(repoDir: string, baseRef: string): WorkspaceOutput;
38
+ export type PullResult = {
39
+ mode: "applied";
40
+ } | {
41
+ mode: "branch";
42
+ branch: string;
43
+ } | {
44
+ mode: "empty";
45
+ };
46
+ export type PullOptions = {
47
+ /** Always land results on a dedicated branch; never touch the checkout. */
48
+ forceBranch?: boolean;
49
+ /** Branch name prefix for branch-mode pulls. Defaults to "warrant/". */
50
+ branchPrefix?: string;
51
+ /** Committer identity for the branch-mode commit. */
52
+ committer?: {
53
+ name: string;
54
+ email: string;
55
+ };
56
+ };
57
+ /**
58
+ * Divergence-safe pull: apply the run's output diff directly only when the
59
+ * local workspace is clean and still at the contract's base ref; otherwise
60
+ * materialize the result on a dedicated branch and leave the checkout alone.
61
+ */
62
+ export declare function pullRun(repoDir: string, runId: string, baseRef: string, outDiff: Buffer, options?: PullOptions): PullResult;
@@ -0,0 +1,166 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { sha256Hex } from "@fusionkit/protocol";
5
+ import { minimatch } from "minimatch";
6
+ import { gitBinary, gitText } from "./git.js";
7
+ import { parseWorkspaceRelativePath, parseWorkspaceRoot, resolveInsideWorkspace } from "./paths.js";
8
+ /** Default branch prefix and committer for divergence-safe pulls. */
9
+ export const PULL_BRANCH_PREFIX = "warrant/";
10
+ export const DEFAULT_PULL_COMMITTER = {
11
+ name: "warrant",
12
+ email: "warrant@localhost"
13
+ };
14
+ /** Sentinel content hash recorded for files deleted by a run. */
15
+ export const DELETED_FILE_HASH = "0".repeat(64);
16
+ export const DEFAULT_DENY_PATTERNS = [
17
+ ".env",
18
+ ".env.*",
19
+ "*.pem",
20
+ "*.key",
21
+ "id_rsa*",
22
+ "id_ed25519*"
23
+ ];
24
+ function git(cwd, args, allowFail = false) {
25
+ return gitText(cwd, args, { allowFail });
26
+ }
27
+ export function matchesPattern(path, pattern) {
28
+ const target = pattern.includes("/") ? path : path.split("/").pop() ?? path;
29
+ return minimatch(target, pattern, { dot: true });
30
+ }
31
+ export function captureWorkspace(repoDir, options = {}) {
32
+ const root = parseWorkspaceRoot(repoDir);
33
+ const denyPatterns = options.denyPatterns ?? DEFAULT_DENY_PATTERNS;
34
+ const allowUntracked = options.allowUntracked ?? [];
35
+ const baseRef = git(repoDir, ["rev-parse", "HEAD"]).trim();
36
+ const bundlePath = join(mkdtempSync(join(tmpdir(), "warrant-bundle-")), "workspace.bundle");
37
+ git(repoDir, ["bundle", "create", bundlePath, "HEAD"]);
38
+ const bundle = readFileSync(bundlePath);
39
+ rmSync(dirname(bundlePath), { recursive: true, force: true });
40
+ const dirtyDiffBuffer = gitBinary(repoDir, ["diff", "--binary", "HEAD"]);
41
+ const dirtyDiff = dirtyDiffBuffer.length > 0 ? dirtyDiffBuffer : undefined;
42
+ const untrackedPaths = git(repoDir, [
43
+ "ls-files",
44
+ "--others",
45
+ "--exclude-standard"
46
+ ])
47
+ .split("\n")
48
+ .filter((line) => line.length > 0);
49
+ const deniedPaths = [];
50
+ const untracked = [];
51
+ for (const path of untrackedPaths) {
52
+ const rel = parseWorkspaceRelativePath(path);
53
+ if (denyPatterns.some((pattern) => matchesPattern(path, pattern))) {
54
+ deniedPaths.push(rel);
55
+ continue;
56
+ }
57
+ if (!allowUntracked.some((pattern) => matchesPattern(path, pattern))) {
58
+ continue;
59
+ }
60
+ const content = readFileSync(resolveInsideWorkspace(root, rel));
61
+ untracked.push({
62
+ file: { path: rel, hash: sha256Hex(content), bytes: content.length },
63
+ content
64
+ });
65
+ }
66
+ const manifest = {
67
+ version: "warrant.manifest.v1",
68
+ baseRef,
69
+ bundleHash: sha256Hex(bundle),
70
+ ...(dirtyDiff ? { dirtyDiffHash: sha256Hex(dirtyDiff) } : {}),
71
+ untrackedFiles: untracked.map((u) => u.file),
72
+ deniedPatterns: denyPatterns,
73
+ deniedPaths
74
+ };
75
+ return { manifest, bundle, dirtyDiff, untracked };
76
+ }
77
+ /** Recreate the captured workspace inside a fresh session directory. */
78
+ export async function materializeWorkspace(sessionDir, manifest, fetchBlob) {
79
+ mkdirSync(sessionDir, { recursive: true });
80
+ const bundlePath = join(sessionDir, "workspace.bundle");
81
+ writeFileSync(bundlePath, await fetchBlob(manifest.bundleHash));
82
+ const repoDir = join(sessionDir, "repo");
83
+ git(sessionDir, ["clone", "--quiet", bundlePath, repoDir]);
84
+ git(repoDir, ["checkout", "--quiet", manifest.baseRef]);
85
+ if (manifest.dirtyDiffHash) {
86
+ const diffPath = join(sessionDir, "dirty.patch");
87
+ writeFileSync(diffPath, await fetchBlob(manifest.dirtyDiffHash));
88
+ git(repoDir, ["apply", "--binary", "--whitespace=nowarn", diffPath]);
89
+ }
90
+ for (const file of manifest.untrackedFiles) {
91
+ const rel = parseWorkspaceRelativePath(file.path);
92
+ const content = await fetchBlob(file.hash);
93
+ if (sha256Hex(content) !== file.hash) {
94
+ throw new Error(`blob hash mismatch for ${file.path}`);
95
+ }
96
+ const target = resolveInsideWorkspace(repoDir, rel);
97
+ mkdirSync(dirname(target), { recursive: true });
98
+ writeFileSync(target, content);
99
+ }
100
+ return repoDir;
101
+ }
102
+ /** Collect the session's output as a binary diff against the base ref. */
103
+ export function collectOutput(repoDir, baseRef) {
104
+ git(repoDir, ["add", "-A"]);
105
+ const diff = gitBinary(repoDir, ["diff", "--binary", "--cached", baseRef]);
106
+ const changed = git(repoDir, ["diff", "--name-only", "--cached", baseRef])
107
+ .split("\n")
108
+ .filter((line) => line.length > 0);
109
+ const changedFiles = changed.map((path) => {
110
+ const rel = parseWorkspaceRelativePath(path);
111
+ const full = resolveInsideWorkspace(repoDir, rel);
112
+ try {
113
+ statSync(full);
114
+ return { path: rel, contentHash: sha256Hex(readFileSync(full)) };
115
+ }
116
+ catch {
117
+ return { path: rel, contentHash: DELETED_FILE_HASH };
118
+ }
119
+ });
120
+ return { diff, changedFiles };
121
+ }
122
+ /**
123
+ * Divergence-safe pull: apply the run's output diff directly only when the
124
+ * local workspace is clean and still at the contract's base ref; otherwise
125
+ * materialize the result on a dedicated branch and leave the checkout alone.
126
+ */
127
+ export function pullRun(repoDir, runId, baseRef, outDiff, options = {}) {
128
+ if (outDiff.length === 0)
129
+ return { mode: "empty" };
130
+ const head = git(repoDir, ["rev-parse", "HEAD"]).trim();
131
+ const dirty = git(repoDir, ["status", "--porcelain"]).trim().length > 0;
132
+ const diffPath = join(mkdtempSync(join(tmpdir(), "warrant-pull-")), "out.patch");
133
+ writeFileSync(diffPath, outDiff);
134
+ if (!options.forceBranch && head === baseRef && !dirty) {
135
+ git(repoDir, ["apply", "--binary", "--whitespace=nowarn", diffPath]);
136
+ rmSync(dirname(diffPath), { recursive: true, force: true });
137
+ return { mode: "applied" };
138
+ }
139
+ const shortId = runId.replace(/^run_/, "").slice(0, 12);
140
+ const branch = `${options.branchPrefix ?? PULL_BRANCH_PREFIX}${shortId}`;
141
+ const committer = options.committer ?? DEFAULT_PULL_COMMITTER;
142
+ const worktree = mkdtempSync(join(tmpdir(), "warrant-worktree-"));
143
+ try {
144
+ git(repoDir, ["worktree", "add", "--detach", worktree, baseRef]);
145
+ git(worktree, ["apply", "--binary", "--whitespace=nowarn", diffPath]);
146
+ git(worktree, ["add", "-A"]);
147
+ git(worktree, [
148
+ "-c",
149
+ `user.name=${committer.name}`,
150
+ "-c",
151
+ `user.email=${committer.email}`,
152
+ "commit",
153
+ "--quiet",
154
+ "-m",
155
+ `warrant run ${runId}`
156
+ ]);
157
+ const commit = git(worktree, ["rev-parse", "HEAD"]).trim();
158
+ git(repoDir, ["branch", "-f", branch, commit]);
159
+ }
160
+ finally {
161
+ git(repoDir, ["worktree", "remove", "--force", worktree], true);
162
+ rmSync(worktree, { recursive: true, force: true });
163
+ rmSync(dirname(diffPath), { recursive: true, force: true });
164
+ }
165
+ return { mode: "branch", branch };
166
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@fusionkit/workspace",
3
+ "private": false,
4
+ "version": "0.1.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/velum-labs/handoffkit.git",
8
+ "directory": "packages/workspace"
9
+ },
10
+ "description": "Workspace capture, materialization, and divergence-safe pull. Shared by the CLI, runner, and handoff SDK.",
11
+ "license": "UNLICENSED",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "publishConfig": {
23
+ "registry": "https://registry.npmjs.org",
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "dependencies": {
28
+ "minimatch": "10.2.5",
29
+ "@fusionkit/protocol": "0.1.0"
30
+ }
31
+ }