@solaqua/gji 0.14.0 → 0.16.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 (67) hide show
  1. package/README.md +63 -120
  2. package/dist/cli/commands/back.d.ts +1 -1
  3. package/dist/cli/commands/back.js +1 -0
  4. package/dist/cli/commands/clean.d.ts +2 -1
  5. package/dist/cli/commands/clean.js +66 -18
  6. package/dist/cli/commands/done.js +3 -2
  7. package/dist/cli/commands/go.js +18 -5
  8. package/dist/cli/commands/init.js +0 -14
  9. package/dist/cli/commands/new.d.ts +2 -1
  10. package/dist/cli/commands/new.js +71 -63
  11. package/dist/cli/commands/open.js +1 -1
  12. package/dist/cli/commands/pr-open.js +2 -2
  13. package/dist/cli/commands/pr.js +17 -6
  14. package/dist/cli/commands/run-hook.js +4 -0
  15. package/dist/cli/commands/undo.js +10 -74
  16. package/dist/cli/dependencies.d.ts +5 -1
  17. package/dist/cli/dependencies.js +7 -2
  18. package/dist/cli/program.js +6 -56
  19. package/dist/cli/runtime/headless.d.ts +3 -3
  20. package/dist/cli/runtime/headless.js +6 -4
  21. package/dist/domain/worktree/path-filter.d.ts +13 -0
  22. package/dist/domain/worktree/path-filter.js +72 -0
  23. package/dist/domain/worktree/policy.d.ts +1 -0
  24. package/dist/domain/worktree/policy.js +1 -1
  25. package/dist/gji-bundle.mjs +738 -969
  26. package/dist/infrastructure/filesystem/locked-file.d.ts +4 -0
  27. package/dist/infrastructure/filesystem/locked-file.js +53 -0
  28. package/dist/infrastructure/git/runner.d.ts +7 -0
  29. package/dist/infrastructure/git/runner.js +35 -1
  30. package/dist/infrastructure/persistence/history.js +12 -10
  31. package/dist/infrastructure/persistence/slots.js +24 -21
  32. package/dist/infrastructure/process/hooks.js +2 -0
  33. package/dist/infrastructure/repository/registry.js +11 -41
  34. package/dist/infrastructure/worktree/lifecycle.d.ts +25 -0
  35. package/dist/infrastructure/worktree/lifecycle.js +120 -19
  36. package/dist/ports/git.d.ts +1 -0
  37. package/dist/ports/hooks.d.ts +7 -0
  38. package/dist/presentation/shell/completion.js +14 -32
  39. package/dist/presentation/terminal/context-card.js +1 -1
  40. package/man/man1/gji-back.1 +1 -1
  41. package/man/man1/gji-clean.1 +4 -1
  42. package/man/man1/gji-completion.1 +1 -1
  43. package/man/man1/gji-config.1 +1 -1
  44. package/man/man1/gji-doctor.1 +1 -1
  45. package/man/man1/gji-done.1 +1 -1
  46. package/man/man1/gji-go.1 +1 -1
  47. package/man/man1/gji-history.1 +1 -1
  48. package/man/man1/gji-init.1 +1 -1
  49. package/man/man1/gji-ls.1 +1 -1
  50. package/man/man1/gji-new.1 +1 -1
  51. package/man/man1/gji-open.1 +1 -1
  52. package/man/man1/gji-pr.1 +1 -1
  53. package/man/man1/gji-root.1 +1 -1
  54. package/man/man1/gji-run-hook.1 +3 -3
  55. package/man/man1/gji-status.1 +1 -1
  56. package/man/man1/gji-sync-files.1 +1 -1
  57. package/man/man1/gji-sync.1 +1 -1
  58. package/man/man1/gji-task.1 +1 -1
  59. package/man/man1/gji-undo.1 +1 -1
  60. package/man/man1/gji.1 +2 -12
  61. package/package.json +3 -2
  62. package/dist/cli/commands/remove.d.ts +0 -21
  63. package/dist/cli/commands/remove.js +0 -189
  64. package/dist/cli/commands/warp.d.ts +0 -28
  65. package/dist/cli/commands/warp.js +0 -123
  66. package/man/man1/gji-remove.1 +0 -19
  67. package/man/man1/gji-warp.1 +0 -16
