@ferueda/grove 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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/config.d.ts +2 -0
  4. package/dist/config.d.ts.map +1 -0
  5. package/dist/config.js +27 -0
  6. package/dist/errors.d.ts +31 -0
  7. package/dist/errors.d.ts.map +1 -0
  8. package/dist/errors.js +50 -0
  9. package/dist/git/branch.d.ts +9 -0
  10. package/dist/git/branch.d.ts.map +1 -0
  11. package/dist/git/branch.js +106 -0
  12. package/dist/git/index.d.ts +4 -0
  13. package/dist/git/index.d.ts.map +1 -0
  14. package/dist/git/index.js +3 -0
  15. package/dist/git/run.d.ts +2 -0
  16. package/dist/git/run.d.ts.map +1 -0
  17. package/dist/git/run.js +15 -0
  18. package/dist/git/worktree.d.ts +7 -0
  19. package/dist/git/worktree.d.ts.map +1 -0
  20. package/dist/git/worktree.js +32 -0
  21. package/dist/hooks.d.ts +7 -0
  22. package/dist/hooks.d.ts.map +1 -0
  23. package/dist/hooks.js +33 -0
  24. package/dist/index.d.ts +10 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +11 -0
  27. package/dist/lock.d.ts +3 -0
  28. package/dist/lock.d.ts.map +1 -0
  29. package/dist/lock.js +26 -0
  30. package/dist/pool.d.ts +33 -0
  31. package/dist/pool.d.ts.map +1 -0
  32. package/dist/pool.js +284 -0
  33. package/dist/process/detect.d.ts +11 -0
  34. package/dist/process/detect.d.ts.map +1 -0
  35. package/dist/process/detect.js +147 -0
  36. package/dist/process/terminate.d.ts +6 -0
  37. package/dist/process/terminate.d.ts.map +1 -0
  38. package/dist/process/terminate.js +96 -0
  39. package/dist/schemas.d.ts +34 -0
  40. package/dist/schemas.d.ts.map +1 -0
  41. package/dist/schemas.js +25 -0
  42. package/dist/state.d.ts +6 -0
  43. package/dist/state.d.ts.map +1 -0
  44. package/dist/state.js +58 -0
  45. package/package.json +23 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 grove contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # Grove
