@solaqua/gji 0.15.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 (63) 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 +1 -1
  5. package/dist/cli/commands/clean.js +11 -2
  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 +1 -56
  19. package/dist/cli/runtime/headless.d.ts +3 -3
  20. package/dist/cli/runtime/headless.js +6 -4
  21. package/dist/gji-bundle.mjs +500 -842
  22. package/dist/infrastructure/filesystem/locked-file.d.ts +4 -0
  23. package/dist/infrastructure/filesystem/locked-file.js +53 -0
  24. package/dist/infrastructure/git/runner.d.ts +7 -0
  25. package/dist/infrastructure/git/runner.js +35 -1
  26. package/dist/infrastructure/persistence/history.js +12 -10
  27. package/dist/infrastructure/persistence/slots.js +24 -21
  28. package/dist/infrastructure/process/hooks.js +2 -0
  29. package/dist/infrastructure/repository/registry.js +11 -41
  30. package/dist/infrastructure/worktree/lifecycle.d.ts +25 -0
  31. package/dist/infrastructure/worktree/lifecycle.js +120 -19
  32. package/dist/ports/git.d.ts +1 -0
  33. package/dist/ports/hooks.d.ts +7 -0
  34. package/dist/presentation/shell/completion.js +7 -30
  35. package/dist/presentation/terminal/context-card.js +1 -1
  36. package/man/man1/gji-back.1 +1 -1
  37. package/man/man1/gji-clean.1 +1 -1
  38. package/man/man1/gji-completion.1 +1 -1
  39. package/man/man1/gji-config.1 +1 -1
  40. package/man/man1/gji-doctor.1 +1 -1
  41. package/man/man1/gji-done.1 +1 -1
  42. package/man/man1/gji-go.1 +1 -1
  43. package/man/man1/gji-history.1 +1 -1
  44. package/man/man1/gji-init.1 +1 -1
  45. package/man/man1/gji-ls.1 +1 -1
  46. package/man/man1/gji-new.1 +1 -1
  47. package/man/man1/gji-open.1 +1 -1
  48. package/man/man1/gji-pr.1 +1 -1
  49. package/man/man1/gji-root.1 +1 -1
  50. package/man/man1/gji-run-hook.1 +3 -3
  51. package/man/man1/gji-status.1 +1 -1
  52. package/man/man1/gji-sync-files.1 +1 -1
  53. package/man/man1/gji-sync.1 +1 -1
  54. package/man/man1/gji-task.1 +1 -1
  55. package/man/man1/gji-undo.1 +1 -1
  56. package/man/man1/gji.1 +2 -12
  57. package/package.json +3 -2
  58. package/dist/cli/commands/remove.d.ts +0 -21
  59. package/dist/cli/commands/remove.js +0 -189
  60. package/dist/cli/commands/warp.d.ts +0 -28
  61. package/dist/cli/commands/warp.js +0 -123
  62. package/man/man1/gji-remove.1 +0 -19
  63. package/man/man1/gji-warp.1 +0 -16
package/README.md CHANGED
@@ -1,157 +1,100 @@
1
- # gji — Switch Git contexts without stashing
1
+ <picture>
2
+ <source media="(prefers-color-scheme: dark)" srcset="website/static/img/logo-dark.svg" />
3
+ <img src="website/static/img/logo.svg" alt="gji branch mascot" width="72" height="68" />
4
+ </picture>
2
5
 
3
- > Start, review, and switch between Git tasks without disturbing the work already open.
6
+ # gji — Switch tasks. Keep your place.
4
7
 
5
- `gji` makes Git worktrees feel like normal shell navigation. Each task gets its
6
- own directory, dependencies, editor context, and terminal — so moving to a
7
- pull request or experiment does not require stashing, checking out, or
8
- reinstalling.
8
+ Open a new task or review a PR. Your current work stays right where you left it.
9
9
 
10
- ## Start your next task
10
+ `gji` is a Git worktree CLI for developers who keep more than one task open.
11
+ Each task gets a separate working directory and dependency installation, so
12
+ switching tasks doesn't mean packing up your changes first. Keep using your
13
+ usual Git commands, editor, and coding tools inside each worktree.
11
14
 