@@ -0,0 +1,4 @@
1
+ /** Serializes read-modify-write cycles on `path` across gji processes. */
2
+ export declare function withFileLock<T>(path: string, operation: () => Promise<T>): Promise<T>;
3
+ /** Replaces `path` in one step so concurrent readers never see a partial file. */
4
+ export declare function writeJsonAtomic(path: string, value: unknown): Promise<void>;
@@ -0,0 +1,53 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, open, rename, rm, stat, unlink, writeFile, } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { isAlreadyExistsError } from "./fs-utils.js";
6
+ const LOCK_STALE_AFTER_MS = 10_000;
7
+ const LOCK_RETRY_DELAY_MS = 5;
8
+ const LOCK_WAIT_TIMEOUT_MS = LOCK_STALE_AFTER_MS + LOCK_RETRY_DELAY_MS;
9
+ /** Serializes read-modify-write cycles on `path` across gji processes. */
10
+ export async function withFileLock(path, operation) {
11
+ await mkdir(dirname(path), { recursive: true });
12
+ const release = await acquireFileLock(path);
13
+ try {
14
+ return await operation();
15
+ }
16
+ finally {
17
+ await release();
18
+ }
19
+ }
20
+ /** Replaces `path` in one step so concurrent readers never see a partial file. */
21
+ export async function writeJsonAtomic(path, value) {
22
+ const temporaryPath = `${path}.tmp-${process.pid}-${randomUUID()}`;
23
+ try {
24
+ await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
25
+ await rename(temporaryPath, path);
26
+ }
27
+ catch (error) {
28
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
29
+ throw error;
30
+ }
31
+ }
32
+ async function acquireFileLock(path) {
33
+ const lockPath = `${path}.lock`;
34
+ const deadline = Date.now() + LOCK_WAIT_TIMEOUT_MS;
35
+ while (Date.now() < deadline) {
36
+ try {
37
+ const handle = await open(lockPath, "wx");
38
+ return async () => {
39
+ await handle.close();
40
+ await unlink(lockPath).catch(() => undefined);
41
+ };
42
+ }
43
+ catch (error) {
44
+ if (!isAlreadyExistsError(error))
45
+ throw error;
46
+ const lockStat = await stat(lockPath).catch(() => null);
47
+ if (lockStat && Date.now() - lockStat.mtimeMs > LOCK_STALE_AFTER_MS)
48
+ await unlink(lockPath).catch(() => undefined);
49
+ await delay(LOCK_RETRY_DELAY_MS);
50
+ }
51
+ }
52
+ throw new Error(`timed out waiting for the lock on ${path}`);
53
+ }
@@ -1,2 +1,9 @@
1
+ export declare const GIT_ENV: {
2
+ LC_ALL: string;
3
+ };
1
4
  export declare function runGit(cwd: string, args: string[]): Promise<string>;
2
5
  export declare function runGitRaw(cwd: string, args: string[]): Promise<string>;
6
+ /** Runs a Git command that can lose a race with another Git process. */
7
+ export declare function runGitRetryingContention(cwd: string, args: string[]): Promise<string>;
8
+ /** Retries `operation`, with jittered backoff, while Git reports contention with another process. */
9
+ export declare function retryGitContention<T>(operation: () => Promise<T>, timeoutMs?: number): Promise<T>;
@@ -1,12 +1,39 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  const execFileAsync = promisify(execFile);
4
+ const CONTENTION_RETRY_TIMEOUT_MS = 10_000;
5
+ const CONTENTION_RETRY_MAX_DELAY_MS = 1_000;
6
+ // Force English output so error message string matching is locale-independent.
7
+ export const GIT_ENV = { ...process.env, LC_ALL: "C" };
4
8
  export async function runGit(cwd, args) {
5
9
  return (await runGitRaw(cwd, args)).trim();
6
10
  }