2
+
3
+ > A fast, secure pool of reusable git worktrees
4
+
5
+ This is the programmatic SDK for Grove.
6
+
7
+ Please see the [main documentation](https://github.com/ferueda/grove#readme) for full usage instructions and API references!
@@ -0,0 +1,2 @@
1
+ export declare function resolveGroveDir(repoRoot: string, root?: string): Promise<string>;
2
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAWA,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAoBtF"}
package/dist/config.js ADDED
@@ -0,0 +1,27 @@
1
+ import { join, basename, isAbsolute } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { getRemoteUrl, shortHash } from "./git/index.js";
4
+ function expandEnv(str) {
5
+ return str.replace(/\$(?:{([A-Za-z_][A-Za-z0-9_]*)}|([A-Za-z_][A-Za-z0-9_]*))/g, (_, n1, n2) => {
6
+ const name = n1 || n2 || "";
7
+ return process.env[name] || "";
8
+ });
9
+ }
10
+ export async function resolveGroveDir(repoRoot, root) {
11
+ let hashInput = repoRoot;
12
+ try {
13
+ hashInput = await getRemoteUrl(repoRoot);
14
+ }
15
+ catch { }
16
+ const repoName = basename(repoRoot);
17
+ const hash = shortHash(hashInput);
18
+ const poolName = `${repoName}-${hash}`;
19
+ if (!root) {
20
+ return join(homedir(), ".grove", poolName);
21
+ }
22
+ let expanded = expandEnv(root);
23
+ if (!isAbsolute(expanded)) {
24
+ expanded = join(repoRoot, expanded);
25
+ }
26
+ return join(expanded, ".grove", poolName);
27
+ }
@@ -0,0 +1,31 @@
1
+ export type GroveErrorCode = "GROVE_EXHAUSTED" | "WORKTREE_DESTROYING" | "WORKTREE_NOT_MANAGED" | "WORKTREE_IN_USE" | "GIT_NOT_FOUND" | "GIT_COMMAND_FAILED" | "INVALID_GROVE_STATE" | "LOCK_FAILED";
2
+ export declare class GroveError extends Error {
3
+ readonly code: GroveErrorCode;
4
+ constructor(message: string, code: GroveErrorCode);
5
+ }
6
+ export declare class GroveExhaustedError extends GroveError {
7
+ constructor(message?: string);
8
+ }
9
+ export declare class WorktreeDestroyingError extends GroveError {
10
+ constructor(message?: string);
11
+ }
12
+ export declare class WorktreeNotManagedError extends GroveError {
13
+ constructor(message?: string);
14
+ }
15
+ export declare class WorktreeInUseError extends GroveError {
16
+ constructor(message?: string);
17
+ }
18
+ export declare class GitNotFoundError extends GroveError {
19
+ constructor(message?: string);
20
+ }
21
+ export declare class GitCommandError extends GroveError {
22
+ stderr: string;
23
+ constructor(message: string, stderr?: string);
24
+ }
25
+ export declare class InvalidGroveStateError extends GroveError {
26
+ constructor(message?: string);
27
+ }
28
+ export declare class LockFailedError extends GroveError {
29
+ constructor(message?: string);
30
+ }
31
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,cAAc,GACtB,iBAAiB,GACjB,qBAAqB,GACrB,sBAAsB,GACtB,iBAAiB,GACjB,eAAe,GACf,oBAAoB,GACpB,qBAAqB,GACrB,aAAa,CAAC;AAElB,qBAAa,UAAW,SAAQ,KAAK;IACnC,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAC9B,YAAY,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,EAIhD;CACF;AAED,qBAAa,mBAAoB,SAAQ,UAAU;IACjD,YAAY,OAAO,GAAE,MAA0B,EAE9C;CACF;AAED,qBAAa,uBAAwB,SAAQ,UAAU;IACrD,YAAY,OAAO,GAAE,MAAiC,EAErD;CACF;AAED,qBAAa,uBAAwB,SAAQ,UAAU;IACrD,YAAY,OAAO,GAAE,MAA+B,EAEnD;CACF;AAED,qBAAa,kBAAmB,SAAQ,UAAU;IAChD,YAAY,OAAO,GAAE,MAA6B,EAEjD;CACF;AAED,qBAAa,gBAAiB,SAAQ,UAAU;IAC9C,YAAY,OAAO,GAAE,MAAwB,EAE5C;CACF;AAED,qBAAa,eAAgB,SAAQ,UAAU;IAC7C,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,OAAO,EAAE,MAAM,EAAE,MAAM,GAAE,MAAW,EAG/C;CACF;AAED,qBAAa,sBAAuB,SAAQ,UAAU;IACpD,YAAY,OAAO,GAAE,MAA8B,EAElD;CACF;AAED,qBAAa,eAAgB,SAAQ,UAAU;IAC7C,YAAY,OAAO,GAAE,MAAiC,EAErD;CACF"}
package/dist/errors.js ADDED
@@ -0,0 +1,50 @@
1
+ export class GroveError extends Error {
2
+ code;
3
+ constructor(message, code) {
4
+ super(message);
5
+ this.name = this.constructor.name;
6
+ this.code = code;
7
+ }
8
+ }
9
+ export class GroveExhaustedError extends GroveError {
10
+ constructor(message = "Grove exhausted") {
11
+ super(message, "GROVE_EXHAUSTED");
12
+ }
13
+ }
14
+ export class WorktreeDestroyingError extends GroveError {
15
+ constructor(message = "Worktree is destroying") {
16
+ super(message, "WORKTREE_DESTROYING");
17
+ }
18
+ }
19
+ export class WorktreeNotManagedError extends GroveError {
20
+ constructor(message = "Worktree not managed") {
21
+ super(message, "WORKTREE_NOT_MANAGED");
22
+ }
23
+ }
24
+ export class WorktreeInUseError extends GroveError {
25
+ constructor(message = "Worktree is in use") {
26
+ super(message, "WORKTREE_IN_USE");
27
+ }
28
+ }
29
+ export class GitNotFoundError extends GroveError {
30
+ constructor(message = "Git not found") {
31
+ super(message, "GIT_NOT_FOUND");
32
+ }
33
+ }
34
+ export class GitCommandError extends GroveError {
35
+ stderr;
36
+ constructor(message, stderr = "") {
37
+ super(message, "GIT_COMMAND_FAILED");
38
+ this.stderr = stderr;
39
+ }
40
+ }
41
+ export class InvalidGroveStateError extends GroveError {
42
+ constructor(message = "Invalid grove state") {
43
+ super(message, "INVALID_GROVE_STATE");
44
+ }
45
+ }
46
+ export class LockFailedError extends GroveError {
47
+ constructor(message = "Failed to acquire lock") {
48
+ super(message, "LOCK_FAILED");
49
+ }
50
+ }
@@ -0,0 +1,9 @@
1
+ export declare function findRepoRoot(cwd?: string): Promise<string>;
2
+ export declare function findRepoRootFrom(cwd: string): Promise<string>;
3
+ export declare function getDefaultBranch(repoRoot: string): Promise<string>;
4
+ export declare function hasRemote(repoRoot: string, name: string): Promise<boolean>;
5
+ export declare function getRemoteUrl(repoRoot: string): Promise<string>;
6
+ export declare function shortHash(input: string): string;
7
+ export declare function isAncestor(repoRoot: string, a: string, b: string): Promise<boolean>;
8
+ export declare function branchRef(repoRoot: string, branch: string): Promise<string>;
9
+ //# sourceMappingURL=branch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"branch.d.ts","sourceRoot":"","sources":["../../src/git/branch.ts"],"names":[],"mappings":"AAGA,wBAAsB,YAAY,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAEhE;AAED,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAEnE;AAED,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAwCxE;AAED,wBAAsB,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUhF;AAED,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAEpE;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED,wBAAsB,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAOzF;AAED,wBAAsB,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA8BjF"}
@@ -0,0 +1,106 @@
1
+ import { createHash } from "node:crypto";
2
+ import { runGit } from "./run.js";
3
+ export async function findRepoRoot(cwd) {
4
+ return runGit(cwd, ["rev-parse", "--show-toplevel"]);
5
+ }
6
+ export async function findRepoRootFrom(cwd) {
7
+ return findRepoRoot(cwd);
8
+ }
9
+ export async function getDefaultBranch(repoRoot) {
10
+ let mainRoot = repoRoot;
11
+ try {
12
+ let commonDir = await runGit(repoRoot, ["rev-parse", "--git-common-dir"]);
13
+ try {
14
+ commonDir = await runGit(repoRoot, [
15
+ "rev-parse",
16
+ "--path-format=absolute",
17
+ "--git-common-dir",
18
+ ]);
19
+ }
20
+ catch { }
21
+ if (commonDir.endsWith(".git")) {
22
+ mainRoot = commonDir.slice(0, -4);
23
+ }
24
+ }
25
+ catch { }
26
+ try {
27
+ const out = await runGit(mainRoot, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
28
+ return out.replace("refs/remotes/origin/", "");
29
+ }
30
+ catch {
31
+ // ignore
32
+ }
33
+ try {
34
+ const out = await runGit(mainRoot, ["symbolic-ref", "HEAD"]);
35
+ return out.replace("refs/heads/", "");
36
+ }
37
+ catch {
38
+ // ignore
39
+ }
40
+ try {
41
+ const out = await runGit(mainRoot, ["config", "init.defaultBranch"]);
42
+ if (out)
43
+ return out;
44
+ }
45
+ catch {
46
+ // ignore
47
+ }
48
+ throw new Error("cannot determine default branch: try running 'git fetch' or ensure you are on a branch");
49
+ }
50
+ export async function hasRemote(repoRoot, name) {
51
+ try {
52
+ const out = await runGit(repoRoot, ["remote"]);
53
+ return out
54
+ .split("\n")
55
+ .map((r) => r.trim())
56
+ .includes(name);
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ export async function getRemoteUrl(repoRoot) {
63
+ return runGit(repoRoot, ["remote", "get-url", "origin"]);
64
+ }
65
+ export function shortHash(input) {
66
+ return createHash("sha256").update(input).digest("hex").substring(0, 6);
67
+ }
68
+ export async function isAncestor(repoRoot, a, b) {
69
+ try {
70
+ await runGit(repoRoot, ["merge-base", "--is-ancestor", a, b]);
71
+ return true; // exit code 0 means true
72
+ }
73
+ catch {
74
+ return false; // exit code 1 means false
75
+ }
76
+ }
77
+ export async function branchRef(repoRoot, branch) {
78
+ const local = `refs/heads/${branch}`;
79
+ const remote = `origin/${branch}`;
80
+ let localExists = false;
81
+ try {
82
+ await runGit(repoRoot, ["rev-parse", "--verify", local]);
83
+ localExists = true;
84
+ }
85
+ catch { }
86
+ let remoteExists = false;
87
+ try {
88
+ await runGit(repoRoot, ["rev-parse", "--verify", remote]);
89
+ remoteExists = true;
90
+ }
91
+ catch { }
92
+ if (localExists && remoteExists) {
93
+ if (await isAncestor(repoRoot, local, remote)) {
94
+ return remote; // remote is ahead or equal
95
+ }
96
+ if (await isAncestor(repoRoot, remote, local)) {
97
+ return branch; // local is ahead
98
+ }
99
+ return remote; // diverged, prefer remote
100
+ }
101
+ if (localExists)
102
+ return branch;
103
+ if (remoteExists)
104
+ return remote;
105
+ return branch; // fallback
106
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./run.js";
2
+ export * from "./branch.js";
3
+ export * from "./worktree.js";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/git/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,eAAe,CAAC"}
@@ -0,0 +1,3 @@
1
+ export * from "./run.js";
2
+ export * from "./branch.js";
3
+ export * from "./worktree.js";
@@ -0,0 +1,2 @@
1
+ export declare function runGit(cwd: string | undefined, args: string[]): Promise<string>;
2
+ //# sourceMappingURL=run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/git/run.ts"],"names":[],"mappings":"AAGA,wBAAsB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAWrF"}
@@ -0,0 +1,15 @@
1
+ import { execa } from "execa";
2
+ import { GitCommandError, GitNotFoundError } from "../errors.js";
3
+ export async function runGit(cwd, args) {
4
+ try {
5
+ const { stdout } = await execa("git", args, cwd ? { cwd } : undefined);
6
+ return stdout.trim();
7
+ }
8
+ catch (error) {
9
+ if (error.code === "ENOENT") {
10
+ throw new GitNotFoundError();
11
+ }
12
+ const stderr = error.stderr || error.message || "Unknown git error";
13
+ throw new GitCommandError(`git ${args.join(" ")} failed`, stderr);
14
+ }
15
+ }
@@ -0,0 +1,7 @@
1
+ export declare function addWorktree(repoRoot: string, path: string, branch: string): Promise<void>;
2
+ export declare function removeWorktree(repoRoot: string, path: string): Promise<void>;
3
+ export declare function resetWorktree(path: string, branch: string): Promise<void>;
4
+ export declare function detachWorktree(path: string): Promise<void>;
5
+ export declare function isDirty(path: string): Promise<boolean>;
6
+ export declare function fetchOrigin(repoRoot: string): Promise<void>;
7
+ //# sourceMappingURL=worktree.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worktree.d.ts","sourceRoot":"","sources":["../../src/git/worktree.ts"],"names":[],"mappings":"AAGA,wBAAsB,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG/F;AAED,wBAAsB,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAElF;AAED,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAS/E;AAED,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEhE;AAED,wBAAsB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAG5D;AAED,wBAAsB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAIjE"}
@@ -0,0 +1,32 @@
1
+ import { runGit } from "./run.js";
2
+ import { branchRef, hasRemote } from "./branch.js";
3
+ export async function addWorktree(repoRoot, path, branch) {
4
+ const ref = await branchRef(repoRoot, branch);
5
+ await runGit(repoRoot, ["worktree", "add", "--detach", "--", path, ref]);
6
+ }
7
+ export async function removeWorktree(repoRoot, path) {
8
+ await runGit(repoRoot, ["worktree", "remove", "--force", "--", path]);
9
+ }
10
+ export async function resetWorktree(path, branch) {
11
+ let repoRoot = path;
12
+ try {
13
+ repoRoot = await runGit(path, ["rev-parse", "--show-toplevel"]);
14
+ }
15
+ catch { }
16
+ const ref = await branchRef(repoRoot, branch);
17
+ await runGit(path, ["checkout", "--detach", "--force", ref]);
18
+ await runGit(path, ["reset", "--hard", ref]);
19
+ await runGit(path, ["clean", "-fd"]);
20
+ }
21
+ export async function detachWorktree(path) {
22
+ await runGit(path, ["checkout", "--detach"]);
23
+ }
24
+ export async function isDirty(path) {
25
+ const status = await runGit(path, ["status", "--porcelain"]);
26
+ return status.trim().length > 0;
27
+ }
28
+ export async function fetchOrigin(repoRoot) {
29
+ if (await hasRemote(repoRoot, "origin")) {
30
+ await runGit(repoRoot, ["fetch", "origin"]);
31
+ }
32
+ }
@@ -0,0 +1,7 @@
1
+ import type { Writable } from "node:stream";
2
+ export interface RunHooksOptions {
3
+ stdout?: Writable;
4
+ stderr?: Writable;
5
+ }
6
+ export declare function runHooks(commands: string[], workDir: string, opts?: RunHooksOptions): Promise<void>;
7
+ //# sourceMappingURL=hooks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../src/hooks.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C,MAAM,WAAW,eAAe;IAC9B,MAAM,CAAC,EAAE,QAAQ,CAAC;IAClB,MAAM,CAAC,EAAE,QAAQ,CAAC;CACnB;AAED,wBAAsB,QAAQ,CAC5B,QAAQ,EAAE,MAAM,EAAE,EAClB,OAAO,EAAE,MAAM,EACf,IAAI,GAAE,eAAoB,GACzB,OAAO,CAAC,IAAI,CAAC,CAgCf"}
package/dist/hooks.js ADDED
@@ -0,0 +1,33 @@
1
+ import { execa } from "execa";
2
+ export async function runHooks(commands, workDir, opts = {}) {
3
+ for (const command of commands) {
4
+ try {
5
+ const isWin = process.platform === "win32";
6
+ const shell = isWin ? process.env.COMSPEC || "cmd.exe" : "/bin/sh";
7
+ const args = isWin ? ["/d", "/s", "/c", command] : ["-c", command];
8
+ const child = execa(shell, args, {
9
+ cwd: workDir,
10
+ stdout: opts.stdout ? "pipe" : "ignore",
11
+ stderr: opts.stderr ? "pipe" : "ignore",
12
+ windowsVerbatimArguments: isWin,
13
+ });
14
+ if (opts.stdout) {
15
+ child.stdout?.pipe(opts.stdout, { end: false });
16
+ }
17
+ if (opts.stderr) {
18
+ child.stderr?.pipe(opts.stderr, { end: false });
19
+ }
20
+ await child;
21
+ }
22
+ catch (err) {
23
+ const exitCode = err.exitCode ?? -1;
24
+ const msg = `🌳 hook command failed: "${command}" (exit ${exitCode}): ${err.message}\n`;
25
+ if (opts.stderr) {
26
+ opts.stderr.write(msg);
27
+ }
28
+ else {
29
+ process.stderr.write(msg);
30
+ }
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,10 @@
1
+ import { Grove } from "./pool.js";
2
+ import type { GroveConfig } from "./schemas.js";
3
+ export { Grove } from "./pool.js";
4
+ export type { AcquiredSlot, WorktreeStatus, WorktreeStatusInfo } from "./pool.js";
5
+ export { GroveConfigSchema } from "./schemas.js";
6
+ export type { GroveConfig, WorktreeEntry, GroveState } from "./schemas.js";
7
+ export { GroveError, GroveExhaustedError, WorktreeDestroyingError, WorktreeNotManagedError, WorktreeInUseError, GitNotFoundError, GitCommandError, InvalidGroveStateError, LockFailedError, } from "./errors.js";
8
+ export type { GroveErrorCode } from "./errors.js";
9
+ export declare function createGrove(configInput: GroveConfig): Promise<Grove>;
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,EAAE,MAAM,WAAW,CAAC;AAElC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD,OAAO,EAAE,KAAK,EAAE,MAAM,WAAW,CAAC;AAClC,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAClF,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACjD,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE3E,OAAO,EACL,UAAU,EACV,mBAAmB,EACnB,uBAAuB,EACvB,uBAAuB,EACvB,kBAAkB,EAClB,gBAAgB,EAChB,eAAe,EACf,sBAAsB,EACtB,eAAe,GAChB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD,wBAAsB,WAAW,CAAC,WAAW,EAAE,WAAW,GAAG,OAAO,CAAC,KAAK,CAAC,CAK1E"}
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ import { resolveGroveDir } from "./config.js";
2
+ import { Grove } from "./pool.js";
3
+ import { GroveConfigSchema } from "./schemas.js";
4
+ export { Grove } from "./pool.js";
5
+ export { GroveConfigSchema } from "./schemas.js";
6
+ export { GroveError, GroveExhaustedError, WorktreeDestroyingError, WorktreeNotManagedError, WorktreeInUseError, GitNotFoundError, GitCommandError, InvalidGroveStateError, LockFailedError, } from "./errors.js";
7
+ export async function createGrove(configInput) {
8
+ const config = GroveConfigSchema.parse(configInput);
9
+ const groveDir = config.groveDir || (await resolveGroveDir(config.repoRoot, config.groveRoot || ""));
10
+ return new Grove(groveDir, config);
11
+ }
package/dist/lock.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { type LockOptions } from "proper-lockfile";
2
+ export declare function withStateLock<T>(groveDir: string, fn: () => Promise<T>, opts?: LockOptions): Promise<T>;
3
+ //# sourceMappingURL=lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../src/lock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAKzD,wBAAsB,aAAa,CAAC,CAAC,EACnC,QAAQ,EAAE,MAAM,EAChB,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,IAAI,CAAC,EAAE,WAAW,GACjB,OAAO,CAAC,CAAC,CAAC,CAsBZ"}
package/dist/lock.js ADDED
@@ -0,0 +1,26 @@
1
+ import { lock } from "proper-lockfile";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { LockFailedError } from "./errors.js";
5
+ export async function withStateLock(groveDir, fn, opts) {
6
+ await mkdir(groveDir, { recursive: true });
7
+ const lockTarget = join(groveDir, "grove-state.lock");
8
+ await writeFile(lockTarget, "", { flag: "a" });
9
+ const lockOpts = {
10
+ retries: opts?.retries ?? { retries: 300, minTimeout: 500, maxTimeout: 2000 },
11
+ ...opts,
12
+ };
13
+ let release;
14
+ try {
15
+ release = await lock(lockTarget, lockOpts);
16
+ }
17
+ catch (err) {
18
+ throw new LockFailedError(err.message);
19
+ }
20
+ try {
21
+ return await fn();
22
+ }
23
+ finally {
24
+ await release();
25
+ }
26
+ }
package/dist/pool.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { GroveConfig } from "./index.js";
2
+ import type { WorktreeEntry } from "./schemas.js";
3
+ export type WorktreeStatusInfo = "available" | "dirty" | "in-use" | "you're here";
4
+ export interface AcquiredSlot {
5
+ readonly path: string;
6
+ readonly name: string;
7
+ }
8
+ export interface WorktreeStatus {
9
+ name: string;
10
+ path: string;
11
+ status: WorktreeStatusInfo;
12
+ processes: {
13
+ PID: number;
14
+ Name?: string;
15
+ }[];
16
+ }
17
+ export declare class Grove {
18
+ readonly poolDir: string;
19
+ private config;
20
+ constructor(poolDir: string, config: GroveConfig);
21
+ acquire(): Promise<AcquiredSlot>;
22
+ release(worktreePath: string): Promise<void>;
23
+ private nextName;
24
+ list(): Promise<WorktreeStatus[]>;
25
+ destroy(worktreePath: string, options?: {
26
+ force?: boolean;
27
+ }): Promise<void>;
28
+ destroyAll(options?: {
29
+ force?: boolean;
30
+ }): Promise<void>;
31
+ findByPath(worktreePath: string): Promise<WorktreeEntry | null>;
32
+ }
33
+ //# sourceMappingURL=pool.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pool.d.ts","sourceRoot":"","sources":["../src/pool.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAQlD,MAAM,MAAM,kBAAkB,GAAG,WAAW,GAAG,OAAO,GAAG,QAAQ,GAAG,aAAa,CAAC;AAElF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,kBAAkB,CAAC;IAC3B,SAAS,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC7C;AAED,qBAAa,KAAK;aAEE,OAAO,EAAE,MAAM;IAC/B,OAAO,CAAC,MAAM;IAFhB,YACkB,OAAO,EAAE,MAAM,EACvB,MAAM,EAAE,WAAW,EACzB;IAEE,OAAO,IAAI,OAAO,CAAC,YAAY,CAAC,CA4ErC;IAEK,OAAO,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA6BjD;IAED,OAAO,CAAC,QAAQ;IAWV,IAAI,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC,CAuCtC;IAEK,OAAO,CAAC,YAAY,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAyDhF;IAEK,UAAU,CAAC,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA6D7D;IAEK,UAAU,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAQpE;CACF"}
package/dist/pool.js ADDED
@@ -0,0 +1,284 @@
1
+ import { join, basename, dirname, isAbsolute, relative } from "node:path";
2
+ import { mkdir, rm } from "node:fs/promises";
3
+ import { getDefaultBranch, addWorktree, resetWorktree, removeWorktree, isDirty, fetchOrigin, } from "./git/index.js";
4
+ import { withStateLock } from "./lock.js";
5
+ import { readState, writeState, healState } from "./state.js";
6
+ import { reserveOwner, ownerAlive, isWorktreeInUse, findInWorktree } from "./process/detect.js";
7
+ import { runHooks } from "./hooks.js";
8
+ import { GroveExhaustedError, WorktreeDestroyingError, WorktreeInUseError, WorktreeNotManagedError, } from "./errors.js";
9
+ export class Grove {
10
+ poolDir;
11
+ config;
12
+ constructor(poolDir, config) {
13
+ this.poolDir = poolDir;
14
+ this.config = config;
15
+ }
16
+ async acquire() {
17
+ const branch = await getDefaultBranch(this.config.repoRoot);
18
+ if (this.config.fetchOnAcquire !== false) {
19
+ await fetchOrigin(this.config.repoRoot);
20
+ }
21
+ let acquiredPath = "";
22
+ let acquiredName = "";
23
+ let runPostCreate = false;
24
+ await withStateLock(this.poolDir, async () => {
25
+ let state = await readState(this.poolDir);
26
+ state = await healState(state);
27
+ for (const wt of state.worktrees) {
28
+ if (wt.destroying)
29
+ continue;
30
+ const inUse = (await ownerAlive(wt)) || (await isWorktreeInUse(wt.path));
31
+ if (inUse)
32
+ continue;
33
+ const dirty = await isDirty(wt.path);
34
+ if (dirty) {
35
+ continue; // Do not destructively reset dirty worktrees
36
+ }
37
+ try {
38
+ await resetWorktree(wt.path, branch); // Always reset clean worktrees to default branch
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ await reserveOwner(wt);
44
+ await writeState(this.poolDir, state);
45
+ acquiredPath = wt.path;
46
+ acquiredName = wt.name;
47
+ runPostCreate = true;
48
+ return;
49
+ }
50
+ const maxTrees = this.config.maxTrees || 16;
51
+ if (state.worktrees.length >= maxTrees) {
52
+ throw new GroveExhaustedError(`Exhausted worktrees (max ${maxTrees})`);
53
+ }
54
+ const nextId = this.nextName(state);
55
+ const repoName = basename(this.config.repoRoot);
56
+ const wtPath = join(this.poolDir, nextId, repoName);
57
+ await mkdir(dirname(wtPath), { recursive: true });
58
+ await addWorktree(this.config.repoRoot, wtPath, branch);
59
+ const entry = {
60
+ name: nextId,
61
+ path: wtPath,
62
+ created_at: new Date().toISOString(),
63
+ };
64
+ await reserveOwner(entry);
65
+ state.worktrees.push(entry);
66
+ await writeState(this.poolDir, state);
67
+ acquiredPath = wtPath;
68
+ acquiredName = nextId;
69
+ runPostCreate = true;
70
+ });
71
+ if (runPostCreate && this.config.hooks?.postCreate) {
72
+ try {
73
+ await runHooks(this.config.hooks.postCreate, acquiredPath, {
74
+ stdout: process.stderr,
75
+ stderr: process.stderr,
76
+ });
77
+ }
78
+ catch {
79
+ // hook failure does not fail acquire
80
+ }
81
+ }
82
+ return { path: acquiredPath, name: acquiredName };
83
+ }
84
+ async release(worktreePath) {
85
+ const branch = await getDefaultBranch(this.config.repoRoot);
86
+ await withStateLock(this.poolDir, async () => {
87
+ const state = await readState(this.poolDir);
88
+ const wt = state.worktrees.find((w) => w.path === worktreePath);
89
+ if (!wt) {
90
+ throw new WorktreeNotManagedError(`worktree ${worktreePath} is not managed by grove`);
91
+ }
92
+ if (wt.destroying) {
93
+ throw new WorktreeDestroyingError(`worktree ${worktreePath} is being destroyed`);
94
+ }
95
+ });
96
+ await resetWorktree(worktreePath, branch);
97
+ await withStateLock(this.poolDir, async () => {
98
+ const state = await readState(this.poolDir);
99
+ const wt = state.worktrees.find((w) => w.path === worktreePath);
100
+ if (!wt) {
101
+ throw new WorktreeNotManagedError(`worktree ${worktreePath} is not managed by grove`);
102
+ }
103
+ if (wt.destroying) {
104
+ throw new WorktreeDestroyingError(`worktree ${worktreePath} is being destroyed`);
105
+ }
106
+ wt.owner_pid = undefined;
107
+ wt.owner_started_at = undefined;
108
+ await writeState(this.poolDir, state);
109
+ });
110
+ }
111
+ nextName(state) {
112
+ let max = 0;
113
+ for (const wt of state.worktrees) {
114
+ const n = parseInt(wt.name, 10);
115
+ if (!isNaN(n) && n > max) {
116
+ max = n;
117
+ }
118
+ }
119
+ return (max + 1).toString();
120
+ }
121
+ async list() {
122
+ const result = [];
123
+ await withStateLock(this.poolDir, async () => {
124
+ let state = await readState(this.poolDir);
125
+ state = await healState(state);
126
+ await writeState(this.poolDir, state);
127
+ const cwd = process.cwd();
128
+ for (const wt of state.worktrees) {
129
+ if (wt.destroying)
130
+ continue;
131
+ let status = "available";
132
+ const processes = await findInWorktree(wt.path);
133
+ const alive = await ownerAlive(wt);
134
+ if (alive) {
135
+ status = "in-use";
136
+ }
137
+ else if (processes.length > 0) {
138
+ status = "in-use";
139
+ if (cwdInWorktree(cwd, wt.path)) {
140
+ status = "you're here";
141
+ }
142
+ }
143
+ else if (await isDirty(wt.path)) {
144
+ status = "dirty";
145
+ }
146
+ result.push({
147
+ name: wt.name,
148
+ path: wt.path,
149
+ status,
150
+ processes,
151
+ });
152
+ }
153
+ });
154
+ return result;
155
+ }
156
+ async destroy(worktreePath, options) {
157
+ let reserved;
158
+ await withStateLock(this.poolDir, async () => {
159
+ const state = await readState(this.poolDir);
160
+ const idx = state.worktrees.findIndex((wt) => wt.path === worktreePath);
161
+ const targetWt = state.worktrees[idx];
162
+ if (!targetWt) {
163
+ throw new WorktreeNotManagedError(`worktree ${worktreePath} is not managed by grove`);
164
+ }
165
+ if (!options?.force) {
166
+ const inUse = (await ownerAlive(targetWt)) || (await isWorktreeInUse(targetWt.path));
167
+ if (inUse) {
168
+ throw new WorktreeInUseError(`worktree ${worktreePath} is in use by an agent. Use --force to override`);
169
+ }
170
+ }
171
+ targetWt.destroying = true;
172
+ await reserveOwner(targetWt);
173
+ reserved = { ...targetWt };
174
+ await writeState(this.poolDir, state);
175
+ });
176
+ if (this.config.hooks?.preDestroy) {
177
+ try {
178
+ await runHooks(this.config.hooks.preDestroy, worktreePath, {
179
+ stdout: process.stderr,
180
+ stderr: process.stderr,
181
+ });
182
+ }
183
+ catch { }
184
+ }
185
+ await withStateLock(this.poolDir, async () => {
186
+ const state = await readState(this.poolDir);
187
+ const idx = state.worktrees.findIndex((wt) => wt.path === worktreePath);
188
+ if (idx === -1)
189
+ return;
190
+ if (!sameDestroyReservation(state.worktrees[idx], reserved)) {
191
+ return;
192
+ }
193
+ try {
194
+ await removeWorktree(this.config.repoRoot, worktreePath);
195
+ }
196
+ catch { }
197
+ assertPathWithinPool(this.poolDir, worktreePath);
198
+ try {
199
+ await rm(dirname(worktreePath), { recursive: true, force: true });
200
+ }
201
+ catch { }
202
+ state.worktrees.splice(idx, 1);
203
+ await writeState(this.poolDir, state);
204
+ });
205
+ }
206
+ async destroyAll(options) {
207
+ let worktrees = [];
208
+ await withStateLock(this.poolDir, async () => {
209
+ const state = await readState(this.poolDir);
210
+ if (!options?.force) {
211
+ for (const wt of state.worktrees) {
212
+ const inUse = (await ownerAlive(wt)) || (await isWorktreeInUse(wt.path));
213
+ if (inUse) {
214
+ throw new WorktreeInUseError(`worktree ${wt.path} is in use by an agent. Use --force to override`);
215
+ }
216
+ }
217
+ }
218
+ for (const wt of state.worktrees) {
219
+ wt.destroying = true;
220
+ await reserveOwner(wt);
221
+ }
222
+ worktrees = state.worktrees.map((wt) => ({ ...wt }));
223
+ await writeState(this.poolDir, state);
224
+ });
225
+ for (const wt of worktrees) {
226
+ if (this.config.hooks?.preDestroy) {
227
+ try {
228
+ await runHooks(this.config.hooks.preDestroy, wt.path, {
229
+ stdout: process.stderr,
230
+ stderr: process.stderr,
231
+ });
232
+ }
233
+ catch { }
234
+ }
235
+ }
236
+ await withStateLock(this.poolDir, async () => {
237
+ const state = await readState(this.poolDir);
238
+ const remove = new Set();
239
+ for (const wt of worktrees) {
240
+ const idx = state.worktrees.findIndex((s) => s.path === wt.path);
241
+ if (idx === -1 || !sameDestroyReservation(state.worktrees[idx], wt)) {
242
+ continue;
243
+ }
244
+ remove.add(wt.path);
245
+ try {
246
+ await removeWorktree(this.config.repoRoot, wt.path);
247
+ }
248
+ catch { }
249
+ assertPathWithinPool(this.poolDir, wt.path);
250
+ try {
251
+ await rm(dirname(wt.path), { recursive: true, force: true });
252
+ }
253
+ catch { }
254
+ }
255
+ state.worktrees = state.worktrees.filter((wt) => !remove.has(wt.path));
256
+ await writeState(this.poolDir, state);
257
+ });
258
+ }
259
+ async findByPath(worktreePath) {
260
+ const state = await readState(this.poolDir);
261
+ for (const wt of state.worktrees) {
262
+ if (wt.path === worktreePath) {
263
+ return wt;
264
+ }
265
+ }
266
+ return null;
267
+ }
268
+ }
269
+ function sameDestroyReservation(current, reserved) {
270
+ return (current.path === reserved.path &&
271
+ current.destroying &&
272
+ current.owner_pid === reserved.owner_pid &&
273
+ current.owner_started_at === reserved.owner_started_at);
274
+ }
275
+ function cwdInWorktree(cwd, worktreePath) {
276
+ const rel = relative(worktreePath, cwd);
277
+ return !rel.startsWith("..") && !isAbsolute(rel);
278
+ }
279
+ function assertPathWithinPool(poolDir, targetPath) {
280
+ const rel = relative(poolDir, targetPath);
281
+ if (rel.startsWith("..") || isAbsolute(rel)) {
282
+ throw new Error("Security violation: target path is outside the pool boundary");
283
+ }
284
+ }
@@ -0,0 +1,11 @@
1
+ import type { WorktreeEntry } from "../schemas.js";
2
+ export interface ProcessInfo {
3
+ PID: number;
4
+ Name?: string;
5
+ }
6
+ export declare function startedAt(pid: number): Promise<number | null>;
7
+ export declare function reserveOwner(entry: WorktreeEntry): Promise<void>;
8
+ export declare function ownerAlive(entry: WorktreeEntry): Promise<boolean>;
9
+ export declare function findInWorktree(worktreePath: string): Promise<ProcessInfo[]>;
10
+ export declare function isWorktreeInUse(worktreePath: string): Promise<boolean>;
11
+ //# sourceMappingURL=detect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detect.d.ts","sourceRoot":"","sources":["../../src/process/detect.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAEnD,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAkBD,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA+BnE;AAED,wBAAsB,YAAY,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAMtE;AAED,wBAAsB,UAAU,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,OAAO,CAAC,CAsBvE;AAUD,wBAAsB,cAAc,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAgDjF;AAED,wBAAsB,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAG5E"}
@@ -0,0 +1,147 @@
1
+ import { realpath, readdir, readlink, stat, readFile } from "node:fs/promises";
2
+ import { relative } from "node:path";
3
+ import { execa } from "execa";
4
+ let cachedBtime = null;
5
+ const cachedClkTck = 100;
6
+ async function getLinuxBootTime() {
7
+ if (cachedBtime !== null)
8
+ return cachedBtime;
9
+ try {
10
+ const content = await readFile("/proc/stat", "utf8");
11
+ const match = content.match(/^btime\s+(\d+)/m);
12
+ if (match && match[1]) {
13
+ cachedBtime = parseInt(match[1], 10) * 1000;
14
+ return cachedBtime;
15
+ }
16
+ }
17
+ catch { }
18
+ return Date.now() - process.uptime() * 1000;
19
+ }
20
+ export async function startedAt(pid) {
21
+ try {
22
+ if (process.platform === "linux") {
23
+ try {
24
+ const statContent = await readFile(`/proc/${pid}/stat`, "utf8");
25
+ const lastParen = statContent.lastIndexOf(")");
26
+ if (lastParen !== -1) {
27
+ const rest = statContent
28
+ .slice(lastParen + 2)
29
+ .trim()
30
+ .split(/\s+/);
31
+ const starttimeTicks = parseInt(rest[19], 10);
32
+ if (!isNaN(starttimeTicks)) {
33
+ const btime = await getLinuxBootTime();
34
+ const msSinceBoot = (starttimeTicks / cachedClkTck) * 1000;
35
+ return btime + msSinceBoot;
36
+ }
37
+ }
38
+ }
39
+ catch {
40
+ const s = await stat(`/proc/${pid}`);
41
+ return s.mtimeMs;
42
+ }
43
+ }
44
+ else if (process.platform === "darwin") {
45
+ const { stdout } = await execa("ps", ["-p", String(pid), "-o", "lstart="]);
46
+ const parsed = Date.parse(stdout.trim());
47
+ return Number.isNaN(parsed) ? null : parsed;
48
+ }
49
+ return null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ export async function reserveOwner(entry) {
56
+ entry.owner_pid = process.pid;
57
+ const start = await startedAt(process.pid);
58
+ if (start !== null) {
59
+ entry.owner_started_at = start;
60
+ }
61
+ }
62
+ export async function ownerAlive(entry) {
63
+ if (entry.owner_pid === undefined)
64
+ return false;
65
+ // Phase 2 stub mock PID handling
66
+ if (entry.owner_pid === -1)
67
+ return false;
68
+ // Quick PID existence check
69
+ try {
70
+ process.kill(entry.owner_pid, 0);
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ // Exact process match check
76
+ if (entry.owner_started_at !== undefined) {
77
+ const start = await startedAt(entry.owner_pid);
78
+ if (start === null || start !== entry.owner_started_at) {
79
+ return false;
80
+ }
81
+ }
82
+ return true;
83
+ }
84
+ async function resolvePathSafe(p) {
85
+ try {
86
+ return await realpath(p);
87
+ }
88
+ catch {
89
+ return p;
90
+ }
91
+ }
92
+ export async function findInWorktree(worktreePath) {
93
+ const absWorktree = await resolvePathSafe(worktreePath);
94
+ const result = [];
95
+ if (process.platform === "linux") {
96
+ let procs = [];
97
+ try {
98
+ procs = await readdir("/proc");
99
+ }
100
+ catch {
101
+ return [];
102
+ }
103
+ for (const p of procs) {
104
+ if (!/^\d+$/.test(p))
105
+ continue;
106
+ try {
107
+ const cwd = await readlink(`/proc/${p}/cwd`);
108
+ const absCwd = await resolvePathSafe(cwd);
109
+ const rel = relative(absWorktree, absCwd);
110
+ if (!rel.startsWith("..") && rel !== "..") {
111
+ result.push({ PID: parseInt(p, 10) });
112
+ }
113
+ }
114
+ catch {
115
+ continue;
116
+ }
117
+ }
118
+ }
119
+ else if (process.platform === "darwin") {
120
+ try {
121
+ const { stdout } = await execa("lsof", ["-F", "pn", "-d", "cwd"], { reject: false });
122
+ const lines = stdout.split("\n");
123
+ let currentPid = -1;
124
+ for (const line of lines) {
125
+ if (line.startsWith("p")) {
126
+ currentPid = parseInt(line.slice(1), 10);
127
+ }
128
+ else if (line.startsWith("n") && currentPid !== -1) {
129
+ const cwd = line.slice(1);
130
+ const absCwd = await resolvePathSafe(cwd);
131
+ const rel = relative(absWorktree, absCwd);
132
+ if (!rel.startsWith("..") && rel !== "..") {
133
+ result.push({ PID: currentPid });
134
+ }
135
+ }
136
+ }
137
+ }
138
+ catch {
139
+ // lsof returns error code if it finds no files or encounters permission errors.
140
+ }
141
+ }
142
+ return result;
143
+ }
144
+ export async function isWorktreeInUse(worktreePath) {
145
+ const procs = await findInWorktree(worktreePath);
146
+ return procs.length > 0;
147
+ }
@@ -0,0 +1,6 @@
1
+ import { type ProcessInfo } from "./detect.js";
2
+ export declare function parentPID(pid: number): Promise<number>;
3
+ export declare function isAlive(pid: number): boolean;
4
+ export declare function filterProtectedProcesses(procs: ProcessInfo[], currentPID: number): Promise<ProcessInfo[]>;
5
+ export declare function terminateWorktreeProcesses(worktreePath: string, gracePeriodMs: number): Promise<ProcessInfo[]>;
6
+ //# sourceMappingURL=terminate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"terminate.d.ts","sourceRoot":"","sources":["../../src/process/terminate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAG/D,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA6B5D;AAED,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAO5C;AAgCD,wBAAsB,wBAAwB,CAC5C,KAAK,EAAE,WAAW,EAAE,EACpB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,WAAW,EAAE,CAAC,CAaxB;AAED,wBAAsB,0BAA0B,CAC9C,YAAY,EAAE,MAAM,EACpB,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,WAAW,EAAE,CAAC,CAWxB"}
@@ -0,0 +1,96 @@
1
+ import { findInWorktree } from "./detect.js";
2
+ import { execa } from "execa";
3
+ export async function parentPID(pid) {
4
+ if (process.platform === "linux") {
5
+ try {
6
+ const { readFile } = await import("node:fs/promises");
7
+ const stat = await readFile(`/proc/${pid}/stat`, "utf8");
8
+ const lastCloseParen = stat.lastIndexOf(")");
9
+ if (lastCloseParen !== -1) {
10
+ const postParen = stat.substring(lastCloseParen + 2);
11
+ const fields = postParen.split(" ");
12
+ const ppid = parseInt(fields[1] || "", 10);
13
+ if (!Number.isNaN(ppid)) {
14
+ return ppid;
15
+ }
16
+ }
17
+ }
18
+ catch {
19
+ return 0;
20
+ }
21
+ }
22
+ else if (process.platform === "darwin") {
23
+ try {
24
+ const { stdout } = await execa("ps", ["-p", String(pid), "-o", "ppid="]);
25
+ const ppid = parseInt(stdout.trim(), 10);
26
+ if (!Number.isNaN(ppid)) {
27
+ return ppid;
28
+ }
29
+ }
30
+ catch {
31
+ return 0;
32
+ }
33
+ }
34
+ return 0;
35
+ }
36
+ export function isAlive(pid) {
37
+ try {
38
+ process.kill(pid, 0);
39
+ return true;
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ function anyAlive(pids) {
46
+ return pids.some((pid) => isAlive(pid));
47
+ }
48
+ async function terminate(pids, gracePeriodMs) {
49
+ for (const pid of pids) {
50
+ if (isAlive(pid)) {
51
+ try {
52
+ process.kill(pid, "SIGTERM");
53
+ }
54
+ catch { }
55
+ }
56
+ }
57
+ const deadline = Date.now() + gracePeriodMs;
58
+ while (Date.now() < deadline) {
59
+ if (!anyAlive(pids)) {
60
+ return;
61
+ }
62
+ await new Promise((r) => setTimeout(r, 50));
63
+ }
64
+ for (const pid of pids) {
65
+ if (isAlive(pid)) {
66
+ try {
67
+ process.kill(pid, "SIGKILL");
68
+ }
69
+ catch { }
70
+ }
71
+ }
72
+ }
73
+ export async function filterProtectedProcesses(procs, currentPID) {
74
+ const protectedPids = new Set([currentPID]);
75
+ let pid = currentPID;
76
+ while (pid > 0) {
77
+ const parent = await parentPID(pid);
78
+ if (parent <= 0)
79
+ break;
80
+ if (protectedPids.has(parent))
81
+ break;
82
+ protectedPids.add(parent);
83
+ pid = parent;
84
+ }
85
+ return procs.filter((p) => !protectedPids.has(p.PID));
86
+ }
87
+ export async function terminateWorktreeProcesses(worktreePath, gracePeriodMs) {
88
+ const procs = await findInWorktree(worktreePath);
89
+ const targetProcs = await filterProtectedProcesses(procs, process.pid);
90
+ if (targetProcs.length === 0) {
91
+ return [];
92
+ }
93
+ const pids = targetProcs.map((p) => p.PID);
94
+ await terminate(pids, gracePeriodMs);
95
+ return targetProcs;
96
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from "zod";
2
+ export declare const WorktreeEntrySchema: z.ZodObject<{
3
+ name: z.ZodString;
4
+ path: z.ZodString;
5
+ created_at: z.ZodString;
6
+ destroying: z.ZodOptional<z.ZodBoolean>;
7
+ owner_pid: z.ZodOptional<z.ZodNumber>;
8
+ owner_started_at: z.ZodOptional<z.ZodNumber>;
9
+ }, z.core.$strip>;
10
+ export type WorktreeEntry = z.infer<typeof WorktreeEntrySchema>;
11
+ export declare const GroveStateSchema: z.ZodObject<{
12
+ worktrees: z.ZodArray<z.ZodObject<{
13
+ name: z.ZodString;
14
+ path: z.ZodString;
15
+ created_at: z.ZodString;
16
+ destroying: z.ZodOptional<z.ZodBoolean>;
17
+ owner_pid: z.ZodOptional<z.ZodNumber>;
18
+ owner_started_at: z.ZodOptional<z.ZodNumber>;
19
+ }, z.core.$strip>>;
20
+ }, z.core.$strip>;
21
+ export type GroveState = z.infer<typeof GroveStateSchema>;
22
+ export declare const GroveConfigSchema: z.ZodObject<{
23
+ repoRoot: z.ZodString;
24
+ groveDir: z.ZodOptional<z.ZodString>;
25
+ groveRoot: z.ZodOptional<z.ZodString>;
26
+ maxTrees: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
27
+ hooks: z.ZodOptional<z.ZodObject<{
28
+ postCreate: z.ZodOptional<z.ZodArray<z.ZodString>>;
29
+ preDestroy: z.ZodOptional<z.ZodArray<z.ZodString>>;
30
+ }, z.core.$strip>>;
31
+ fetchOnAcquire: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
32
+ }, z.core.$strip>;
33
+ export type GroveConfig = z.input<typeof GroveConfigSchema>;
34
+ //# sourceMappingURL=schemas.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,eAAO,MAAM,mBAAmB;;;;;;;iBAO9B,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE,eAAO,MAAM,gBAAgB;;;;;;;;;iBAE3B,CAAC;AAEH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE1D,eAAO,MAAM,iBAAiB;;;;;;;;;;iBAY5B,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC"}
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+ export const WorktreeEntrySchema = z.object({
3
+ name: z.string(),
4
+ path: z.string(),
5
+ created_at: z.string(),
6
+ destroying: z.boolean().optional(),
7
+ owner_pid: z.number().optional(),
8
+ owner_started_at: z.number().optional(),
9
+ });
10
+ export const GroveStateSchema = z.object({
11
+ worktrees: z.array(WorktreeEntrySchema),
12
+ });
13
+ export const GroveConfigSchema = z.object({
14
+ repoRoot: z.string(),
15
+ groveDir: z.string().optional(),
16
+ groveRoot: z.string().optional(),
17
+ maxTrees: z.number().optional().default(16),
18
+ hooks: z
19
+ .object({
20
+ postCreate: z.array(z.string()).optional(),
21
+ preDestroy: z.array(z.string()).optional(),
22
+ })
23
+ .optional(),
24
+ fetchOnAcquire: z.boolean().optional().default(true),
25
+ });
@@ -0,0 +1,6 @@
1
+ import type { GroveState } from "./schemas.js";
2
+ export declare function stateFilePath(groveDir: string): string;
3
+ export declare function readState(groveDir: string): Promise<GroveState>;
4
+ export declare function writeState(groveDir: string, state: GroveState): Promise<void>;
5
+ export declare function healState(state: GroveState): Promise<GroveState>;
6
+ //# sourceMappingURL=state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAK/C,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEtD;AAED,wBAAsB,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAwBrE;AAED,wBAAsB,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAMnF;AAED,wBAAsB,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAqBtE"}
package/dist/state.js ADDED
@@ -0,0 +1,58 @@
1
+ import { readFile, writeFile, rename } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { GroveStateSchema } from "./schemas.js";
4
+ import { InvalidGroveStateError } from "./errors.js";
5
+ import { ownerAlive } from "./process/detect.js";
6
+ import { existsSync } from "node:fs";
7
+ export function stateFilePath(groveDir) {
8
+ return join(groveDir, "grove-state.json");
9
+ }
10
+ export async function readState(groveDir) {
11
+ let data;
12
+ try {
13
+ data = await readFile(stateFilePath(groveDir), "utf8");
14
+ }
15
+ catch (error) {
16
+ if (error.code === "ENOENT") {
17
+ return { worktrees: [] };
18
+ }
19
+ throw error;
20
+ }
21
+ let parsed;
22
+ try {
23
+ parsed = JSON.parse(data);
24
+ }
25
+ catch {
26
+ throw new InvalidGroveStateError("Invalid JSON format");
27
+ }
28
+ const result = GroveStateSchema.safeParse(parsed);
29
+ if (!result.success) {
30
+ throw new InvalidGroveStateError("State validation failed");
31
+ }
32
+ return result.data;
33
+ }
34
+ export async function writeState(groveDir, state) {
35
+ const data = JSON.stringify(state, null, 2);
36
+ const target = stateFilePath(groveDir);
37
+ const tmp = `${target}.tmp`;
38
+ await writeFile(tmp, data, { mode: 0o644 });
39
+ await rename(tmp, target);
40
+ }
41
+ export async function healState(state) {
42
+ const healed = { worktrees: [] };
43
+ for (const entry of state.worktrees) {
44
+ if (!existsSync(entry.path)) {
45
+ continue; // drop entries where path does not exist on disk
46
+ }
47
+ if (entry.owner_pid !== undefined) {
48
+ if (!(await ownerAlive(entry))) {
49
+ // clear owner fields
50
+ const { owner_pid: _p, owner_started_at: _s, destroying: _d, ...rest } = entry;
51
+ healed.worktrees.push(rest);
52
+ continue;
53
+ }
54
+ }
55
+ healed.worktrees.push(entry);
56
+ }
57
+ return healed;
58
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@ferueda/grove",
3
+ "version": "0.1.0",
4
+ "files": [
5
+ "dist"
6
+ ],
7
+ "type": "module",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "dependencies": {
16
+ "execa": "^9.6.0",
17
+ "proper-lockfile": "^4.0.0",
18
+ "zod": "^4.4.0"
19
+ },
20
+ "scripts": {
21
+ "build": "tsgo -p tsconfig.json"
22
+ }
23
+ }