@n-seiji/nuthatch 0.1.5 → 0.2.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.
@@ -0,0 +1,124 @@
1
+ import {
2
+ EXIT_SAFE_REJECTION,
3
+ fail
4
+ } from "./cli-77yanq37.js";
5
+
6
+ // src/infra/lock.ts
7
+ import { randomUUID } from "node:crypto";
8
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+
11
+ // src/domain/lock-policy.ts
12
+ var canReclaimLock = (input) => {
13
+ if (input.processAlive !== false) {
14
+ return false;
15
+ }
16
+ return input.nowMs - input.startedAtMs > input.ttlMs;
17
+ };
18
+
19
+ // src/infra/lock.ts
20
+ var LOCK_DIR_NAME = "nuthatch-lock";
21
+ var LOCK_INFO_FILE = "info.json";
22
+ var DEFAULT_TTL_MS = 30000;
23
+ var HEARTBEAT_INTERVAL_MS = 5000;
24
+
25
+ class LockHeldError extends Error {
26
+ info;
27
+ constructor(info) {
28
+ super(`Repository is locked by another nuthatch process (pid ${info.pid})`);
29
+ this.name = "LockHeldError";
30
+ this.info = info;
31
+ }
32
+ }
33
+ var isProcessAlive = (pid) => {
34
+ try {
35
+ process.kill(pid, 0);
36
+ return true;
37
+ } catch (error) {
38
+ const { code } = error;
39
+ if (code === "ESRCH") {
40
+ return false;
41
+ }
42
+ if (code === "EPERM") {
43
+ return true;
44
+ }
45
+ return "unknown";
46
+ }
47
+ };
48
+ var readLockInfo = async (lockDir) => {
49
+ try {
50
+ const raw = await readFile(join(lockDir, LOCK_INFO_FILE), "utf8");
51
+ return JSON.parse(raw);
52
+ } catch {
53
+ return null;
54
+ }
55
+ };
56
+ var writeLockInfo = async (lockDir, info) => {
57
+ await writeFile(join(lockDir, LOCK_INFO_FILE), JSON.stringify(info), "utf8");
58
+ };
59
+ var acquireRepoLock = async (commonDir, ttlMs = DEFAULT_TTL_MS) => {
60
+ const lockDir = join(commonDir, LOCK_DIR_NAME);
61
+ for (;; ) {
62
+ try {
63
+ await mkdir(lockDir);
64
+ break;
65
+ } catch (error) {
66
+ if (error.code !== "EEXIST") {
67
+ throw error;
68
+ }
69
+ const existing = await readLockInfo(lockDir);
70
+ if (existing === null) {
71
+ throw new LockHeldError({ pid: -1, startedAtMs: 0, token: "unknown" });
72
+ }
73
+ const reclaimable = canReclaimLock({
74
+ processAlive: isProcessAlive(existing.pid),
75
+ startedAtMs: existing.startedAtMs,
76
+ nowMs: Date.now(),
77
+ ttlMs
78
+ });
79
+ if (!reclaimable) {
80
+ throw new LockHeldError(existing);
81
+ }
82
+ await rm(lockDir, { recursive: true, force: true });
83
+ }
84
+ }
85
+ const info = {
86
+ pid: process.pid,
87
+ startedAtMs: Date.now(),
88
+ token: randomUUID()
89
+ };
90
+ await writeLockInfo(lockDir, info);
91
+ const heartbeat = setInterval(() => {
92
+ writeLockInfo(lockDir, { ...info, startedAtMs: Date.now() });
93
+ }, HEARTBEAT_INTERVAL_MS);
94
+ heartbeat.unref();
95
+ return {
96
+ async release() {
97
+ clearInterval(heartbeat);
98
+ await rm(lockDir, { recursive: true, force: true });
99
+ }
100
+ };
101
+ };
102
+ var acquireUnlessHeld = async (commonDir) => {
103
+ try {
104
+ return await acquireRepoLock(commonDir);
105
+ } catch (error) {
106
+ if (error instanceof LockHeldError) {
107
+ return error;
108
+ }
109
+ throw error;
110
+ }
111
+ };
112
+ var withRepoLock = async (commonDir, mutate) => {
113
+ const lock = await acquireUnlessHeld(commonDir);
114
+ if (lock instanceof LockHeldError) {
115
+ return fail(EXIT_SAFE_REJECTION, lock.message);
116
+ }
117
+ try {
118
+ return await mutate();
119
+ } finally {
120
+ await lock.release();
121
+ }
122
+ };
123
+
124
+ export { withRepoLock };
@@ -0,0 +1,97 @@
1
+ import {
2
+ parsePorcelain
3
+ } from "./cli-b0tk5wcx.js";
4
+ import {
5
+ EXIT_USAGE_ERROR,
6
+ fail,
7
+ pass,
8
+ reject
9
+ } from "./cli-77yanq37.js";
10
+ import {
11
+ isWithin,
12
+ classifyWorktreePath
13
+ } from "./cli-a56b2ar0.js";
14
+
15
+ // src/domain/branch-name.ts
16
+ var isOptionLikeRef = (name) => name.startsWith("-");
17
+ var optionLikeRefRejection = (label, name) => isOptionLikeRef(name) ? fail(EXIT_USAGE_ERROR, `Refusing ${label} "${name}": names starting with "-" would be parsed as git options.`) : null;
18
+ var refArgumentRejection = (branch, track) => optionLikeRefRejection("branch", branch) ?? (track === undefined ? null : optionLikeRefRejection("--track ref", track));
19
+
20
+ // src/infra/repo.ts
21
+ import { basename, dirname, join } from "node:path";
22
+
23
+ // src/domain/tracking.ts
24
+ var resolveTrackingRef = (branch, remotes) => {
25
+ if (remotes.includes("origin")) {
26
+ return { kind: "track", ref: `origin/${branch}` };
27
+ }
28
+ const [first, second] = remotes;
29
+ if (first === undefined) {
30
+ return { kind: "none" };
31
+ }
32
+ if (second !== undefined) {
33
+ return { kind: "ambiguous" };
34
+ }
35
+ return { kind: "track", ref: `${first}/${branch}` };
36
+ };
37
+
38
+ // src/infra/repo.ts
39
+ var loadRepoContext = async (git, fs, cwd) => {
40
+ const [commonDir, porcelainOut] = await Promise.all([
41
+ git.commonDir(cwd),
42
+ git.listWorktreesPorcelain(cwd)
43
+ ]);
44
+ const parsed = parsePorcelain(porcelainOut);
45
+ const [firstEntry] = parsed;
46
+ if (firstEntry === undefined) {
47
+ throw new Error("git worktree list returned no entries");
48
+ }
49
+ const rootPath = await realpathOrRaw(fs, firstEntry.path);
50
+ const managedRoot = join(dirname(rootPath), "_worktree", basename(rootPath));
51
+ const worktrees = await Promise.all(parsed.map(async (entry) => {
52
+ const resolvedPath = await realpathOrRaw(fs, entry.path);
53
+ return {
54
+ ...entry,
55
+ path: resolvedPath,
56
+ kind: classifyWorktreePath(resolvedPath, rootPath, managedRoot)
57
+ };
58
+ }));
59
+ return { rootPath, managedRoot, commonDir, worktrees };
60
+ };
61
+ var nestedWorktrees = (worktrees, path) => worktrees.filter((wt) => wt.path !== path && !wt.prunable && isWithin(path, wt.path));
62
+ var isWorktreeDirty = (git, worktrees, path) => git.isDirty(path, nestedWorktrees(worktrees, path).map((wt) => wt.path));
63
+ var worktreeDirtyState = async (git, fs, worktrees, wt) => {
64
+ if (wt.bare || wt.prunable || !await fs.exists(wt.path)) {
65
+ return null;
66
+ }
67
+ return isWorktreeDirty(git, worktrees, wt.path);
68
+ };
69
+ var resolveBranchCheckout = async (git, rootPath, branch, explicitTrack) => {
70
+ const localBranches = await git.listBranches(rootPath);
71
+ const createBranch = !localBranches.includes(branch);
72
+ let track = explicitTrack;
73
+ if (createBranch && track === undefined) {
74
+ const remotes = await git.remotesWithBranch(rootPath, branch);
75
+ const resolution = resolveTrackingRef(branch, remotes);
76
+ if (resolution.kind === "ambiguous") {
77
+ return reject(fail(EXIT_USAGE_ERROR, `Branch "${branch}" exists on multiple remotes (${remotes.join(", ")}). Use --track to disambiguate.`));
78
+ }
79
+ if (resolution.kind === "track") {
80
+ const rejection = optionLikeRefRejection("--track ref", resolution.ref);
81
+ if (rejection !== null) {
82
+ return reject(rejection);
83
+ }
84
+ track = resolution.ref;
85
+ }
86
+ }
87
+ return pass({ createBranch, ...track === undefined ? {} : { track } });
88
+ };
89
+ var realpathOrRaw = async (fs, path) => {
90
+ try {
91
+ return await fs.realpath(path);
92
+ } catch {
93
+ return path;
94
+ }
95
+ };
96
+
97
+ export { isOptionLikeRef, optionLikeRefRejection, refArgumentRejection, loadRepoContext, nestedWorktrees, isWorktreeDirty, worktreeDirtyState, resolveBranchCheckout };