12
- Run `gji new` and let the interactive flow guide you into a fresh worktree:
15
+ [Get started](#get-started) · [Documentation](https://gji.solaqua.dev/docs/intro) · [Is gji a fit?](https://gji.solaqua.dev/docs/comparison)
13
16
 
14
- ```sh
15
- gji new
16
- ```
17
-
18
- Choose a task name and setup options, then continue working in the new
19
- directory. For repeatable workflows, you can still provide a branch directly:
20
-
21
- ```sh
22
- gji new feature/payment-refactor
23
- ```
24
-
25
- If you prefer to reuse the current worktree, create a branch in place and
26
- refresh the configured default branch first:
27
-
28
- ```sh
29
- gji new --branch-only feature/payment-refactor
30
- ```
31
-
32
- This creates the new branch from the latest configured remote default branch
33
- without creating another worktree or installing dependencies. The current
34
- worktree must be clean; use `--no-fetch` to create from the local default
35
- branch as-is.
36
-
37
- ## The problem
38
-
39
- You are halfway through a feature when someone asks you to review a pull
40
- request. Without worktrees, a quick review often becomes:
41
-
42
- ```text
43
- stash → checkout → reinstall → review → checkout back → stash pop
44
- ```
45
-
46
- With `gji`, the review gets its own workspace and your current task stays
47
- untouched:
17
+ <a href="https://gji.solaqua.dev/#workflow">
18
+ <picture>
19
+ <source media="(prefers-reduced-motion: reduce)" srcset=".github/assets/readme-workflow.png" />
20
+ <img src=".github/assets/readme-workflow.gif" alt="Start a new task, open a PR in a separate worktree, then return to feature/payments with the same two modified files." width="960" height="336" />
21
+ </picture>
22
+ </a>
48
23
 
49
- ```sh
50
- gji pr 1234
51
- ```
52
-
53
- <table>
54
- <tr>
55
- <td width="50%" valign="top">
56
- <strong>Before</strong><br />
57
- <img src=".github/assets/readme-before.gif" alt="Traditional branch switching with stash and reinstall steps" />
58
- </td>
59
- <td width="50%" valign="top">
60
- <strong>After</strong><br />
61
- <img src=".github/assets/readme-after.gif" alt="Creating an isolated pull request worktree with gji" />
62
- </td>
63
- </tr>
64
- </table>
65
-
66
- ## Remember four commands
67
-
68
- ```sh
69
- gji new # interactively start a task
70
- gji pr 1234 # open a pull request in isolation
71
- gji go main # jump to another worktree
72
- gji done feature/payment-refactor # finish and clean up a task
73
- ```
24
+ *Illustrated workflow. Shell integration is enabled; setup time is omitted.*
74
25
 
75
- With shell integration enabled, each command moves your shell into the
76
- selected worktree automatically.
26
+ ## Get started
77
27
 
78
- ## Install
28
+ You need **Node.js 18+**, Git with worktree support, and **zsh, bash, or fish**
29
+ for automatic directory changes.
79
30
 
80
31
  ```sh
81
32
  npm install -g @solaqua/gji
82
33
  gji init
83
34
  ```
84
35
 
85
- `gji init` interactively installs shell integration, completions, and an
86
- available editor. Restart your shell, or source the rc file shown by the
87
- wizard, then run:
36
+ Restart your shell, or load the shell configuration file shown by the wizard.
37
+ Then, inside a repository you already work in:
88
38
 
89
39
  ```sh
90
40
  gji doctor
41
+ gji new feature/try-gji
42
+ gji back
91
43
  ```
92
44
 
93
- Without shell integration, commands still print paths for scripts and other
94
- non-interactive use. See the [installation guide](https://gji.solaqua.dev/docs/installation)
95
- for explicit shell setup.
96
-
97
- ## A typical day
45
+ You just opened a separate worktree and returned to your previous one. Any
46
+ uncommitted files you left there stay there. New worktrees install dependencies
47
+ automatically when a supported lockfile is detected, so the first setup can take
48
+ time. Use `--no-install` to skip it for one task.
98
49
 
99
- ```sh
100
- # Start feature work
101
- gji new
102
-
103
- # Check what is active
104
- gji
50
+ [Installation and shell setup](https://gji.solaqua.dev/docs/installation) · [Complete the first-worktree walkthrough](https://gji.solaqua.dev/docs/quick-start)
105
51
 
106
- # Review a PR without changing the current task
107
- gji pr 1234
52
+ ## Keep several tasks open
108
53
 
109
- # Move between tasks
110
- gji go feature/payment-refactor
111
- gji back
54
+ For example, while working on `feature/payments`:
112
55
 
113
- # Keep worktrees current and remove finished work
114
- gji sync
115
- gji done feature/payment-refactor
56
+ ```sh
57
+ gji new feature/search # give another task its own directory
58
+ gji pr 1234 # open a real PR from your repository
59
+ gji go feature/payments # return to the changes you left open
116
60
  ```
117
61
 
118
- Worktrees use a predictable location by default:
62
+ Replace `1234` with a GitHub pull request in your repository. Each task stays in
63
+ its own worktree. Navigation moves your shell; it doesn't move changes between
64
+ branches or keep files synchronized.
119
65
 
120
- ```text
121
- ../worktrees/<repo>/<branch>
122
- ```
123
-
124
- That keeps editor bookmarks, scripts, and terminal navigation stable.
66
+ | When you want to… | Run |
67
+ | --- | --- |
68
+ | Start a task | `gji new` |
69
+ | Review a PR locally | `gji pr 1234` |
70
+ | Find an open task | `gji` or `gji go` |
71
+ | Return to the last worktree | `gji back` |
72
+ | Open the current worktree in an editor | `gji open` |
73
+ | Finish a linked worktree | `gji done` |
125
74
 
126
- ## More capabilities
75
+ With `gji go`, press **Tab** to browse all registered repositories. Worktrees
76
+ have predictable paths: `../worktrees/<repo>/<branch>`.
127
77
 
128
- Use the documentation when you need to go beyond the core workflow:
78
+ ## Make the next worktree ready for your project
129
79
 
130
- - [Daily workflow](https://gji.solaqua.dev/docs/daily-workflow) — navigation, PRs, sync, and cleanup
131
- - [Commands](https://gji.solaqua.dev/docs/commands) — complete command reference
132
- - [Shell integration](https://gji.solaqua.dev/docs/shell-integration) — zsh, bash, and fish setup
133
- - [Configuration](https://gji.solaqua.dev/docs/configuration) — paths, defaults, and local files
134
- - [Hooks](https://gji.solaqua.dev/docs/hooks) — automate setup and cleanup
135
- - [Sync and cleanup](https://gji.solaqua.dev/docs/sync-and-cleanup) — maintain active worktrees
136
- - [Comparison](https://gji.solaqua.dev/docs/comparison) — `gji` vs `git worktree`, `lazygit`, and `ghq`
137
- - [FAQ and troubleshooting](https://gji.solaqua.dev/docs/faq) — common questions and fixes
80
+ - [Local files and defaults](https://gji.solaqua.dev/docs/configuration) — copy `.env.local`, choose an editor, or adjust dependency setup.
81
+ - [Hooks](https://gji.solaqua.dev/docs/hooks) — run project-specific setup after the built-in dependency install.
82
+ - [Daily workflow](https://gji.solaqua.dev/docs/daily-workflow) — review, experiment, navigate, and open editors.
83
+ - [Sync and cleanup](https://gji.solaqua.dev/docs/sync-and-cleanup) — rebase active worktrees and remove finished ones. Cleanup can delete branches; undo does not recover uncommitted files.
84
+ - [Command reference](https://gji.solaqua.dev/docs/commands) — options, JSON output, and scripting.
85
+ - [Troubleshooting](https://gji.solaqua.dev/docs/troubleshooting) — recover from setup, navigation, or install problems.
138
86
 
139
- `gji` also supports JSON output for scripts and AI-assisted workflows:
87
+ ## Contributing
140
88
 
141
89
  ```sh
142
- gji --json
143
- gji new --json feature/dark-mode
144
- gji go --json feature/dark-mode
90
+ pnpm install
91
+ pnpm test
92
+ pnpm build
145
93
  ```
146
94
 
147
- Read the [full documentation](https://gji.solaqua.dev) for configuration,
148
- dependency bootstrap, hooks, automation, and the complete CLI reference.
149
-
150
- ---
151
-
152
- If `gji` has saved you from a `git stash` spiral, a ⭐ on
153
- [GitHub](https://github.com/sjquant/gji) helps other developers find it.
95
+ The CLI source is in `src/`. See [website/README.md](website/README.md) for site
96
+ and documentation development.
154
97
 
155
98
  ## License
156
99
 
157
- MIT
100
+ [MIT](LICENSE)
@@ -9,7 +9,7 @@ export interface BackCommandOptions {
9
9
  commandName?: string;
10
10
  outputEnv?: string;
11
11
  print?: boolean;
12
- runtime?: CliRuntime<"config" | "historyStore" | "hooks" | "repositoryContext">;
12
+ runtime?: CliRuntime<"config" | "historyStore" | "hooks" | "repositoryContext" | "slots">;
13
13
  stderr: (chunk: string) => void;
14
14
  stdout: (chunk: string) => void;
15
15
  }
@@ -59,6 +59,7 @@ export async function runBackCommand(options) {
59
59
  branch: target.branch ?? undefined,
60
60
  path: target.path,
61
61
  repo: basename(repository.repoRoot),
62
+ slot: await runtime.slots.getWorktreeSlot(target.path),
62
63
  }, options.stderr);
63
64
  }
64
65
  catch {
@@ -9,7 +9,7 @@ export interface CleanCommandOptions {
9
9
  json?: boolean;
10
10
  paths?: string[];
11
11
  stale?: boolean;
12
- runtime?: CliRuntime<"git" | "config" | "configStore" | "slots" | "repositoryContext" | "repositoryRegistry" | "worktrees" | "worktreeInfo" | "worktreeLifecycle" | "worktreeCatalog">;
12
+ runtime?: CliRuntime<"git" | "config" | "configStore" | "hooks" | "slots" | "repositoryContext" | "repositoryRegistry" | "worktrees" | "worktreeInfo" | "worktreeLifecycle" | "worktreeCatalog">;
13
13
  stderr: (chunk: string) => void;
14
14
  stdout: (chunk: string) => void;
15
15
  }
@@ -30,7 +30,8 @@ export function createCleanCommand(dependencies = {}) {
30
30
  const runtime = options.runtime ?? defaultCliDependencies;
31
31
  const { readWorktreeHealth, isBranchMergedInto, resolveRemoteBase, runGit, } = runtime.git;
32
32
  const { loadEffectiveConfig } = runtime.config;
33
- const { releaseWorktreeSlot } = runtime.slots;
33
+ const { getWorktreeSlot, releaseWorktreeSlot } = runtime.slots;
34
+ const { extractHooks, runHook } = runtime.hooks;
34
35
  const sourceDependencies = {
35
36
  ...runtime.repositoryContext,
36
37
  ...runtime.repositoryRegistry,
@@ -155,7 +156,8 @@ export function createCleanCommand(dependencies = {}) {
155
156
  emitError(options, message);
156
157
  }
157
158
  else {
158
- options.stderr(`gji clean: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
159
+ options.stderr(`gji clean: ${message} in non-interactive mode\n`);
160
+ options.stderr("Hint: --force removes every candidate; use --dry-run to preview them first\n");
159
161
  }
160
162
  return 1;
161
163
  }
