just-git 1.0.2 → 1.1.1

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/README.md CHANGED
@@ -46,7 +46,8 @@ await bash.exec("git log --oneline");
46
46
  | `credentials` | `(url) => HttpAuth \| null` callback for Smart HTTP transport auth. |
47
47
  | `disabled` | `GitCommandName[]` of subcommands to block (e.g. `["push", "rebase"]`). |
48
48
  | `network` | `{ allowed?: string[], fetch?: FetchFunction }` to restrict HTTP access and/or provide a custom `fetch`. `allowed` accepts hostnames (`"github.com"`) or URL prefixes (`"https://github.com/myorg/"`). Set to `false` to block all network access. |
49
- | `resolveRemote` | `(url) => GitContext \| null` callback for cross-VFS remote resolution. See [Multi-agent collaboration](#multi-agent-collaboration). |
49
+ | `hooks` | `GitHooks` config object with named callback properties. See [Hooks](#hooks). |
50
+ | `resolveRemote` | `(url) => GitRepo \| null` callback for cross-VFS remote resolution. See [Multi-agent collaboration](#multi-agent-collaboration). |
50
51
 
51
52
  ```ts
52
53
  const git = createGit({
@@ -57,80 +58,107 @@ const git = createGit({
57
58
  });
58
59
  ```
59
60
 
60
- ## Middleware
61
+ ## Hooks
62
+
63
+ Hooks fire at specific points inside command execution. Specified as a `GitHooks` config object at construction time. All hook event payloads include `repo: GitRepo`, providing access to the [repo module helpers](src/repo/) inside hooks.
61
64
 
62
- Middleware wraps every `git <subcommand>` invocation. Each middleware receives a `CommandEvent` and a `next()` function. Call `next()` to proceed, or return an `ExecResult` to short-circuit. Middlewares compose in registration order (first registered = outermost). `git.use()` returns an unsubscribe function.
65
+ Pre-hooks can reject the operation by returning `{ reject: true, message? }`. Post-hooks are observational return value is ignored.
63
66
 
64
67
  ```ts
65
- // Audit log record every command the agent runs
66
- git.use(async (event, next) => {
67
- const result = await next();
68
- auditLog.push({ command: `git ${event.command}`, exitCode: result.exitCode });
69
- return result;
70
- });
68
+ import { createGit, type GitHooks } from "just-git";
69
+ import { getChangedFiles } from "just-git/repo";
71
70
 
72
- // Gate pushes on human approval
73
- git.use(async (event, next) => {
74
- if (event.command === "push" && !(await getHumanApproval(event.rawArgs))) {
75
- return { stdout: "", stderr: "Push blocked — awaiting approval.\n", exitCode: 1 };
76
- }
77
- return next();
78
- });
71
+ const git = createGit({
72
+ hooks: {
73
+ // Block secrets from being committed
74
+ preCommit: ({ index }) => {
75
+ const forbidden = index.entries.filter((e) => /\.(env|pem|key)$/.test(e.path));
76
+ if (forbidden.length) {
77
+ return { reject: true, message: `Blocked: ${forbidden.map((e) => e.path).join(", ")}` };
78
+ }
79
+ },
79
80
 
80
- // Block commits that add large files (uses event.fs to read the worktree)
81
- git.use(async (event, next) => {
82
- if (event.command === "add") {
83
- for (const path of event.rawArgs.filter((a) => !a.startsWith("-"))) {
84
- const resolved = path.startsWith("/") ? path : `${event.cwd}/${path}`;
85
- const stat = await event.fs.stat(resolved).catch(() => null);
86
- if (stat && stat.size > 5_000_000) {
87
- return { stdout: "", stderr: `Blocked: ${path} exceeds 5 MB\n`, exitCode: 1 };
81
+ // Enforce conventional commit messages
82
+ commitMsg: (event) => {
83
+ if (!/^(feat|fix|docs|refactor|test|chore)(\(.+\))?:/.test(event.message)) {
84
+ return { reject: true, message: "Commit message must follow conventional commits format" };
85
+ }
86
+ },
87
+
88
+ // Feed agent activity to your UI with changed file list
89
+ postCommit: async ({ repo, hash, branch, parents }) => {
90
+ const files = await getChangedFiles(repo, parents[0] ?? null, hash);
91
+ onAgentCommit({ hash, branch, changedFiles: files });
92
+ },
93
+
94
+ // Audit log — record every command
95
+ afterCommand: ({ command, args, result }) => {
96
+ auditLog.push({ command: `git ${command}`, exitCode: result.exitCode });
97
+ },
98
+
99
+ // Gate pushes on human approval
100
+ beforeCommand: async ({ command }) => {
101
+ if (command === "push" && !(await getHumanApproval())) {
102
+ return { reject: true, message: "Push blocked — awaiting approval." };
88
103
  }
89
- }
90
- }
91
- return next();
104
+ },
105
+ },
92
106
  });
93
107
  ```
94
108
 
95
- ## Hooks
96
-
97
- Hooks fire at specific points inside command execution (after middleware, inside operation logic). Register with `git.on(event, handler)`, which returns an unsubscribe function.
98
-
99
- Pre-hooks can abort the operation by returning `{ abort: true, message? }`. Post-hooks are observational — return value is ignored.
109
+ Use `composeGitHooks()` to combine multiple hook sets:
100
110
 
101
111
  ```ts
102
- // Block secrets from being committed
103
- git.on("pre-commit", (event) => {
104
- const forbidden = event.index.entries.filter((e) => /\.(env|pem|key)$/.test(e.path));
105
- if (forbidden.length) {
106
- return { abort: true, message: `Blocked: ${forbidden.map((e) => e.path).join(", ")}` };
107
- }
108
- });
112
+ import { createGit, composeGitHooks } from "just-git";
109
113
 
110
- // Enforce conventional commit messages
111
- git.on("commit-msg", (event) => {
112
- if (!/^(feat|fix|docs|refactor|test|chore)(\(.+\))?:/.test(event.message)) {
113
- return { abort: true, message: "Commit message must follow conventional commits format" };
114
- }
115
- });
116
-
117
- // Feed agent activity to your UI or orchestration layer
118
- git.on("post-commit", (event) => {
119
- onAgentCommit({ hash: event.hash, branch: event.branch, message: event.message });
114
+ const git = createGit({
115
+ hooks: composeGitHooks(auditHooks, policyHooks, loggingHooks),
120
116
  });
121
117
  ```
122
118
 
123
- Available pre-hooks: `pre-commit`, `commit-msg`, `merge-msg`, `pre-merge-commit`, `pre-checkout`, `pre-push`, `pre-fetch`, `pre-clone`, `pre-pull`, `pre-rebase`, `pre-reset`, `pre-clean`, `pre-rm`, `pre-cherry-pick`, `pre-revert`, `pre-stash`. Available post-hooks: `post-commit`, `post-merge`, `post-checkout`, `post-push`, `post-fetch`, `post-clone`, `post-pull`, `post-reset`, `post-clean`, `post-rm`, `post-cherry-pick`, `post-revert`, `post-stash`. Low-level events: `ref:update`, `ref:delete`, `object:write`.
119
+ Available pre-hooks: `preCommit`, `commitMsg`, `mergeMsg`, `preMergeCommit`, `preCheckout`, `prePush`, `preFetch`, `preClone`, `prePull`, `preRebase`, `preReset`, `preClean`, `preRm`, `preCherryPick`, `preRevert`, `preStash`. Available post-hooks: `postCommit`, `postMerge`, `postCheckout`, `postPush`, `postFetch`, `postClone`, `postPull`, `postReset`, `postClean`, `postRm`, `postCherryPick`, `postRevert`, `postStash`. Low-level events: `onRefUpdate`, `onRefDelete`, `onObjectWrite`. Command-level: `beforeCommand`, `afterCommand`.
124
120
 
125
- See [HOOKS.md](HOOKS.md) for full payload types and the `CommandEvent` shape.
121
+ ## Repo module
122
+
123
+ `just-git/repo` provides a high-level API for working with repositories programmatically — reading commits, diffing trees, creating objects, and merging — without going through command execution. This is what you use inside hooks (all hook payloads include `repo: GitRepo`) and anywhere else you need direct repo access.
124
+
125
+ ```ts
126
+ import {
127
+ readCommit,
128
+ readFileAtCommit,
129
+ getChangedFiles,
130
+ resolveRef,
131
+ listBranches,
132
+ diffTrees,
133
+ flattenTree,
134
+ createCommit,
135
+ writeBlob,
136
+ writeTree,
137
+ mergeTrees,
138
+ checkoutTo,
139
+ readonlyRepo,
140
+ } from "just-git/repo";
141
+
142
+ // Read a file at a specific commit
143
+ const content = await readFileAtCommit(repo, commitHash, "src/index.ts");
144
+
145
+ // Diff two commits
146
+ const changes = await getChangedFiles(repo, parentHash, commitHash);
147
+
148
+ // Merge two branches at the tree level (no worktree needed)
149
+ const result = await mergeTrees(repo, oursCommit, theirsCommit);
150
+ if (!result.clean) console.log("conflicts:", result.conflicts);
151
+ ```
152
+
153
+ Also re-exports `PackedObjectStore` and `FileSystemRefStore` for custom storage backends, and `readonlyRepo()` to wrap a repo so all write operations throw.
126
154
 
127
155
  ## Multi-agent collaboration
128
156
 
129
- Multiple agents can work on clones of the same repository in the same process, each with full VFS isolation. The `resolveRemote` option maps remote URLs to `GitContext` instances on other virtual filesystems, so clone/fetch/push/pull cross VFS boundaries without any network or shared filesystem.
157
+ Multiple agents can work on clones of the same repository in the same process, each with full VFS isolation. The `resolveRemote` option maps remote URLs to `GitRepo` instances (any object/ref store — VFS-backed, SQLite, etc.), so clone/fetch/push/pull cross VFS boundaries without any network or shared filesystem.
130
158
 
131
159
  ```ts
132
160
  import { Bash, InMemoryFs } from "just-bash";
133
- import { createGit, findGitDir } from "just-git";
161
+ import { createGit, findRepo } from "just-git";
134
162
 
135
163
  // Origin repo on its own filesystem
136
164
  const originFs = new InMemoryFs();
@@ -145,17 +173,13 @@ await setupBash.exec("git init");
145
173
  await setupBash.exec("echo 'hello' > README.md");
146
174
  await setupBash.exec("git add . && git commit -m 'initial'");
147
175
 
148
- const originCtx = await findGitDir(originFs, "/repo");
149
- const resolve = (url: string) => (url === "/origin" ? originCtx : null);
150
-
151
- // Each agent gets its own filesystem + resolveRemote pointing to origin
152
176
  const alice = new Bash({
153
177
  fs: new InMemoryFs(),
154
178
  cwd: "/repo",
155
179
  customCommands: [
156
180
  createGit({
157
181
  identity: { name: "Alice", email: "alice@example.com", locked: true },
158
- resolveRemote: resolve,
182
+ resolveRemote: () => findRepo(originFs, "/repo"),
159
183
  }),
160
184
  ],
161
185
  });
@@ -166,7 +190,7 @@ const bob = new Bash({
166
190
  customCommands: [
167
191
  createGit({
168
192
  identity: { name: "Bob", email: "bob@example.com", locked: true },
169
- resolveRemote: resolve,
193
+ resolveRemote: () => findRepo(originFs, "/repo"),
170
194
  }),
171
195
  ],
172
196
  });
@@ -181,6 +205,43 @@ Concurrent pushes to the same remote are automatically serialized — if two age
181
205
 
182
206
  See [`examples/multi-agent.ts`](examples/multi-agent.ts) for a full working example with a coordinator agent that merges feature branches.
183
207
 
208
+ ## Server
209
+
210
+ `just-git/server` is an embeddable Git Smart HTTP server. Any standard git client can clone, fetch, and push. Uses web-standard `Request`/`Response` — works with Bun, Hono, Cloudflare Workers, or any fetch-compatible runtime.
211
+
212
+ ```ts
213
+ import { createGitServer, SqliteStorage } from "just-git/server";
214
+ import { getChangedFiles } from "just-git/repo";
215
+ import { Database } from "bun:sqlite";
216
+
217
+ const storage = new SqliteStorage(new Database("repos.sqlite"));
218
+
219
+ const server = createGitServer({
220
+ resolveRepo: async (repoPath) => storage.repo(repoPath),
221
+ hooks: {
222
+ preReceive: async ({ updates }) => {
223
+ for (const u of updates) {
224
+ if (u.ref === "refs/heads/main" && !u.isFF && !u.isCreate) {
225
+ return { reject: true, message: "no force-push to main" };
226
+ }
227
+ }
228
+ },
229
+ postReceive: async ({ repo, updates }) => {
230
+ for (const u of updates) {
231
+ const files = await getChangedFiles(repo, u.oldHash, u.newHash);
232
+ console.log(`${u.ref}: ${files.length} files changed`);
233
+ }
234
+ },
235
+ },
236
+ });
237
+
238
+ Bun.serve({ fetch: server.fetch });
239
+ ```
240
+
241
+ `SqliteStorage` persists repos in SQLite without a filesystem — works with `bun:sqlite`, `better-sqlite3`, or any compatible driver. Repos backed by `SqliteStorage` work with both the server (external HTTP) and `resolveRemote` (in-process), with CAS-protected ref updates ensuring correctness regardless of write path.
242
+
243
+ See [SERVER.md](SERVER.md) for the full API: hooks, `createStandardHooks`, `SqliteStorage`, configuration, and deployment patterns. See [`examples/sqlite-server.ts`](examples/sqlite-server.ts) for a runnable SQLite server and [`examples/server.ts`](examples/server.ts) for a VFS-backed server with virtual client. See [`src/platform/`](src/platform/) for a reference implementation that builds GitHub-like functionality (repos, pull requests, merge strategies) on top of these primitives.
244
+
184
245
  ## Command coverage
185
246
 
186
247
  See [CLI.md](CLI.md) for full usage details.
@@ -264,3 +325,18 @@ console.log(result.exitCode); // 0
264
325
  ```
265
326
 
266
327
  The `FileSystem` interface requires: `readFile`, `readFileBuffer`, `writeFile`, `exists`, `stat`, `mkdir`, `readdir`, `rm`. Optional: `lstat`, `readlink`, `symlink`.
328
+
329
+ ## Examples
330
+
331
+ Runnable examples in [`examples/`](examples/):
332
+
333
+ | File | What it demonstrates |
334
+ | --------------------------------------------------------------- | -------------------------------------------------------------------- |
335
+ | [`usage.ts`](examples/usage.ts) | Identity, disabled commands, hooks, compose, full sandbox setup |
336
+ | [`multi-agent.ts`](examples/multi-agent.ts) | Cross-VFS collaboration with clone/push/pull between isolated agents |
337
+ | [`server.ts`](examples/server.ts) | VFS-backed Smart HTTP server with virtual client clone and push |
338
+ | [`sqlite-server.ts`](examples/sqlite-server.ts) | SQLite-backed server with auto-creating repos, works with real `git` |
339
+ | [`platform-server.ts`](examples/platform-server.ts) | GitHub-like PR workflows: create, merge, close via REST API |
340
+ | [`agent-remote-workflow.ts`](examples/agent-remote-workflow.ts) | Clone from GitHub, work in sandbox, push back (requires token) |
341
+
342
+ Run any example with `bun examples/<file>`.