@solaqua/gji 0.12.0 → 0.12.2

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/hub.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { basename } from "node:path";
2
+ import { stdin, stdout } from "node:process";
3
+ import { spinner } from "@clack/prompts";
2
4
  import { loadHistory } from "./history.js";
3
5
  import { createPullRequestQuery, } from "./pull-requests.js";
4
6
  import { detectRepository } from "./repo.js";
@@ -65,7 +67,17 @@ export async function buildHubData(cwd, dependencies = {}) {
65
67
  };
66
68
  }
67
69
  export async function runHubCommand(options, dependencies = {}) {
68
- const data = await buildHubData(options.cwd, dependencies);
70
+ const loading = !options.json && stdin.isTTY === true && stdout.isTTY === true
71
+ ? spinner({ indicator: "timer" })
72
+ : null;
73
+ loading?.start("Loading worktrees");
74
+ let data;
75
+ try {
76
+ data = await buildHubData(options.cwd, dependencies);
77
+ }
78
+ finally {
79
+ loading?.stop();
80
+ }
69
81
  if (options.json) {
70
82
  options.stdout(`${JSON.stringify(data, null, 2)}\n`);
71
83
  return 0;
@@ -4,8 +4,18 @@ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { isAlreadyExistsError, isNotDirectoryError, isNotFoundError, } from "./fs-utils.js";
5
5
  export async function openDestinationDirectory(root, path) {
6
6
  const segments = destinationSegments(root, path);
7
+ if (process.platform === "darwin") {
8
+ await ensureDestinationDirectory(root, path);
9
+ const handle = await open(path, destinationDirectoryFlags());
10
+ return {
11
+ path: resolve(path),
12
+ close: async () => handle.close(),
13
+ };
14
+ }
7
15
  if (process.platform !== "linux") {
8
16
  await ensureDestinationDirectory(root, path);
17
+ const handle = await open(path, destinationDirectoryFlags());
18
+ await handle.close();
9
19
  return { path: resolve(path), close: async () => undefined };
10
20
  }
11
21
  let handle = await open(root, destinationDirectoryFlags());
@@ -1,4 +1,3 @@
1
- import { type CloneFailureStore } from "./clone-failure-store.js";
2
1
  import type { CloneDirectory } from "./dir-clone.js";
3
2
  import type { SyncDirectoryPlan } from "./sync-plan.js";
4
3
  export interface ClonedDirectory {
@@ -15,7 +14,6 @@ export type SyncDirectoryOutcome = {
15
14
  reason: string;
16
15
  };
17
16
  export interface SyncDirectoryReporter {
18
- readonly emitCachedFailureWarnings: boolean;
19
17
  readonly measureCloneSize: boolean;
20
18
  write(message: string): void;
21
19
  cloned(directory: ClonedDirectory): void;
@@ -26,8 +24,6 @@ export interface SyncDirectoryReporter {
26
24
  }
27
25
  export interface SyncDirectoryExecutionOptions {
28
26
  cloneDirectory: CloneDirectory;
29
- failureStore?: CloneFailureStore;
30
- repoRoot: string;
31
27
  reporter: SyncDirectoryReporter;
32
28
  }
33
29
  export declare function executeSyncDirectoryPlan(plan: readonly SyncDirectoryPlan[], options: SyncDirectoryExecutionOptions): Promise<SyncDirectoryOutcome[]>;
@@ -1,8 +1,6 @@
1
- import { cloneFailureScope, defaultCloneFailureStore, } from "./clone-failure-store.js";
2
- import { isCloneDestinationExistsError, isCloneInProgressError, isCloneUnsupportedError, } from "./dir-clone.js";
1
+ import { isCloneDestinationExistsError, isCloneInProgressError, } from "./dir-clone.js";
3
2
  import { inspectDestination } from "./safe-destination.js";
4
3
  export async function executeSyncDirectoryPlan(plan, options) {
5
- const failureStore = options.failureStore ?? defaultCloneFailureStore;
6
4
  const outcomes = [];
7
5
  for (const entry of plan) {
8
6
  if (entry.destinationWarning) {
@@ -27,18 +25,6 @@ export async function executeSyncDirectoryPlan(plan, options) {
27
25
  recordSkipped(outcomes, options.reporter, entry.directory, "source does not exist");
28
26
  continue;
29
27
  }
30
- const failureScope = await cloneFailureScope(entry.source, entry.destination);
31
- if (await failureStore.isCached(options.repoRoot, entry.directory, failureScope)) {
32
- if (options.reporter.emitCachedFailureWarnings ||
33
- options.reporter.skipped) {
34
- recordSkipped(outcomes, options.reporter, entry.directory, "copy-on-write failure cached");
35
- }
36
- else {
37
- options.reporter.write(`syncDirs: previous copy-on-write failure cached, skipped ${entry.directory}\n`);
38
- recordSkipped(outcomes, options.reporter, entry.directory, "copy-on-write failure cached", false);
39
- }
40
- continue;
41
- }
42
28
  const refreshedDestinationState = await inspectDestination(entry.worktreePath, entry.destination);
43
29
  if (refreshedDestinationState.kind !== "missing") {
44
30
  recordSkipped(outcomes, options.reporter, entry.directory, refreshedDestinationState.kind === "unsafe"
@@ -65,13 +51,9 @@ export async function executeSyncDirectoryPlan(plan, options) {
65
51
  continue;
66
52
  }
67
53
  const reason = toErrorMessage(error);
68
- if (isCloneUnsupportedError(error)) {
69
- await failureStore.cache(options.repoRoot, entry.directory, reason, failureScope);
70
- }
71
54
  recordSkipped(outcomes, options.reporter, entry.directory, reason);
72
55
  continue;
73
56
  }
74
- await failureStore.clear(options.repoRoot, entry.directory, failureScope);
75
57
  const clonedDirectory = {
76
58
  bytes: result.bytes,
77
59
  dir: entry.directory,
@@ -83,14 +65,12 @@ export async function executeSyncDirectoryPlan(plan, options) {
83
65
  }
84
66
  return outcomes;
85
67
  }
86
- function recordSkipped(outcomes, reporter, dir, reason, notify = true) {
68
+ function recordSkipped(outcomes, reporter, dir, reason) {
87
69
  outcomes.push({ kind: "skipped", dir, reason });
88
- if (notify) {
89
- if (reporter.skipped)
90
- reporter.skipped({ dir, reason });
91
- else
92
- reporter.write(`syncDirs: ${reason}, skipped ${dir}\n`);
93
- }
70
+ if (reporter.skipped)
71
+ reporter.skipped({ dir, reason });
72
+ else
73
+ reporter.write(`syncDirs: ${reason}, skipped ${dir}\n`);
94
74
  }
95
75
  function toErrorMessage(error) {
96
76
  return error instanceof Error ? error.message : String(error);
@@ -19,7 +19,6 @@ export async function bootstrapWorktree(options) {
19
19
  const syncPlan = await prepareSyncDirectoryPlan(options.repoRoot, options.worktreePath, options.config.syncDirs ?? []);
20
20
  const outcomes = await executeSyncDirectoryPlan(syncPlan, {
21
21
  cloneDirectory: options.cloneDirectory ?? cloneDir,
22
- repoRoot: options.repoRoot,
23
22
  reporter: options.reporter,
24
23
  });
25
24
  const clonedDirs = outcomes.flatMap((outcome) => outcome.kind === "cloned" ? [outcome.directory] : []);
@@ -53,7 +52,6 @@ export async function bootstrapWorktree(options) {
53
52
  ? { mode: dependencyMode, ready: false, events: [] }
54
53
  : await executeDependencyBootstrap(dependencyPlan, {
55
54
  cloneDirectory: options.cloneDirectory,
56
- repoRoot: options.repoRoot,
57
55
  reporter: options.reporter,
58
56
  stderr: options.commandStderr ?? options.reporter.write,
59
57
  stdout: options.commandStdout,
@@ -1,5 +1,6 @@
1
1
  import { env, platform, stdin, stdout } from "node:process";
2
2
  import { isCancel, Prompt } from "@clack/core";
3
+ import { spinner } from "@clack/prompts";
3
4
  import { loadHistory } from "./history.js";
4
5
  import { createPullRequestQuery, } from "./pull-requests.js";
5
6
  import { readTask } from "./task.js";
@@ -8,6 +9,18 @@ import { readWorktreeInfos, } from "./worktree-info.js";
8
9
  const MAX_PULL_REQUEST_REPOSITORY_QUERY_CONCURRENCY = 4;
9
10
  const MAX_TASK_READ_CONCURRENCY = 8;
10
11
  export async function buildWorktreePromptEntries(sources, dependencies = {}) {
12
+ const loading = stdin.isTTY === true && stdout.isTTY === true
13
+ ? spinner({ indicator: "timer" })
14
+ : null;
15
+ loading?.start("Loading worktrees");
16
+ try {
17
+ return await buildWorktreePromptEntriesWithoutLoading(sources, dependencies);
18
+ }
19
+ finally {
20
+ loading?.stop();
21
+ }
22
+ }
23
+ async function buildWorktreePromptEntriesWithoutLoading(sources, dependencies) {
11
24
  const metadataMode = dependencies.metadata ?? "full";
12
25
  const includeMetadata = metadataMode === "full";
13
26
  const pullRequestQuery = createPullRequestQuery();
@@ -1,4 +1,4 @@
1
- .TH GJI\-BACK 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-BACK 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-back \- navigate to the previously visited worktree, optionally N steps back
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-CLEAN 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-CLEAN 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-clean \- interactively prune linked worktrees
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-COMPLETION 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-COMPLETION 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-completion \- print shell completion definitions
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-CONFIG 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-CONFIG 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-config \- manage global config defaults
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-DOCTOR 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-DOCTOR 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-doctor \- check gji installation and configuration health
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-DONE 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-DONE 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-done \- finish the current linked worktree and return safely
4
4
  .SH SYNOPSIS
package/man/man1/gji-go.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH GJI\-GO 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-GO 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-go \- resolve and jump to a worktree path
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-HISTORY 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-HISTORY 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-history \- show navigation history
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-INIT 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-INIT 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-init \- set up shell integration interactively or print a shell wrapper
4
4
  .SH SYNOPSIS
package/man/man1/gji-ls.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH GJI\-LS 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-LS 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-ls \- list active worktrees
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-NEW 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-NEW 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-new \- create a new branch or detached linked worktree and CoW\-bootstrap configured directories
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-OPEN 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-OPEN 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-open \- open the worktree in an editor
4
4
  .SH SYNOPSIS
package/man/man1/gji-pr.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH GJI\-PR 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-PR 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-pr \- fetch a pull request by number, #number, or URL into a linked worktree
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-REMOVE 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-REMOVE 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-remove \- deprecated: use gji done for one worktree or gji clean for bulk cleanup
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-ROOT 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-ROOT 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-root \- print the main repository root path
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-RUN\-HOOK 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-RUN\-HOOK 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-run\-hook \- run a named hook (after\-create, after\-enter, before\-remove) in the current worktree
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-STATUS 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-STATUS 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-status \- summarize repository and worktree health
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-SYNC\-FILES 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-SYNC\-FILES 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-sync\-files \- manage local files copied into new worktrees
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-SYNC 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-SYNC 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-sync \- fetch and update one or all worktrees
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-TASK 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-TASK 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-task \- show or update the current worktree task
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-UNDO 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-UNDO 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-undo \- restore the most recent destructive worktree operation
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH GJI\-WARP 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI\-WARP 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji\-warp \- deprecated: use gji go (press Tab for all known repos)
4
4
  .SH SYNOPSIS
package/man/man1/gji.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH GJI 1 "July 2026" "gji 0.12.0" "User Commands"
1
+ .TH GJI 1 "August 2026" "gji 0.12.2" "User Commands"
2
2
  .SH NAME
3
3
  gji \- Context switching without the mess.
4
4
  .SH SYNOPSIS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solaqua/gji",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Git worktree CLI for fast context switching.",
5
5
  "license": "MIT",
6
6
  "author": "sjquant",
@@ -1,18 +0,0 @@
1
- export interface CloneFailureStore {
2
- isCached(repoRoot: string, directory: string, scope?: string): Promise<boolean>;
3
- cache(repoRoot: string, directory: string, reason: string, scope?: string): Promise<void>;
4
- clear(repoRoot: string, directory: string, scope?: string): Promise<void>;
5
- }
6
- export declare class FileCloneFailureStore implements CloneFailureStore {
7
- private updateQueue;
8
- isCached(repoRoot: string, directory: string, scope?: string): Promise<boolean>;
9
- cache(repoRoot: string, directory: string, reason: string, scope?: string): Promise<void>;
10
- clear(repoRoot: string, directory: string, scope?: string): Promise<void>;
11
- private update;
12
- private withStateLock;
13
- private readState;
14
- private writeState;
15
- private stateFilePath;
16
- }
17
- export declare const defaultCloneFailureStore: CloneFailureStore;
18
- export declare function cloneFailureScope(source: string, destination: string): Promise<string>;
@@ -1,230 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { mkdir, mkdtemp, readdir, readFile, rename, rm, rmdir, stat, unlink, utimes, writeFile, } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
- import { dirname, join, resolve } from "node:path";
5
- import { GLOBAL_CONFIG_DIRECTORY } from "./config.js";
6
- const STATE_FILE_NAME = "state.json";
7
- const STATE_LOCK_SUFFIX = ".lock";
8
- const CLONE_FAILURE_TTL_MS = 24 * 60 * 60 * 1000;
9
- const STATE_LOCK_TTL_MS = 30 * 1000;
10
- export class FileCloneFailureStore {
11
- updateQueue = Promise.resolve();
12
- async isCached(repoRoot, directory, scope) {
13
- const state = await this.readState();
14
- const repoState = state.syncDirs?.[repoRoot];
15
- const key = failureKey(directory, scope);
16
- if (!repoState || !Object.hasOwn(repoState, key))
17
- return false;
18
- const failure = repoState[key];
19
- return (isPlainObject(failure) &&
20
- typeof failure.failedAt === "number" &&
21
- Date.now() - failure.failedAt < CLONE_FAILURE_TTL_MS);
22
- }
23
- async cache(repoRoot, directory, reason, scope) {
24
- await this.update(async () => {
25
- const state = await this.readState();
26
- const syncDirs = state.syncDirs ?? {};
27
- const repoState = syncDirs[repoRoot] ?? {};
28
- const key = failureKey(directory, scope);
29
- syncDirs[repoRoot] = {
30
- ...repoState,
31
- [key]: { failedAt: Date.now(), reason },
32
- };
33
- await this.writeState({ ...state, syncDirs });
34
- });
35
- }
36
- async clear(repoRoot, directory, scope) {
37
- await this.update(async () => {
38
- const state = await this.readState();
39
- const repoState = state.syncDirs?.[repoRoot];
40
- const key = failureKey(directory, scope);
41
- if (!repoState || !Object.hasOwn(repoState, key))
42
- return;
43
- const nextRepoState = { ...repoState };
44
- delete nextRepoState[key];
45
- const syncDirs = { ...state.syncDirs };
46
- if (Object.keys(nextRepoState).length === 0)
47
- delete syncDirs[repoRoot];
48
- else
49
- syncDirs[repoRoot] = nextRepoState;
50
- await this.writeState({ ...state, syncDirs });
51
- });
52
- }
53
- async update(operation) {
54
- const next = this.updateQueue.then(() => this.withStateLock(operation), () => this.withStateLock(operation));
55
- this.updateQueue = next.then(() => undefined, () => undefined);
56
- await next;
57
- }
58
- async withStateLock(operation) {
59
- const lockPath = `${this.stateFilePath()}${STATE_LOCK_SUFFIX}`;
60
- const lockToken = await acquireStateLock(lockPath);
61
- if (lockToken === undefined)
62
- return;
63
- const stopHeartbeat = startStateLockHeartbeat(lockPath, lockToken);
64
- try {
65
- await operation();
66
- }
67
- finally {
68
- stopHeartbeat();
69
- await releaseStateLock(lockPath, lockToken);
70
- }
71
- }
72
- async readState() {
73
- try {
74
- const raw = await readFile(this.stateFilePath(), "utf8");
75
- const parsed = JSON.parse(raw);
76
- if (!isPlainObject(parsed))
77
- return {};
78
- return isPlainObject(parsed.syncDirs)
79
- ? parsed
80
- : { ...parsed, syncDirs: {} };
81
- }
82
- catch {
83
- return {};
84
- }
85
- }
86
- async writeState(state) {
87
- try {
88
- const path = this.stateFilePath();
89
- const directory = dirname(path);
90
- await mkdir(directory, { recursive: true });
91
- const temporaryDirectory = await mkdtemp(join(directory, `.gji-state-${randomUUID()}-`));
92
- const temporaryPath = join(temporaryDirectory, STATE_FILE_NAME);
93
- try {
94
- await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
95
- await rename(temporaryPath, path);
96
- }
97
- finally {
98
- await rm(temporaryDirectory, { force: true, recursive: true });
99
- }
100
- }
101
- catch {
102
- // The cache is advisory and must never block worktree creation.
103
- }
104
- }
105
- stateFilePath(home = homedir()) {
106
- const configuredDirectory = process.env.GJI_CONFIG_DIR;
107
- const directory = configuredDirectory
108
- ? resolve(configuredDirectory)
109
- : join(home, GLOBAL_CONFIG_DIRECTORY);
110
- return join(directory, STATE_FILE_NAME);
111
- }
112
- }
113
- async function acquireStateLock(lockPath) {
114
- const lockToken = randomUUID();
115
- await mkdir(dirname(lockPath), { recursive: true }).catch(() => undefined);
116
- for (let attempt = 0; attempt < 200; attempt += 1) {
117
- try {
118
- await mkdir(lockPath);
119
- }
120
- catch (error) {
121
- if (!isErrorCode(error, "EEXIST"))
122
- return undefined;
123
- try {
124
- const freshnessPath = await lockFreshnessPath(lockPath);
125
- const lockStats = await stat(freshnessPath);
126
- if (Date.now() - lockStats.mtimeMs >= STATE_LOCK_TTL_MS) {
127
- const stalePath = `${lockPath}.stale-${randomUUID()}`;
128
- try {
129
- await rename(lockPath, stalePath);
130
- }
131
- catch (renameError) {
132
- if (isErrorCode(renameError, "ENOENT"))
133
- continue;
134
- return undefined;
135
- }
136
- await rm(stalePath, { force: true, recursive: true });
137
- continue;
138
- }
139
- }
140
- catch (lockError) {
141
- if (!isErrorCode(lockError, "ENOENT"))
142
- return undefined;
143
- }
144
- await new Promise((resolve) => setTimeout(resolve, 25));
145
- continue;
146
- }
147
- try {
148
- await writeFile(join(lockPath, ownerFileName(lockToken)), `${lockToken}\n`, {
149
- flag: "wx",
150
- });
151
- return lockToken;
152
- }
153
- catch {
154
- await rm(lockPath, { force: true, recursive: true }).catch(() => undefined);
155
- return undefined;
156
- }
157
- }
158
- return undefined;
159
- }
160
- async function lockFreshnessPath(lockPath) {
161
- try {
162
- const owner = (await readdir(lockPath)).find((entry) => entry.startsWith("owner-"));
163
- return owner ? join(lockPath, owner) : lockPath;
164
- }
165
- catch (error) {
166
- if (isErrorCode(error, "ENOENT"))
167
- throw error;
168
- return lockPath;
169
- }
170
- }
171
- function startStateLockHeartbeat(lockPath, lockToken) {
172
- const timer = setInterval(() => {
173
- void refreshStateLock(lockPath, lockToken);
174
- }, STATE_LOCK_TTL_MS / 3);
175
- timer.unref?.();
176
- return () => clearInterval(timer);
177
- }
178
- async function refreshStateLock(lockPath, lockToken) {
179
- try {
180
- const ownerPath = join(lockPath, ownerFileName(lockToken));
181
- await readFile(ownerPath, "utf8");
182
- const now = new Date();
183
- await utimes(ownerPath, now, now);
184
- }
185
- catch {
186
- // The cache is advisory; a failed heartbeat must not block worktree creation.
187
- }
188
- }
189
- async function releaseStateLock(lockPath, lockToken) {
190
- try {
191
- const ownerPath = join(lockPath, ownerFileName(lockToken));
192
- await unlink(ownerPath);
193
- await rmdir(lockPath);
194
- }
195
- catch (error) {
196
- if (!isErrorCode(error, "ENOENT") && !isErrorCode(error, "ENOTEMPTY")) {
197
- return;
198
- }
199
- }
200
- }
201
- function ownerFileName(lockToken) {
202
- return `owner-${lockToken}`;
203
- }
204
- export const defaultCloneFailureStore = new FileCloneFailureStore();
205
- export async function cloneFailureScope(source, destination) {
206
- const sourcePath = resolve(source);
207
- const destinationParent = resolve(dirname(destination));
208
- const [sourceDevice, destinationDevice] = await Promise.all([
209
- readDevice(sourcePath),
210
- readDevice(destinationParent),
211
- ]);
212
- return JSON.stringify([sourcePath, sourceDevice, destinationDevice]);
213
- }
214
- async function readDevice(path) {
215
- try {
216
- return (await stat(path)).dev;
217
- }
218
- catch {
219
- return undefined;
220
- }
221
- }
222
- function failureKey(directory, scope) {
223
- return scope === undefined ? directory : JSON.stringify([scope, directory]);
224
- }
225
- function isPlainObject(value) {
226
- return typeof value === "object" && value !== null && !Array.isArray(value);
227
- }
228
- function isErrorCode(error, code) {
229
- return error instanceof Error && "code" in error && error.code === code;
230
- }