@@ -225,6 +227,13 @@ export function createCleanCommand(dependencies = {}) {
225
227
  options.stderr(`Skipped ${worktree.path}: no longer a safe stale cleanup candidate\n`);
226
228
  continue;
227
229
  }
230
+ const hooks = extractHooks(await loadEffectiveConfig(repoRoot));
231
+ await runHook(hooks["before-remove"], worktree.path, {
232
+ branch: worktree.branch ?? undefined,
233
+ path: worktree.path,
234
+ repo: basename(repoRoot),
235
+ slot: await getWorktreeSlot(worktree.path),
236
+ }, options.stderr, options.json ? () => undefined : options.stdout);
228
237
  try {
229
238
  await removeWorktree(repoRoot, worktree.path);
230
239
  }
@@ -10,7 +10,7 @@ export async function runDoneCommand(options) {
10
10
  const { readWorktreeHealth, isBranchMergedInto, resolveRemoteDefaultBranch, runGit, } = runtime.git;
11
11
  const { loadEffectiveConfig, resolveConfigString } = runtime.config;
12
12
  const { loadHistory } = runtime.historyStore;
13
- const { releaseWorktreeSlot } = runtime.slots;
13
+ const { getWorktreeSlot, releaseWorktreeSlot } = runtime.slots;
14
14
  const { extractHooks, runHook } = runtime.hooks;
15
15
  const { detectRepository } = runtime.repositoryContext;
16
16
  const { listWorktrees } = runtime.worktrees;
@@ -69,7 +69,8 @@ export async function runDoneCommand(options) {
69
69
  branch: target.branch ?? undefined,
70
70
  path: target.path,
71
71
  repo: basename(repository.repoRoot),
72
- }, options.stderr);
72
+ slot: await getWorktreeSlot(target.path),
73
+ }, options.stderr, options.json ? () => undefined : options.stdout);
73
74
  try {
74
75
  await removeWorktree(repository.repoRoot, target.path);
75
76
  }