7
11
  export async function runGitRaw(cwd, args) {
12
+ return execGit(cwd, args);
13
+ }
14
+ /** Runs a Git command that can lose a race with another Git process. */
15
+ export async function runGitRetryingContention(cwd, args) {
16
+ return (await retryGitContention(() => execGit(cwd, args, GIT_ENV))).trim();
17
+ }
18
+ /** Retries `operation`, with jittered backoff, while Git reports contention with another process. */
19
+ export async function retryGitContention(operation, timeoutMs = CONTENTION_RETRY_TIMEOUT_MS) {
20
+ const deadline = Date.now() + timeoutMs;
21
+ for (let delayMs = 25;; delayMs *= 2) {
22
+ try {
23
+ return await operation();
24
+ }
25
+ catch (error) {
26
+ if (!isTransientGitError(error) || Date.now() >= deadline)
27
+ throw error;
28
+ }
29
+ const jitteredDelayMs = Math.min(delayMs, CONTENTION_RETRY_MAX_DELAY_MS) *
30
+ (0.5 + Math.random() / 2);
31
+ await new Promise((resolve) => setTimeout(resolve, jitteredDelayMs));
32
+ }
33
+ }
34
+ async function execGit(cwd, args, env) {
8
35
  try {
9
- const { stdout } = await execFileAsync("git", args, { cwd });
36
+ const { stdout } = await execFileAsync("git", args, { cwd, env });
10
37
  return stdout;
11
38
  }
12
39
  catch (error) {
@@ -14,3 +41,10 @@ export async function runGitRaw(cwd, args) {
14
41
  throw new Error(`Git command failed in '${cwd}': ${message}`);
15
42
  }
16
43
  }
44
+ // Only errors that another process can clear: a held lock, or a linked worktree
45
+ // entry that another process is still creating or removing. Git uses the
46
+ // same "cannot lock" wording for permanent failures such as a conflicting branch name.
47
+ function isTransientGitError(error) {
48
+ return (error instanceof Error &&
49
+ /(could not lock config file .*|Unable to create '.*\.lock'): File exists|cannot lock ref .*: is at .* but expected |failed to read .*\/commondir|Invalid path '.*\/worktrees\//.test(error.message));
50
+ }
@@ -1,6 +1,7 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { readFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join, resolve } from "node:path";
3
+ import { join, resolve } from "node:path";
4
+ import { withFileLock, writeJsonAtomic } from "../filesystem/locked-file.js";
4
5
  import { GLOBAL_CONFIG_DIRECTORY } from "./config.js";
5
6
  export const HISTORY_FILE_NAME = "history.json";
6
7
  const MAX_HISTORY_ENTRIES = 50;
@@ -26,14 +27,15 @@ export async function loadHistory(home = homedir()) {
26
27
  }
27
28
  export async function appendHistory(path, branch, home = homedir()) {
28
29
  const historyPath = HISTORY_FILE_PATH(home);
29
- const existing = await loadHistory(home);
30
- const entry = { branch, path, timestamp: Date.now() };
31
- const next = [
32
- entry,
33
- ...existing.filter((existingEntry) => existingEntry.path !== path),
34
- ].slice(0, MAX_HISTORY_ENTRIES);
35
- await mkdir(dirname(historyPath), { recursive: true });
36
- await writeFile(historyPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
30
+ await withFileLock(historyPath, async () => {
31
+ const existing = await loadHistory(home);
32
+ const entry = { branch, path, timestamp: Date.now() };
33
+ const next = [
34
+ entry,
35
+ ...existing.filter((existingEntry) => existingEntry.path !== path),
36
+ ].slice(0, MAX_HISTORY_ENTRIES);
37
+ await writeJsonAtomic(historyPath, next);
38
+ });
37
39
  }
38
40
  export async function recordWorktreeUsage(path, branch, home = homedir()) {
39
41
  try {
@@ -1,6 +1,7 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { readFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join, resolve } from "node:path";
3
+ import { join, resolve } from "node:path";
4
+ import { withFileLock, writeJsonAtomic } from "../filesystem/locked-file.js";
4
5
  import { GLOBAL_CONFIG_DIRECTORY } from "./config.js";
5
6
  export function SLOTS_FILE_PATH(home = homedir()) {
6
7
  const configDir = process.env.GJI_CONFIG_DIR;
@@ -20,33 +21,35 @@ export async function loadSlots(home = homedir()) {
20
21
  }
21
22
  }
22
23
  export async function assignWorktreeSlot(repoRoot, worktreePath, home = homedir()) {
23
- const slots = await loadSlots(home);
24
- const existing = slots[worktreePath];
25
- if (existing !== undefined)
26
- return existing;
27
- if (worktreePath !== repoRoot && slots[repoRoot] === undefined)
28
- slots[repoRoot] = 0;
29
- const used = new Set(Object.values(slots));
30
- const slot = worktreePath === repoRoot ? 0 : nextAvailableSlot(used);
31
- slots[worktreePath] = slot;
32
- await saveSlots(slots, home);
33
- return slot;
24
+ return withFileLock(SLOTS_FILE_PATH(home), async () => {
25
+ const slots = await loadSlots(home);
26
+ const existing = slots[worktreePath];
27
+ if (existing !== undefined)
28
+ return existing;
29
+ if (worktreePath !== repoRoot && slots[repoRoot] === undefined)
30
+ slots[repoRoot] = 0;
31
+ const used = new Set(Object.values(slots));
32
+ const slot = worktreePath === repoRoot ? 0 : nextAvailableSlot(used);
33
+ slots[worktreePath] = slot;
34
+ await saveSlots(slots, home);
35
+ return slot;
36
+ });
34
37
  }
35
38
  export async function getWorktreeSlot(worktreePath, home = homedir()) {
36
39
  const slot = (await loadSlots(home))[worktreePath];
37
40
  return slot === undefined ? null : slot;
38
41
  }
39
42
  export async function releaseWorktreeSlot(worktreePath, home = homedir()) {
40
- const slots = await loadSlots(home);
41
- if (!(worktreePath in slots))
42
- return;
43
- delete slots[worktreePath];
44
- await saveSlots(slots, home);
43
+ await withFileLock(SLOTS_FILE_PATH(home), async () => {
44
+ const slots = await loadSlots(home);
45
+ if (!(worktreePath in slots))
46
+ return;
47
+ delete slots[worktreePath];
48
+ await saveSlots(slots, home);
49
+ });
45
50
  }
46
51
  async function saveSlots(slots, home) {
47
- const path = SLOTS_FILE_PATH(home);
48
- await mkdir(dirname(path), { recursive: true });
49
- await writeFile(path, `${JSON.stringify(slots, null, 2)}\n`, "utf8");
52
+ await writeJsonAtomic(SLOTS_FILE_PATH(home), slots);
50
53
  }
51
54
  function nextAvailableSlot(used) {
52
55
  let slot = 1;
@@ -96,8 +96,10 @@ export function extractHooks(config) {
96
96
  }
97
97
  const hooks = raw;
98
98
  return {
99
+ "after-branch-create": parseHookCommand(hooks["after-branch-create"] ?? hooks.afterBranchCreate),
99
100
  "after-create": parseHookCommand(hooks["after-create"] ?? hooks.afterCreate),
100
101
  "after-enter": parseHookCommand(hooks["after-enter"] ?? hooks.afterEnter),
102
+ "after-switch": parseHookCommand(hooks["after-switch"] ?? hooks.afterSwitch),
101
103
  "before-remove": parseHookCommand(hooks["before-remove"] ?? hooks.beforeRemove),
102
104
  };
103
105
  }
@@ -1,12 +1,11 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { constants } from "node:fs";
3
- import { access, mkdir, readFile, realpath, rename, rm, writeFile, } from "node:fs/promises";
2
+ import { access, readFile, realpath } from "node:fs/promises";
4
3
  import { homedir } from "node:os";
5
- import { basename, dirname, join, resolve } from "node:path";
4
+ import { basename, join, resolve } from "node:path";
5
+ import { withFileLock, writeJsonAtomic } from "../filesystem/locked-file.js";
6
6
  import { GLOBAL_CONFIG_DIRECTORY } from "../persistence/config.js";
7
7
  const REGISTRY_FILE_NAME = "repos.json";
8
8
  const MAX_REGISTRY_ENTRIES = 100;
9
- const registryLocks = new Map();
10
9
  export function REGISTRY_FILE_PATH(home = homedir()) {
11
10
  const configDir = process.env.GJI_CONFIG_DIR;
12
11
  if (configDir) {
@@ -37,10 +36,12 @@ async function canonicalizeRepoPath(repoPath) {
37
36
  }
38
37
  export async function registerRepo(repoPath, home = homedir()) {
39
38
  const registryPath = REGISTRY_FILE_PATH(home);
40
- await withRegistryLock(registryPath, async () => {
39
+ const canonicalRepoPath = await canonicalizeRepoPath(repoPath);
40
+ // Skip the lock and the write if this repo is already the most-recently-used entry (common case).
41
+ if ((await loadRegistry(home))[0]?.path === canonicalRepoPath)
42
+ return;
43
+ await withFileLock(registryPath, async () => {
41
44
  const existing = await normalizeRegistryForWrite(await loadRegistry(home));
42
- const canonicalRepoPath = await canonicalizeRepoPath(repoPath);
43
- // Skip write if this repo is already the most-recently-used entry (common case).
44
45
  if (existing.length > 0 && existing[0].path === canonicalRepoPath)
45
46
  return;
46
47
  const entry = {
@@ -50,14 +51,14 @@ export async function registerRepo(repoPath, home = homedir()) {
50
51
  };
51
52
  const filtered = existing.filter((e) => e.path !== canonicalRepoPath);
52
53
  const next = [entry, ...filtered].slice(0, MAX_REGISTRY_ENTRIES);
53
- await writeRegistry(next, registryPath);
54
+ await writeJsonAtomic(registryPath, next);
54
55
  });
55
56
  }
56
57
  export async function removeMissingRegistryEntries(paths, home = homedir()) {
57
58
  if (paths.size === 0)
58
59
  return { removedPaths: [], skippedPaths: [] };
59
60
  const registryPath = REGISTRY_FILE_PATH(home);
60
- return withRegistryLock(registryPath, async () => {
61
+ return withFileLock(registryPath, async () => {
61
62
  const entries = await loadRegistry(home);
62
63
  const candidates = entries.filter((entry) => paths.has(entry.path));
63
64
  const status = await Promise.all(candidates.map(async (entry) => ({
@@ -71,30 +72,11 @@ export async function removeMissingRegistryEntries(paths, home = homedir()) {
71
72
  .filter(({ missing }) => !missing)
72
73
  .map(({ entry }) => entry.path);
73
74
  if (removedPaths.length > 0) {
74
- await writeRegistry(entries.filter((entry) => !removedPaths.includes(entry.path)), registryPath);
75
+ await writeJsonAtomic(registryPath, entries.filter((entry) => !removedPaths.includes(entry.path)));
75
76
  }
76
77
  return { removedPaths, skippedPaths };
77
78
  });
78
79
  }
79
- async function withRegistryLock(registryPath, operation) {
80
- const previous = registryLocks.get(registryPath);
81
- let release;
82
- const current = new Promise((resolve) => {
83
- release = resolve;
84
- });
85
- registryLocks.set(registryPath, current);
86
- if (previous)
87
- await previous;
88
- try {
89
- return await operation();
90
- }
91
- finally {
92
- release();
93
- if (registryLocks.get(registryPath) === current) {
94
- registryLocks.delete(registryPath);
95
- }
96
- }
97
- }
98
80
  async function isMissingPath(path) {
99
81
  try {
100
82
  await access(path, constants.F_OK);
@@ -110,18 +92,6 @@ function isMissingPathError(error) {
110
92
  const code = error.code;
111
93
  return code === "ENOENT" || code === "ENOTDIR";
112
94
  }
113
- async function writeRegistry(entries, registryPath) {
114
- await mkdir(dirname(registryPath), { recursive: true });
115
- const temporaryPath = `${registryPath}.tmp-${process.pid}-${randomUUID()}`;
116
- try {
117
- await writeFile(temporaryPath, `${JSON.stringify(entries, null, 2)}\n`, "utf8");
118
- await rename(temporaryPath, registryPath);
119
- }
120
- catch (error) {
121
- await rm(temporaryPath, { force: true }).catch(() => undefined);
122
- throw error;
123
- }
124
- }
125
95
  async function normalizeRegistryForWrite(entries) {
126
96
  const normalized = [];
127
97
  const seenPaths = new Set();
@@ -1,3 +1,28 @@
1
+ export interface NewBranchOptions {
2
+ startPoint?: string;
3
+ track?: boolean;
4
+ }
5
+ export type WorktreeTarget = {
6
+ branch: string;
7
+ } | {
8
+ branch: string;
9
+ create: NewBranchOptions;
10
+ } | {
11
+ detachedAt: string | undefined;
12
+ };
13
+ /**
14
+ * Adds a worktree on a path no other process has claimed, leaving nothing it
15
+ * created behind on failure.
16
+ *
17
+ * A new branch is created first because that is the only part of `git worktree add -b`
18
+ * that writes the shared .git/config. Retrying it stays cheap and lets checkouts run in parallel.
19
+ */
20
+ export declare function addWorktree(repoRoot: string, worktreePath: string, target: WorktreeTarget): Promise<void>;
21
+ /** Best-effort rollback of a worktree, and of the branch if this run created it. */
22
+ export declare function removeCreatedWorktree(repoRoot: string, worktreePath: string, createdBranch?: string): Promise<void>;
23
+ export declare function createBranch(repoRoot: string, branch: string, options?: NewBranchOptions): Promise<void>;
24
+ /** Best-effort rollback. Git refuses to delete a branch another worktree has checked out. */
25
+ export declare function deleteCreatedBranch(repoRoot: string, branch: string): Promise<void>;
1
26
  export declare function removeWorktree(repoRoot: string, worktreePath: string): Promise<void>;
2
27
  export declare function forceRemoveWorktree(repoRoot: string, worktreePath: string): Promise<void>;
3
28
  export declare function deleteBranch(repoRoot: string, branch: string): Promise<void>;
@@ -1,15 +1,113 @@
1
1
  import { execFile } from "node:child_process";
2
- import { rm } from "node:fs/promises";
2
+ import { mkdir, rm, rmdir } from "node:fs/promises";
3
3
  import { promisify } from "node:util";
4
+ import { isAlreadyExistsError } from "../filesystem/fs-utils.js";
5
+ import { GIT_ENV, retryGitContention } from "../git/runner.js";
4
6
  import { listWorktrees } from "../repository/worktrees.js";
5
7
  const execFileAsync = promisify(execFile);
6
- // Force English output so error message string matching is locale-independent.
7
- const GIT_ENV = { ...process.env, LC_ALL: "C" };
8
+ const BRANCH_CONFIG_CLEANUP_TIMEOUT_MS = 1_000;
9
+ /**
10
+ * Adds a worktree on a path no other process has claimed, leaving nothing it
11
+ * created behind on failure.
12
+ *
13
+ * A new branch is created first because that is the only part of `git worktree add -b`
14
+ * that writes the shared .git/config. Retrying it stays cheap and lets checkouts run in parallel.
15
+ */
16
+ export async function addWorktree(repoRoot, worktreePath, target) {
17
+ await claimWorktreePath(worktreePath);
18
+ if ("create" in target) {
19
+ try {
20
+ await createBranch(repoRoot, target.branch, target.create);
21
+ }
22
+ catch (error) {
23
+ await rmdir(worktreePath).catch(() => undefined);
24
+ throw error;
25
+ }
26
+ }
27
+ const args = "branch" in target
28
+ ? [target.branch]
29
+ : ["--detach", ...(target.detachedAt ? [target.detachedAt] : [])];
30
+ try {
31
+ // No forced locale: this runs the user's hooks. Contention errors in another
32
+ // language are not retried, and fall through to the rollback below.
33
+ await retryGitContention(() => execFileAsync("git", ["worktree", "add", worktreePath, ...args], {
34
+ cwd: repoRoot,
35
+ }));
36
+ }
37
+ catch (error) {
38
+ // Git can fail after registering the worktree, for example in a post-checkout hook.
39
+ await removeCreatedWorktree(repoRoot, worktreePath, "create" in target ? target.branch : undefined);
40
+ throw error;
41
+ }
42
+ }
43
+ /** Best-effort rollback of a worktree, and of the branch if this run created it. */
44
+ export async function removeCreatedWorktree(repoRoot, worktreePath, createdBranch) {
45
+ await runLifecycleGit(repoRoot, [
46
+ "worktree",
47
+ "remove",
48
+ "--force",
49
+ worktreePath,
50
+ ]).catch(() => undefined);
51
+ await rmdir(worktreePath).catch(() => undefined);
52
+ if (createdBranch)
53
+ await deleteCreatedBranch(repoRoot, createdBranch);
54
+ }
55
+ // Git accepts an empty target directory but does not claim it atomically, so
56
+ // without this, racing processes can both succeed on one path.
57
+ async function claimWorktreePath(worktreePath) {
58
+ try {
59
+ await mkdir(worktreePath);
60
+ }
61
+ catch (error) {
62
+ if (!isAlreadyExistsError(error))
63
+ throw error;
64
+ throw new Error(`target worktree path already exists: ${worktreePath}`);
65
+ }
66
+ }
67
+ export async function createBranch(repoRoot, branch, options = {}) {
68
+ const args = [
69
+ "branch",
70
+ ...(options.track ? ["--track"] : []),
71
+ branch,
72
+ ...(options.startPoint ? [options.startPoint] : []),
73
+ ];
74
+ try {
75
+ await retryGitContention(async () => {
76
+ try {
77
+ await execFileAsync("git", args, { cwd: repoRoot, env: GIT_ENV });
78
+ }
79
+ catch (error) {
80
+ // Git creates the ref before writing tracking config, so this failure
81
+ // leaves a branch that only this call can have created.
82
+ if (isGitConfigLockError(error))
83
+ await forceDeleteBranch(repoRoot, branch);
84
+ throw error;
85
+ }
86
+ });
87
+ }
88
+ catch (error) {
89
+ if (isGitConfigLockError(error))
90
+ await removeBranchConfig(repoRoot, branch).catch(() => undefined);
91
+ throw error;
92
+ }
93
+ }
94
+ /** Best-effort rollback. Git refuses to delete a branch another worktree has checked out. */
95
+ export async function deleteCreatedBranch(repoRoot, branch) {
96
+ try {
97
+ await forceDeleteBranch(repoRoot, branch);
98
+ await removeBranchConfig(repoRoot, branch);
99
+ }
100
+ catch {
101
+ /* rollback best effort */
102
+ }
103
+ }
104
+ // `git branch -D` only warns when it cannot update the config, and tracking
105
+ // config can be left partly written when `git branch` loses the lock midway.
106
+ async function removeBranchConfig(repoRoot, branch) {
107
+ await runLifecycleGit(repoRoot, ["config", "--remove-section", `branch.${branch}`], BRANCH_CONFIG_CLEANUP_TIMEOUT_MS);
108
+ }
8
109
  export async function removeWorktree(repoRoot, worktreePath) {
9
- await execFileAsync("git", ["worktree", "remove", worktreePath], {
10
- cwd: repoRoot,
11
- env: GIT_ENV,
12
- });
110
+ await runLifecycleGit(repoRoot, ["worktree", "remove", worktreePath]);
13
111
  }
14
112
  export async function forceRemoveWorktree(repoRoot, worktreePath) {
15
113
  try {
@@ -29,10 +127,12 @@ export async function forceRemoveWorktree(repoRoot, worktreePath) {
29
127
  }
30
128
  }
31
129
  async function removeWorktreeWithForce(repoRoot, worktreePath) {
32
- await execFileAsync("git", ["worktree", "remove", "--force", worktreePath], {
33
- cwd: repoRoot,
34
- env: GIT_ENV,
35
- });
130
+ await runLifecycleGit(repoRoot, [
131
+ "worktree",
132
+ "remove",
133
+ "--force",
134
+ worktreePath,
135
+ ]);
36
136
  }
37
137
  async function deinitializeSubmodules(worktreePath) {
38
138
  await execFileAsync("git", ["submodule", "deinit", "--force", "--all"], {
@@ -41,16 +141,10 @@ async function deinitializeSubmodules(worktreePath) {
41
141
  });
42
142
  }
43
143
  export async function deleteBranch(repoRoot, branch) {
44
- await execFileAsync("git", ["branch", "-d", branch], {
45
- cwd: repoRoot,
46
- env: GIT_ENV,
47
- });
144
+ await runLifecycleGit(repoRoot, ["branch", "-d", branch]);
48
145
  }
49
146
  export async function forceDeleteBranch(repoRoot, branch) {
50
- await execFileAsync("git", ["branch", "-D", branch], {
51
- cwd: repoRoot,
52
- env: GIT_ENV,
53
- });
147
+ await runLifecycleGit(repoRoot, ["branch", "-D", branch]);
54
148
  }
55
149
  export function isWorktreeForceRemovalError(error) {
56
150
  if (!hasStderr(error)) {
@@ -75,3 +169,10 @@ function hasStderr(error) {
75
169
  "stderr" in error &&
76
170
  typeof error.stderr === "string");
77
171
  }
172
+ async function runLifecycleGit(repoRoot, args, timeoutMs) {
173
+ await retryGitContention(() => execFileAsync("git", args, { cwd: repoRoot, env: GIT_ENV }), timeoutMs);
174
+ }
175
+ function isGitConfigLockError(error) {
176
+ return (error instanceof Error &&
177
+ error.message.includes("could not lock config file"));
178
+ }
@@ -5,4 +5,5 @@ export interface RemoteBase {
5
5
  export interface GitCommandPort {
6
6
  runGit(cwd: string, args: string[]): Promise<string>;
7
7
  runGitRaw(cwd: string, args: string[]): Promise<string>;
8
+ runGitRetryingContention(cwd: string, args: string[]): Promise<string>;
8
9
  }
@@ -1,7 +1,14 @@
1
1
  export type GjiHookCommand = string | string[];
2
2
  export interface GjiHooks {
3
+ /** A new branch was created by `gji new`, in a new worktree or in place. */
4
+ "after-branch-create"?: GjiHookCommand;
5
+ /** A linked worktree was created and bootstrapped. */
3
6
  "after-create"?: GjiHookCommand;
7
+ /** An existing worktree was entered. */
4
8
  "after-enter"?: GjiHookCommand;
9
+ /** The current worktree switched to another branch in place. */
10
+ "after-switch"?: GjiHookCommand;
11
+ /** A linked worktree is about to be removed. */
5
12
  "before-remove"?: GjiHookCommand;
6
13
  }
7
14
  export interface HookContext {
@@ -37,23 +37,20 @@ const TOP_LEVEL_COMMANDS = [
37
37
  },
38
38
  { name: "ls", description: "list active worktrees" },
39
39
  { name: "clean", description: "interactively prune linked worktrees" },
40
- {
41
- name: "remove",
42
- description: "deprecated: use done for one worktree or clean for bulk cleanup",
43
- },
44
- { name: "rm", description: "deprecated alias of remove" },
45
40
  {
46
41
  name: "run-hook",
47
42
  description: "run a named hook in the current worktree",
48
43
  },
49
- {
50
- name: "warp",
51
- description: "deprecated: use go (press Tab for all known repos)",
52
- },
53
44
  { name: "config", description: "manage global config defaults" },
54
45
  ];
55
46
  const SHELL_NAMES = ["bash", "fish", "zsh"];
56
- const HOOK_NAMES = ["after-create", "after-enter", "before-remove"];
47
+ const HOOK_NAMES = [
48
+ "after-branch-create",
49
+ "after-create",
50
+ "after-enter",
51
+ "after-switch",
52
+ "before-remove",
53
+ ];
57
54
  const CONFIG_KEYS = Array.from(KNOWN_GLOBAL_CONFIG_KEYS);
58
55
  export function renderShellCompletion(shell) {
59
56
  switch (shell) {
@@ -221,17 +218,15 @@ _gji_completion() {
221
218
  COMPREPLY=( $(compgen -W "--compact --json --help" -- "$cur") )
222
219
  ;;
223
220
  clean)
224
- COMPREPLY=( $(compgen -W "-f --force --stale --dry-run --json --help" -- "$cur") )
225
- ;;
226
- remove|rm)
227
- COMPREPLY=( $(compgen -W "$(__gji_worktree_branches) -f --force --dry-run --json --help" -- "$cur") )
221
+ if [ "\${COMP_WORDS[COMP_CWORD-1]}" = "--path" ]; then
222
+ COMPREPLY=( $(compgen -d -- "$cur") )
223
+ else
224
+ COMPREPLY=( $(compgen -W "-f --force --stale --path --dry-run --json --help" -- "$cur") )
225
+ fi
228
226
  ;;
229
227
  run-hook)
230
228
  COMPREPLY=( $(compgen -W "${hooks} --help" -- "$cur") )
231
229
  ;;
232
- warp)
233
- COMPREPLY=( $(compgen -W "--print --json --help" -- "$cur") )
234
- ;;
235
230
  config)
236
231
  if [ "$COMP_CWORD" -eq 2 ]; then
237
232
  COMPREPLY=( $(compgen -W "get set unset" -- "$cur") )
@@ -454,21 +449,14 @@ complete -c gji -n '__fish_seen_subcommand_from ls' -l json -d 'print active wor
454
449
 
455
450
  complete -c gji -n '__fish_seen_subcommand_from clean' -s f -l force -d 'bypass prompts, force-remove dirty worktrees, and force-delete unmerged branches'
456
451
  complete -c gji -n '__fish_seen_subcommand_from clean' -l stale -d 'only target clean worktrees whose upstream is gone and branch is merged into the default branch'
452
+ complete -c gji -n '__fish_seen_subcommand_from clean' -l path -r -F -d 'only target worktrees under a directory or matching a path glob (repeatable)'
457
453
  complete -c gji -n '__fish_seen_subcommand_from clean' -l dry-run -d 'show what would be deleted without removing anything'
458
454
  complete -c gji -n '__fish_seen_subcommand_from clean' -l json -d 'emit JSON on success or error instead of human-readable output'
459
455
 
460
- complete -c gji -n '__fish_seen_subcommand_from remove rm' -s f -l force -d 'bypass prompts, force-remove a dirty worktree, and force-delete an unmerged branch'
461
- complete -c gji -n '__fish_seen_subcommand_from remove rm' -l dry-run -d 'show what would be deleted without removing anything'
462
- complete -c gji -n '__fish_seen_subcommand_from remove rm' -l json -d 'emit JSON on success or error instead of human-readable output'
463
- complete -c gji -n '__fish_seen_subcommand_from remove rm' -a '(__gji_worktree_branches)' -d 'worktree branch'
464
-
465
456
  ${hookLines}
466
457
 
467
458
  complete -c gji -n '__fish_use_subcommand' -l json -d 'emit the repository hub as JSON'
468
459
 
469
- complete -c gji -n '__fish_seen_subcommand_from warp' -l print -d 'print the resolved worktree path without changing directory'
470
- complete -c gji -n '__fish_seen_subcommand_from warp' -l json -d 'emit JSON on success or error instead of human-readable output'
471
-
472
460
  complete -c gji -n '__fish_seen_subcommand_from config; and __gji_should_complete_config_action' -a 'get set unset' -d 'config action'
473
461
  ${configKeyLines}`;
474
462
  }
@@ -612,17 +600,11 @@ case "\${words[2]}" in
612
600
  _arguments '--compact[show only branch and path columns]' '--json[print active worktrees as JSON]'
613
601
  ;;
614
602
  clean)
615
- _arguments '(-f --force)'{-f,--force}'[bypass prompts, force-remove dirty worktrees, and force-delete unmerged branches]' '--stale[only target clean worktrees whose upstream is gone and branch is merged into the default branch]' '--dry-run[show what would be deleted without removing anything]' '--json[emit JSON on success or error instead of human-readable output]'
616
- ;;
617
- remove|rm)
618
- _arguments '(-f --force)'{-f,--force}'[bypass prompts, force-remove a dirty worktree, and force-delete an unmerged branch]' '--dry-run[show what would be deleted without removing anything]' '--json[emit JSON on success or error instead of human-readable output]' '2:branch:->worktrees'
603
+ _arguments '(-f --force)'{-f,--force}'[bypass prompts, force-remove dirty worktrees, and force-delete unmerged branches]' '--stale[only target clean worktrees whose upstream is gone and branch is merged into the default branch]' '*--path[only target worktrees under a directory or matching a path glob]:pattern:_files' '--dry-run[show what would be deleted without removing anything]' '--json[emit JSON on success or error instead of human-readable output]'
619
604
  ;;
620
605
  run-hook)
621
606
  _arguments "2:hook:(${hooks})"
622
607
  ;;
623
- warp)
624
- _arguments '--print[print the resolved worktree path without changing directory]' '--json[emit JSON on success or error instead of human-readable output]' '2:branch: '
625
- ;;
626
608
  config)
627
609
  if (( CURRENT == 3 )); then
628
610
  _values 'config action' get set unset
@@ -12,5 +12,5 @@ function formatState(info) {
12
12
  return `${info.status}${info.upstream.kind === "tracked" ? ` · ↑${info.upstream.ahead} ↓${info.upstream.behind}` : ""}`;
13
13
  }
14
14
  function formatLastCommit(info) {
15
- return new Date(info.lastCommitTimestamp).toISOString();
15
+ return new Date(info.lastCommitTimestamp * 1000).toISOString();
16
16
  }