@@ -10,7 +10,7 @@ import { buildWorktreePromptEntries, promptForSingleWorktree, } from "../../pres
10
10
  import { defaultCliDependencies, withPullRequestQueries, } from "../dependencies.js";
11
11
  import { isHeadless } from "../runtime/headless.js";
12
12
  import { runBackCommand } from "./back.js";
13
- import { runNewCommand } from "./new.js";
13
+ import { applyConfiguredBranchPrefix, runNewCommand } from "./new.js";
14
14
  import { runPrCommand } from "./pr.js";
15
15
  const GO_OUTPUT_FILE_ENV = "GJI_GO_OUTPUT_FILE";
16
16
  export function createGoCommand(dependencies = {}) {
@@ -42,7 +42,7 @@ export function createGoCommand(dependencies = {}) {
42
42
  : [];
43
43
  if (!options.branch) {
44
44
  if (options.json || isHeadless()) {
45
- return emitError(options, "branch argument is required in non-interactive mode (GJI_NO_TUI=1)");
45
+ return emitError(options, "branch argument is required in non-interactive mode");
46
46
  }
47
47
  let skippedRegisteredRepos = 0;
48
48
  let registeredSources = null;
@@ -150,8 +150,8 @@ async function handleGoBranchResolution(options, resolution, confirmBranchCreati
150
150
  case "create":
151
151
  return createExistingBranchWorktree(options, confirmBranchCreation, resolution.repository, resolution.branch, resolution.mode, resolution.remote, runtime);
152
152
  case "pull-request":
153
- if (options.json || isHeadless() || options.print) {
154
- return emitError(options, "PR navigation creates a worktree; use `gji pr <ref>` in an interactive shell");
153
+ if (options.json || options.print) {
154
+ return emitError(options, "PR navigation creates a worktree; use `gji pr <ref>`");
155
155
  }
156
156
  return runPrCommand({
157
157
  cwd: resolution.repository.repoRoot,
@@ -197,7 +197,8 @@ function deduplicateSources(sources) {
197
197
  }
198
198
  async function createExistingBranchWorktree(options, confirmBranchCreation, repository, branch, mode, remote, runtime) {
199
199
  if (options.json || isHeadless() || options.print) {
200
- return emitError(options, `branch "${branch}" exists but has no worktree; use interactive gji go ${branch} to create one`);
200
+ const command = await resolveNonInteractiveCreateCommand(repository, branch, mode, remote, runtime ?? defaultCliDependencies);
201
+ return emitError(options, `branch "${branch}" exists but has no worktree; run ${command} to create one`);
201
202
  }
202
203
  if (!(await confirmBranchCreation(branch))) {
203
204
  options.stderr("Aborted\n");
@@ -214,6 +215,18 @@ async function createExistingBranchWorktree(options, confirmBranchCreation, repo
214
215
  runtime,
215
216
  });
216
217
  }
218
+ async function resolveNonInteractiveCreateCommand(repository, branch, mode, remote, runtime) {
219
+ if (mode === "track") {
220
+ return `git worktree add --track -b ${branch} <path> ${remote ?? "origin"}/${branch}`;
221
+ }
222
+ // gji new applies branchPrefix, so it would create a different branch.
223
+ const config = await runtime.config
224
+ .loadEffectiveConfig(repository.repoRoot)
225
+ .catch(() => null);
226
+ return applyConfiguredBranchPrefix(branch, config?.branchPrefix) === branch
227
+ ? `gji new ${branch}`
228
+ : `git worktree add <path> ${branch}`;
229
+ }
217
230
  async function navigateToExistingWorktree(options, path, worktree, runtime) {
218
231
  const repository = await runtime.repositoryContext.detectRepository(path);
219
232
  if (options.json) {
@@ -45,13 +45,6 @@ const SHELL_WRAPPED_COMMANDS = [
45
45
  names: ["root"],
46
46
  tempPrefix: "gji-root",
47
47
  },
48
- {
49
- bypassOptions: ["--help", "-h", "--json"],
50
- commandName: "remove",
51
- envVar: "GJI_REMOVE_OUTPUT_FILE",
52
- names: ["remove", "rm"],
53
- tempPrefix: "gji-remove",
54
- },
55
48
  {
56
49
  bypassOptions: ["--help", "-h", "--json"],
57
50
  commandName: "done",
@@ -59,13 +52,6 @@ const SHELL_WRAPPED_COMMANDS = [
59
52
  names: ["done"],
60
53
  tempPrefix: "gji-done",
61
54
  },
62
- {
63
- bypassOptions: ["--print", "--json", "--help", "-h"],
64
- commandName: "warp",
65
- envVar: "GJI_WARP_OUTPUT_FILE",
66
- names: ["warp"],
67
- tempPrefix: "gji-warp",
68
- },
69
55
  ];
70
56
  export async function runInitCommand(options) {
71
57
  const runtime = options.runtime ?? defaultCliDependencies;
@@ -26,7 +26,7 @@ export interface NewCommandOptions {
26
26
  task?: string;
27
27
  runtime?: NewRuntime;
28
28
  }
29
- type NewRuntime = CliRuntime<"bootstrap" | "config" | "git" | "history" | "integrations" | "repositoryContext" | "tasks">;
29
+ type NewRuntime = CliRuntime<"bootstrap" | "config" | "git" | "history" | "hooks" | "integrations" | "repositoryContext" | "slots" | "tasks">;
30
30
  export interface NewCommandDependencies {
31
31
  createBranchPlaceholder: () => string;
32
32
  promptForBranch: (placeholder: string) => Promise<string | null>;
@@ -38,3 +38,4 @@ export interface NewCommandDependencies {
38
38
  export declare function createNewCommand(dependencies?: Partial<NewCommandDependencies>): (options: NewCommandOptions) => Promise<number>;
39
39
  export declare const runNewCommand: (options: NewCommandOptions) => Promise<number>;
40
40
  export declare function generateBranchPlaceholder(random?: () => number): string;
41
+ export declare function applyConfiguredBranchPrefix(branch: string, branchPrefix: unknown): string;
@@ -1,5 +1,5 @@
1
- import { access, mkdir } from "node:fs/promises";
2
- import { dirname, resolve } from "node:path";
1
+ import { access, mkdir, rmdir } from "node:fs/promises";
2
+ import { basename, dirname, resolve } from "node:path";
3
3
  import { confirm, isCancel, text } from "@clack/prompts";
4
4
  import { resolveWorktreePath, validateBranchName, } from "../../domain/worktree/policy.js";
5
5
  import { createBootstrapReporter } from "../../presentation/bootstrap/output.js";
@@ -56,7 +56,7 @@ export function createNewCommand(dependencies = {}) {
56
56
  options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
57
57
  }
58
58
  else {
59
- options.stderr(`gji new: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
59
+ options.stderr(`gji new: ${message} in non-interactive mode\n`);
60
60
  }
61
61
  return 1;
62
62
  }
@@ -103,7 +103,7 @@ export function createNewCommand(dependencies = {}) {
103
103
  if (options.force) {
104
104
  if (!options.dryRun) {
105
105
  try {
106
- await runtime.git.runGit(repository.repoRoot, [
106
+ await runtime.git.runGitRetryingContention(repository.repoRoot, [
107
107
  "worktree",
108
108
  "remove",
109
109
  "--force",
@@ -111,6 +111,8 @@ export function createNewCommand(dependencies = {}) {
111
111
  ]);
112
112
  }
113
113
  catch (err) {
114
+ // The worktree claims its path below, which an empty leftover would block.
115
+ await rmdir(worktreePath).catch(() => undefined);
114
116
  if (!isNotRegisteredWorktreeError(err)) {
115
117
  const msg = `could not remove existing worktree at ${worktreePath}: ${toExecMessage(err)}`;
116
118
  if (options.json) {
@@ -141,7 +143,7 @@ export function createNewCommand(dependencies = {}) {
141
143
  options.stderr(`${JSON.stringify({ error: message }, null, 2)}\n`);
142
144
  }
143
145
  else {
144
- options.stderr(`gji new: ${message} in non-interactive mode (GJI_NO_TUI=1)\n`);
146
+ options.stderr(`gji new: ${message} in non-interactive mode\n`);
145
147
  options.stderr(`Hint: Use 'gji done ${worktreeName}' or 'gji clean' to remove the existing worktree\n`);
146
148
  options.stderr(`Hint: Use 'gji run-hook after-create' inside the worktree to re-run setup hooks\n`);
147
149
  }
@@ -229,13 +231,14 @@ export function createNewCommand(dependencies = {}) {
229
231
  if (options.take && submoduleFiles.length > 0)
230
232
  options.stderr(`Warning: submodule changes are not transferred: ${submoduleFiles.join(", ")}\n`);
231
233
  const existingLocalBranch = await localBranchExists(repository.repoRoot, worktreeName, runtime);
232
- const shouldRefreshBase = !options.noFetch &&
233
- !options.detached &&
234
- !options.fromCurrent &&
235
- !options.take &&
234
+ const createsBranch = !options.detached &&
236
235
  options.mode !== "track" &&
237
236
  options.mode !== "checkout" &&
238
237
  !existingLocalBranch;
238
+ const shouldRefreshBase = createsBranch &&
239
+ !options.noFetch &&
240
+ !options.fromCurrent &&
241
+ !options.take;
239
242
  const freshBaseRef = shouldRefreshBase
240
243
  ? await resolveFreshBaseRef(repository.repoRoot, config, options, promptForFetchFailure, runtime)
241
244
  : undefined;
@@ -253,42 +256,21 @@ export function createNewCommand(dependencies = {}) {
253
256
  return emitNewError(options, `could not stash changes safely: ${toExecMessage(error)}`);
254
257
  }
255
258
  }
256
- const gitArgs = options.detached
257
- ? [
258
- "worktree",
259
- "add",
260
- "--detach",
261
- worktreePath,
262
- ...(takeStartPoint ? [takeStartPoint] : []),
263
- ]
264
- : options.mode === "track"
265
- ? [
266
- "worktree",
267
- "add",
268
- "--track",
269
- "-b",
270
- worktreeName,
271
- worktreePath,
272
- `${options.remote ?? "origin"}/${worktreeName}`,
273
- ]
274
- : options.mode === "checkout" || existingLocalBranch
275
- ? ["worktree", "add", worktreePath, worktreeName]
276
- : [
277
- "worktree",
278
- "add",
279
- "-b",
280
- worktreeName,
281
- worktreePath,
282
- ...(takeStartPoint
283
- ? [takeStartPoint]
284
- : startPoint
285
- ? [startPoint]
286
- : freshBaseRef
287
- ? [freshBaseRef]
288
- : []),
289
- ];
259
+ const addsNewBranch = createsBranch || (!options.detached && options.mode === "track");
290
260
  try {
291
- await runtime.git.runGit(repository.repoRoot, gitArgs);
261
+ await runtime.git.addWorktree(repository.repoRoot, worktreePath, options.detached
262
+ ? { detachedAt: takeStartPoint }
263
+ : addsNewBranch
264
+ ? {
265
+ branch: worktreeName,
266
+ create: {
267
+ startPoint: options.mode === "track"
268
+ ? `${options.remote ?? "origin"}/${worktreeName}`
269
+ : (takeStartPoint ?? startPoint ?? freshBaseRef),
270
+ track: options.mode === "track",
271
+ },
272
+ }
273
+ : { branch: worktreeName });
292
274
  }
293
275
  catch (error) {
294
276
  if (stashSha)
@@ -297,17 +279,7 @@ export function createNewCommand(dependencies = {}) {
297
279
  }
298
280
  if (stashSha &&
299
281
  !(await applyTakeStash(worktreePath, options.cwd, stashSha, !!options.copy, options.stderr, runtime.git.runGit))) {
300
- try {
301
- await runtime.git.runGit(repository.repoRoot, [
302
- "worktree",
303
- "remove",
304
- "--force",
305
- worktreePath,
306
- ]);
307
- }
308
- catch {
309
- /* rollback best effort */
310
- }
282
+ await runtime.git.removeCreatedWorktree(repository.repoRoot, worktreePath, addsNewBranch ? worktreeName : undefined);
311
283
  return emitNewError(options, `could not apply taken changes; your changes are safe in stash: ${stashSha}`);
312
284
  }
313
285
  if (options.task !== undefined) {
@@ -338,6 +310,17 @@ export function createNewCommand(dependencies = {}) {
338
310
  syncFiles: bootstrap.syncFileFailures,
339
311
  });
340
312
  }
313
+ if (createsBranch) {
314
+ await runNewHooks({
315
+ branch: worktreeName,
316
+ config,
317
+ hookNames: ["after-branch-create"],
318
+ options,
319
+ path: worktreePath,
320
+ repository,
321
+ runtime,
322
+ });
323
+ }
341
324
  if (options.json) {
342
325
  const navigation = createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), worktreePath, worktreeName);
343
326
  const output = options.take
@@ -438,7 +421,7 @@ async function runBranchOnly(options, runtime, repository, config, rawBranch) {
438
421
  try {
439
422
  let startPoint = baseBranch;
440
423
  if (!options.noFetch) {
441
- await runtime.git.runGit(repository.repoRoot, [
424
+ await runtime.git.runGitRetryingContention(repository.repoRoot, [
442
425
  "fetch",
443
426
  "--prune",
444
427
  remote,
@@ -446,16 +429,29 @@ async function runBranchOnly(options, runtime, repository, config, rawBranch) {
446
429
  ]);
447
430
  startPoint = `${remote}/${baseBranch}`;
448
431
  }
449
- await runtime.git.runGit(options.cwd, [
450
- "switch",
451
- "-c",
452
- branchName,
432
+ await runtime.git.createBranch(repository.repoRoot, branchName, {
453
433
  startPoint,
454
- ]);
434
+ });
435
+ try {
436
+ await runtime.git.runGit(options.cwd, ["switch", branchName]);
437
+ }
438
+ catch (error) {
439
+ await runtime.git.deleteCreatedBranch(repository.repoRoot, branchName);
440
+ throw error;
441
+ }
455
442
  }
456
443
  catch (error) {
457
444
  return emitNewError(options, `failed to create branch in the current worktree: ${toExecMessage(error)}`);
458
445
  }
446
+ await runNewHooks({
447
+ branch: branchName,
448
+ config,
449
+ hookNames: ["after-branch-create", "after-switch"],
450
+ options,
451
+ path: repository.currentRoot,
452
+ repository,
453
+ runtime,
454
+ });
459
455
  if (options.json) {
460
456
  const navigation = createNavigationTarget(createNavigationRepository(repository.repoName, repository.repoRoot), repository.currentRoot, branchName);
461
457
  options.stdout(`${JSON.stringify({ ...navigation, baseBranch, branchOnly: true }, null, 2)}\n`);
@@ -467,6 +463,18 @@ async function runBranchOnly(options, runtime, repository, config, rawBranch) {
467
463
  }
468
464
  return 0;
469
465
  }
466
+ async function runNewHooks({ branch, config, hookNames, options, path, repository, runtime, }) {
467
+ const hooks = runtime.hooks.extractHooks(config);
468
+ const context = {
469
+ branch,
470
+ path,
471
+ repo: basename(repository.repoRoot),
472
+ slot: await runtime.slots.getWorktreeSlot(path),
473
+ };
474
+ for (const hookName of hookNames) {
475
+ await runtime.hooks.runHook(hooks[hookName], path, context, options.json ? () => undefined : options.stderr, options.json ? () => undefined : options.stdout);
476
+ }
477
+ }
470
478
  export const runNewCommand = createNewCommand();
471
479
  async function resolveFreshBaseRef(repoRoot, config, options, promptForFetchFailure, runtime) {
472
480
  const remote = runtime.config.resolveConfigString(config, "syncRemote") ?? "origin";
@@ -480,7 +488,7 @@ async function resolveFreshBaseRef(repoRoot, config, options, promptForFetchFail
480
488
  throw new Error("the remote default branch is unknown");
481
489
  }
482
490
  baseBranch = remoteBase.branch;
483
- await runtime.git.runGit(repoRoot, [
491
+ await runtime.git.runGitRetryingContention(repoRoot, [
484
492
  "fetch",
485
493
  "--prune",
486
494
  remote,
@@ -640,7 +648,7 @@ function generateBranchPlaceholderSuffix(random) {
640
648
  }
641
649
  return suffix;
642
650
  }
643
- function applyConfiguredBranchPrefix(branch, branchPrefix) {
651
+ export function applyConfiguredBranchPrefix(branch, branchPrefix) {
644
652
  if (typeof branchPrefix !== "string" || branchPrefix.length === 0) {
645
653
  return branch;
646
654
  }
@@ -26,7 +26,7 @@ export function createOpenCommand(dependencies = {}) {
26
26
  return 1;
27
27
  }
28
28
  if (options.select && isHeadless()) {
29
- options.stderr("gji open --select: selector is unavailable in non-interactive mode (GJI_NO_TUI=1)\n");
29
+ options.stderr("gji open --select: selector is unavailable in non-interactive mode; pass a branch instead\n");
30
30
  return 1;
31
31
  }
32
32
  const [worktrees, repository] = await Promise.all([