@solaqua/gji 0.10.0 → 0.12.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 (122) hide show
  1. package/README.md +83 -426
  2. package/dist/back.d.ts +3 -0
  3. package/dist/back.js +19 -7
  4. package/dist/bootstrap-output.d.ts +3 -0
  5. package/dist/bootstrap-output.js +27 -0
  6. package/dist/bootstrap-preview.d.ts +10 -0
  7. package/dist/bootstrap-preview.js +21 -0
  8. package/dist/clean.js +27 -3
  9. package/dist/cli.d.ts +4 -0
  10. package/dist/cli.js +140 -22
  11. package/dist/clone-failure-store.d.ts +18 -0
  12. package/dist/clone-failure-store.js +230 -0
  13. package/dist/command-runner.d.ts +6 -0
  14. package/dist/command-runner.js +38 -0
  15. package/dist/completion.js +7 -2
  16. package/dist/config-command.d.ts +1 -0
  17. package/dist/config-command.js +7 -1
  18. package/dist/config.d.ts +26 -1
  19. package/dist/config.js +110 -6
  20. package/dist/context-card.d.ts +1 -0
  21. package/dist/context-card.js +25 -0
  22. package/dist/dependency-bootstrap-prompt.d.ts +24 -0
  23. package/dist/dependency-bootstrap-prompt.js +103 -0
  24. package/dist/dependency-bootstrap.d.ts +129 -0
  25. package/dist/dependency-bootstrap.js +566 -0
  26. package/dist/dir-clone.d.ts +33 -0
  27. package/dist/dir-clone.js +505 -0
  28. package/dist/doctor.js +21 -0
  29. package/dist/done.d.ts +10 -0
  30. package/dist/done.js +210 -0
  31. package/dist/file-sync.js +71 -22
  32. package/dist/format-bytes.d.ts +1 -0
  33. package/dist/format-bytes.js +14 -0
  34. package/dist/fs-utils.d.ts +4 -0
  35. package/dist/fs-utils.js +26 -0
  36. package/dist/git.d.ts +5 -0
  37. package/dist/git.js +50 -0
  38. package/dist/gji-bundle.mjs +6189 -1313
  39. package/dist/go-resolver.d.ts +35 -0
  40. package/dist/go-resolver.js +185 -0
  41. package/dist/go.d.ts +6 -2
  42. package/dist/go.js +270 -62
  43. package/dist/hooks.d.ts +2 -1
  44. package/dist/hooks.js +20 -8
  45. package/dist/hub.d.ts +31 -0
  46. package/dist/hub.js +287 -0
  47. package/dist/init.js +30 -22
  48. package/dist/install-prompt.d.ts +1 -0
  49. package/dist/install-prompt.js +3 -25
  50. package/dist/ls.js +9 -1
  51. package/dist/navigation-output.d.ts +11 -0
  52. package/dist/navigation-output.js +6 -0
  53. package/dist/new.d.ts +14 -2
  54. package/dist/new.js +422 -38
  55. package/dist/pr-open.js +2 -10
  56. package/dist/pr.d.ts +6 -2
  57. package/dist/pr.js +135 -30
  58. package/dist/pull-requests.d.ts +1 -0
  59. package/dist/pull-requests.js +32 -3
  60. package/dist/remove.js +22 -0
  61. package/dist/repo.d.ts +4 -0
  62. package/dist/repo.js +61 -6
  63. package/dist/run-hook.js +2 -0
  64. package/dist/safe-destination.d.ts +15 -0
  65. package/dist/safe-destination.js +166 -0
  66. package/dist/shell-completion.js +52 -13
  67. package/dist/slots.d.ts +8 -0
  68. package/dist/slots.js +61 -0
  69. package/dist/status.d.ts +2 -0
  70. package/dist/status.js +9 -1
  71. package/dist/sync-directories.d.ts +33 -0
  72. package/dist/sync-directories.js +97 -0
  73. package/dist/sync-plan.d.ts +16 -0
  74. package/dist/sync-plan.js +128 -0
  75. package/dist/sync.js +7 -13
  76. package/dist/task-command.d.ts +9 -0
  77. package/dist/task-command.js +29 -0
  78. package/dist/task.d.ts +9 -0
  79. package/dist/task.js +37 -0
  80. package/dist/terminal-text.d.ts +4 -0
  81. package/dist/terminal-text.js +72 -0
  82. package/dist/undo.d.ts +36 -0
  83. package/dist/undo.js +392 -0
  84. package/dist/warp.d.ts +3 -1
  85. package/dist/warp.js +32 -125
  86. package/dist/worktree-bootstrap.d.ts +35 -0
  87. package/dist/worktree-bootstrap.js +96 -0
  88. package/dist/worktree-info.d.ts +6 -1
  89. package/dist/worktree-info.js +14 -4
  90. package/dist/worktree-management.d.ts +1 -0
  91. package/dist/worktree-management.js +23 -4
  92. package/dist/worktree-picker.d.ts +24 -11
  93. package/dist/worktree-picker.js +176 -95
  94. package/dist/worktree-prompts.js +1 -1
  95. package/dist/worktree-source.d.ts +6 -0
  96. package/dist/worktree-source.js +1 -0
  97. package/dist/worktree-sources.d.ts +4 -0
  98. package/dist/worktree-sources.js +77 -0
  99. package/man/man1/gji-back.1 +1 -1
  100. package/man/man1/gji-clean.1 +1 -1
  101. package/man/man1/gji-completion.1 +1 -1
  102. package/man/man1/gji-config.1 +1 -1
  103. package/man/man1/gji-doctor.1 +1 -1
  104. package/man/man1/gji-done.1 +19 -0
  105. package/man/man1/gji-go.1 +12 -3
  106. package/man/man1/gji-history.1 +1 -1
  107. package/man/man1/gji-init.1 +1 -1
  108. package/man/man1/gji-ls.1 +1 -1
  109. package/man/man1/gji-new.1 +21 -3
  110. package/man/man1/gji-open.1 +1 -1
  111. package/man/man1/gji-pr.1 +7 -1
  112. package/man/man1/gji-remove.1 +3 -3
  113. package/man/man1/gji-root.1 +1 -1
  114. package/man/man1/gji-run-hook.1 +1 -1
  115. package/man/man1/gji-status.1 +1 -1
  116. package/man/man1/gji-sync-files.1 +1 -1
  117. package/man/man1/gji-sync.1 +1 -1
  118. package/man/man1/gji-task.1 +16 -0
  119. package/man/man1/gji-undo.1 +16 -0
  120. package/man/man1/gji-warp.1 +3 -6
  121. package/man/man1/gji.1 +17 -5
  122. package/package.json +3 -1
@@ -0,0 +1,166 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, mkdir, open } from "node:fs/promises";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { isAlreadyExistsError, isNotDirectoryError, isNotFoundError, } from "./fs-utils.js";
5
+ export async function openDestinationDirectory(root, path) {
6
+ const segments = destinationSegments(root, path);
7
+ if (process.platform !== "linux") {
8
+ await ensureDestinationDirectory(root, path);
9
+ return { path: resolve(path), close: async () => undefined };
10
+ }
11
+ let handle = await open(root, destinationDirectoryFlags());
12
+ try {
13
+ for (const segment of segments) {
14
+ const childPath = join(fileDescriptorPath(handle.fd), segment);
15
+ try {
16
+ await mkdir(childPath);
17
+ }
18
+ catch (error) {
19
+ if (!isAlreadyExistsError(error))
20
+ throw error;
21
+ }
22
+ const childHandle = await open(childPath, destinationDirectoryFlags());
23
+ await handle.close();
24
+ handle = childHandle;
25
+ }
26
+ return {
27
+ path: fileDescriptorPath(handle.fd),
28
+ close: async () => handle.close(),
29
+ };
30
+ }
31
+ catch (error) {
32
+ await handle.close().catch(() => undefined);
33
+ throw error;
34
+ }
35
+ }
36
+ export async function inspectDestination(root, path) {
37
+ const resolvedPath = resolve(path);
38
+ const resolvedRoot = resolve(root);
39
+ const distance = relative(resolvedRoot, resolvedPath);
40
+ if (isAbsolute(distance) ||
41
+ distance === ".." ||
42
+ distance.startsWith(`..${sep}`)) {
43
+ return { kind: "unsafe", reason: "destination escapes the worktree" };
44
+ }
45
+ let current = resolvedRoot;
46
+ try {
47
+ const rootStats = await lstat(current);
48
+ if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
49
+ return { kind: "unsafe", reason: "worktree root is not a directory" };
50
+ }
51
+ }
52
+ catch (error) {
53
+ if (isNotFoundError(error))
54
+ return { kind: "missing" };
55
+ throw error;
56
+ }
57
+ const segments = distance.split(sep).filter(Boolean);
58
+ for (const [index, segment] of segments.entries()) {
59
+ current = resolve(current, segment);
60
+ try {
61
+ const stats = await lstat(current);
62
+ if (stats.isSymbolicLink()) {
63
+ return {
64
+ kind: "unsafe",
65
+ reason: `destination has a symbolic-link component: ${current}`,
66
+ };
67
+ }
68
+ if (index < segments.length - 1 && !stats.isDirectory()) {
69
+ return {
70
+ kind: "unsafe",
71
+ reason: `destination has a non-directory ancestor: ${current}`,
72
+ };
73
+ }
74
+ if (index === segments.length - 1) {
75
+ return stats.isDirectory()
76
+ ? { kind: "exists" }
77
+ : {
78
+ kind: "unsafe",
79
+ reason: `destination is not a directory: ${current}`,
80
+ };
81
+ }
82
+ }
83
+ catch (error) {
84
+ if (isNotFoundError(error))
85
+ return { kind: "missing" };
86
+ if (isNotDirectoryError(error)) {
87
+ return {
88
+ kind: "unsafe",
89
+ reason: `destination has a non-directory ancestor: ${current}`,
90
+ };
91
+ }
92
+ throw error;
93
+ }
94
+ }
95
+ try {
96
+ const stats = await lstat(current);
97
+ return stats.isDirectory()
98
+ ? { kind: "exists" }
99
+ : { kind: "unsafe", reason: "destination is not a directory" };
100
+ }
101
+ catch (error) {
102
+ if (isNotFoundError(error))
103
+ return { kind: "missing" };
104
+ throw error;
105
+ }
106
+ }
107
+ function destinationSegments(root, path) {
108
+ const resolvedRoot = resolve(root);
109
+ const resolvedPath = resolve(path);
110
+ const distance = relative(resolvedRoot, resolvedPath);
111
+ if (isAbsolute(distance) ||
112
+ distance === ".." ||
113
+ distance.startsWith(`..${sep}`)) {
114
+ throw new Error("destination escapes the worktree");
115
+ }
116
+ return distance.split(sep).filter(Boolean);
117
+ }
118
+ function destinationDirectoryFlags() {
119
+ return constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
120
+ }
121
+ function fileDescriptorPath(fd) {
122
+ if (process.platform === "linux")
123
+ return `/proc/self/fd/${fd}`;
124
+ if (process.platform === "darwin")
125
+ return `/dev/fd/${fd}`;
126
+ throw new Error(`safe destination handles are unsupported on ${process.platform}`);
127
+ }
128
+ export async function ensureDestinationDirectory(root, path) {
129
+ const resolvedRoot = resolve(root);
130
+ const resolvedPath = resolve(path);
131
+ const distance = relative(resolvedRoot, resolvedPath);
132
+ if (isAbsolute(distance) ||
133
+ distance === ".." ||
134
+ distance.startsWith(`..${sep}`)) {
135
+ throw new Error("destination escapes the worktree");
136
+ }
137
+ let current = resolvedRoot;
138
+ const rootStats = await lstat(current);
139
+ if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
140
+ throw new Error("worktree root is not a directory");
141
+ }
142
+ for (const segment of distance.split(sep).filter(Boolean)) {
143
+ current = resolve(current, segment);
144
+ try {
145
+ const stats = await lstat(current);
146
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
147
+ throw new Error(`destination has an unsafe component: ${current}`);
148
+ }
149
+ }
150
+ catch (error) {
151
+ if (!isNotFoundError(error))
152
+ throw error;
153
+ try {
154
+ await mkdir(current);
155
+ }
156
+ catch (mkdirError) {
157
+ if (!isAlreadyExistsError(mkdirError))
158
+ throw mkdirError;
159
+ }
160
+ const stats = await lstat(current);
161
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
162
+ throw new Error(`destination has an unsafe component: ${current}`);
163
+ }
164
+ }
165
+ }
166
+ }
@@ -2,7 +2,15 @@ import { KNOWN_GLOBAL_CONFIG_KEYS } from "./config.js";
2
2
  const TOP_LEVEL_COMMANDS = [
3
3
  {
4
4
  name: "new",
5
- description: "create a new branch or detached linked worktree",
5
+ description: "create a new branch or detached linked worktree and CoW-bootstrap configured directories",
6
+ },
7
+ {
8
+ name: "done",
9
+ description: "finish the current linked worktree and return safely",
10
+ },
11
+ {
12
+ name: "undo",
13
+ description: "restore the most recent destructive worktree operation",
6
14
  },
7
15
  {
8
16
  name: "init",
@@ -17,10 +25,11 @@ const TOP_LEVEL_COMMANDS = [
17
25
  { name: "back", description: "navigate to the previously visited worktree" },
18
26
  { name: "history", description: "show navigation history" },
19
27
  { name: "open", description: "open the worktree in an editor" },
20
- { name: "go", description: "print or select a worktree path" },
28
+ { name: "go", description: "resolve and jump to a worktree path" },
21
29
  { name: "jump", description: "alias of go" },
22
30
  { name: "root", description: "print the main repository root path" },
23
31
  { name: "status", description: "summarize repository and worktree health" },
32
+ { name: "task", description: "show or update the current worktree task" },
24
33
  { name: "sync", description: "fetch and update one or all worktrees" },
25
34
  {
26
35
  name: "sync-files",
@@ -30,14 +39,17 @@ const TOP_LEVEL_COMMANDS = [
30
39
  { name: "clean", description: "interactively prune linked worktrees" },
31
40
  {
32
41
  name: "remove",
33
- description: "remove a linked worktree and delete its branch when present",
42
+ description: "deprecated: use done for one worktree or clean for bulk cleanup",
34
43
  },
35
- { name: "rm", description: "alias of remove" },
44
+ { name: "rm", description: "deprecated alias of remove" },
36
45
  {
37
46
  name: "run-hook",
38
47
  description: "run a named hook in the current worktree",
39
48
  },
40
- { name: "warp", description: "jump to any worktree across all known repos" },
49
+ {
50
+ name: "warp",
51
+ description: "deprecated: use go (press Tab for all known repos)",
52
+ },
41
53
  { name: "config", description: "manage global config defaults" },
42
54
  ];
43
55
  const SHELL_NAMES = ["bash", "fish", "zsh"];
@@ -129,7 +141,7 @@ _gji_completion() {
129
141
  cur="\${COMP_WORDS[COMP_CWORD]:-}"
130
142
 
131
143
  if [ "$COMP_CWORD" -eq 1 ]; then
132
- COMPREPLY=( $(compgen -W "${topLevelCommands}" -- "$cur") )
144
+ COMPREPLY=( $(compgen -W "${topLevelCommands} --json --help" -- "$cur") )
133
145
  return 0
134
146
  fi
135
147
 
@@ -137,7 +149,13 @@ _gji_completion() {
137
149
 
138
150
  case "$command_name" in
139
151
  new)
140
- COMPREPLY=( $(compgen -W "--detached --from-current --force --open --editor --dry-run --json --help" -- "$cur") )
152
+ COMPREPLY=( $(compgen -W "--detached --from-current --no-fetch --take --copy --task --force --open --editor --dry-run --json --help" -- "$cur") )
153
+ ;;
154
+ done)
155
+ COMPREPLY=( $(compgen -W "--force --keep-branch --json --help" -- "$cur") )
156
+ ;;
157
+ undo)
158
+ COMPREPLY=( $(compgen -W "--list --json --help" -- "$cur") )
141
159
  ;;
142
160
  init)
143
161
  COMPREPLY=( $(compgen -W "${shells} --write --json --help" -- "$cur") )
@@ -182,7 +200,7 @@ _gji_completion() {
182
200
  COMPREPLY=( $(compgen -W "$(__gji_worktree_branches) --editor --select --save --workspace --help" -- "$cur") )
183
201
  ;;
184
202
  go|jump)
185
- COMPREPLY=( $(compgen -W "$(__gji_worktree_branches) --print --help" -- "$cur") )
203
+ COMPREPLY=( $(compgen -W "$(__gji_worktree_branches) --root --print --json --help" -- "$cur") )
186
204
  ;;
187
205
  root)
188
206
  COMPREPLY=( $(compgen -W "--print --help" -- "$cur") )
@@ -212,7 +230,7 @@ _gji_completion() {
212
230
  COMPREPLY=( $(compgen -W "${hooks} --help" -- "$cur") )
213
231
  ;;
214
232
  warp)
215
- COMPREPLY=( $(compgen -W "-n --new --print --json --help" -- "$cur") )
233
+ COMPREPLY=( $(compgen -W "--print --json --help" -- "$cur") )
216
234
  ;;
217
235
  config)
218
236
  if [ "$COMP_CWORD" -eq 2 ]; then
@@ -361,10 +379,18 @@ function __gji_should_complete_config_key
361
379
  end
362
380
 
363
381
  complete -c gji -f
382
+ complete -c gji -n '__fish_seen_subcommand_from done' -l force -d 'remove dirty or unmerged worktrees without prompting'
383
+ complete -c gji -n '__fish_seen_subcommand_from done' -l keep-branch -d 'remove the worktree but preserve its branch'
384
+ complete -c gji -n '__fish_seen_subcommand_from done' -l json -d 'emit JSON on success or error instead of human-readable output'
385
+ complete -c gji -n '__fish_seen_subcommand_from undo' -l list -d 'list recoverable operations'
386
+ complete -c gji -n '__fish_seen_subcommand_from undo' -l json -d 'emit JSON on success or error instead of human-readable output'
364
387
  ${commandLines}
365
388
 
366
389
  complete -c gji -n '__fish_seen_subcommand_from new' -l detached -d 'create a detached worktree without a branch'
367
390
  complete -c gji -n '__fish_seen_subcommand_from new' -l from-current -d 'base the new branch on the current worktree instead of the main worktree'
391
+ complete -c gji -n '__fish_seen_subcommand_from new' -l take -d 'move current uncommitted changes into the new worktree'
392
+ complete -c gji -n '__fish_seen_subcommand_from new' -l copy -d 'copy current uncommitted changes instead of moving them (requires --take)'
393
+ complete -c gji -n '__fish_seen_subcommand_from new' -l task -r -d 'record the purpose of the new worktree'
368
394
  complete -c gji -n '__fish_seen_subcommand_from new' -l force -d 'remove and recreate the worktree if the target path already exists'
369
395
  complete -c gji -n '__fish_seen_subcommand_from new' -l open -d 'open the new worktree in an editor after creation'
370
396
  complete -c gji -n '__fish_seen_subcommand_from new' -l editor -r -d 'editor CLI to use with --open (code, cursor, zed, …)'
@@ -400,11 +426,16 @@ complete -c gji -n '__fish_seen_subcommand_from open' -l workspace -d 'generate
400
426
  complete -c gji -n '__fish_seen_subcommand_from open' -a '(__gji_worktree_branches)' -d 'worktree branch'
401
427
 
402
428
  complete -c gji -n '__fish_seen_subcommand_from go jump' -l print -d 'print the resolved worktree path explicitly'
429
+ complete -c gji -n '__fish_seen_subcommand_from go jump' -l root -d 'navigate to the main repository root'
430
+ complete -c gji -n '__fish_seen_subcommand_from go jump' -l json -d 'emit JSON for an existing worktree destination'
431
+ complete -c gji -n '__fish_seen_subcommand_from go jump' -l quiet -d 'suppress the context card'
403
432
  complete -c gji -n '__fish_seen_subcommand_from go jump' -a '(__gji_worktree_branches)' -d 'worktree branch'
404
433
 
405
434
  complete -c gji -n '__fish_seen_subcommand_from root' -l print -d 'print the resolved repository root path explicitly'
406
435
 
407
436
  complete -c gji -n '__fish_seen_subcommand_from status' -l json -d 'print repository and worktree health as JSON'
437
+ complete -c gji -n '__fish_seen_subcommand_from task' -l clear -d 'clear the current worktree task'
438
+ complete -c gji -n '__fish_seen_subcommand_from task' -l json -d 'emit task metadata as JSON'
408
439
 
409
440
  complete -c gji -n '__fish_seen_subcommand_from sync' -l all -d 'sync every worktree in the repository'
410
441
  complete -c gji -n '__fish_seen_subcommand_from sync' -l json -d 'emit JSON on success or error instead of human-readable output'
@@ -430,7 +461,8 @@ complete -c gji -n '__fish_seen_subcommand_from remove rm' -a '(__gji_worktree_b
430
461
 
431
462
  ${hookLines}
432
463
 
433
- complete -c gji -n '__fish_seen_subcommand_from warp' -s n -l new -d 'create a new worktree in a registered repo'
464
+ complete -c gji -n '__fish_use_subcommand' -l json -d 'emit the repository hub as JSON'
465
+
434
466
  complete -c gji -n '__fish_seen_subcommand_from warp' -l print -d 'print the resolved worktree path without changing directory'
435
467
  complete -c gji -n '__fish_seen_subcommand_from warp' -l json -d 'emit JSON on success or error instead of human-readable output'
436
468
 
@@ -509,6 +541,7 @@ local -a command_entries worktree_branches
509
541
 
510
542
  command_entries=(
511
543
  ${commandLines}
544
+ '--json:emit the repository hub as JSON'
512
545
  )
513
546
 
514
547
  if (( CURRENT == 2 )); then
@@ -518,7 +551,13 @@ fi
518
551
 
519
552
  case "\${words[2]}" in
520
553
  new)
521
- _arguments '--detached[create a detached worktree without a branch]' '--from-current[base the new branch on the current worktree instead of the main worktree]' '--force[remove and recreate the worktree if the target path already exists]' '--open[open the new worktree in an editor after creation]' '--editor[editor CLI to use with --open (code, cursor, zed, …)]:editor:' '--dry-run[show what would be created without executing any git commands or writing files]' '--json[emit JSON on success or error instead of human-readable output]' '2:branch: '
554
+ _arguments '--detached[create a detached worktree without a branch]' '--from-current[base the new branch on the current worktree instead of the main worktree]' '--no-fetch[skip refreshing the remote default branch before creating the new branch]' '--take[move current uncommitted changes into the new worktree]' '--copy[copy current uncommitted changes instead of moving them (requires --take)]' '--task[record the purpose of the new worktree]:description:' '--force[remove and recreate the worktree if the target path already exists]' '--open[open the new worktree in an editor after creation]' '--editor[editor CLI to use with --open (code, cursor, zed, …)]:editor:' '--dry-run[show what would be created without executing any git commands or writing files]' '--json[emit JSON on success or error instead of human-readable output]' '2:branch: '
555
+ ;;
556
+ done)
557
+ _arguments '--force[remove dirty or unmerged worktrees without prompting]' '--keep-branch[remove the worktree but preserve its branch]' '--json[emit JSON on success or error instead of human-readable output]' '2:branch: '
558
+ ;;
559
+ undo)
560
+ _arguments '--list[list recoverable operations]' '--json[emit JSON on success or error instead of human-readable output]' '2:id: '
522
561
  ;;
523
562
  init)
524
563
  _arguments '--write[write the integration to the shell config file]' '--json[emit a JSON error in non-interactive mode]' '2:shell:(${shells})'
@@ -552,7 +591,7 @@ case "\${words[2]}" in
552
591
  _arguments '--editor[editor CLI to use (code, cursor, zed, windsurf, subl, …)]:editor:' '--select[choose a worktree with the interactive selector]' '--save[save the chosen editor to global config]' '--workspace[generate a .code-workspace file before opening (VS Code / Cursor / Windsurf)]' '2:branch:->worktrees'
553
592
  ;;
554
593
  go|jump)
555
- _arguments '--print[print the resolved worktree path explicitly]' '2:branch:->worktrees'
594
+ _arguments '--root[navigate to the main repository root]' '--print[print the resolved worktree path explicitly]' '--json[emit JSON for an existing worktree destination]' '2:branch:->worktrees'
556
595
  ;;
557
596
  root)
558
597
  _arguments '--print[print the resolved repository root path explicitly]'
@@ -579,7 +618,7 @@ case "\${words[2]}" in
579
618
  _arguments "2:hook:(${hooks})"
580
619
  ;;
581
620
  warp)
582
- _arguments '(-n --new)'{-n,--new}'[create a new worktree in a registered repo]:branch:' '--print[print the resolved worktree path without changing directory]' '--json[emit JSON on success or error instead of human-readable output]' '2:branch: '
621
+ _arguments '--print[print the resolved worktree path without changing directory]' '--json[emit JSON on success or error instead of human-readable output]' '2:branch: '
583
622
  ;;
584
623
  config)
585
624
  if (( CURRENT == 3 )); then
@@ -0,0 +1,8 @@
1
+ export interface SlotStore {
2
+ [worktreePath: string]: number;
3
+ }
4
+ export declare function SLOTS_FILE_PATH(home?: string): string;
5
+ export declare function loadSlots(home?: string): Promise<SlotStore>;
6
+ export declare function assignWorktreeSlot(repoRoot: string, worktreePath: string, home?: string): Promise<number>;
7
+ export declare function getWorktreeSlot(worktreePath: string, home?: string): Promise<number | null>;
8
+ export declare function releaseWorktreeSlot(worktreePath: string, home?: string): Promise<void>;
package/dist/slots.js ADDED
@@ -0,0 +1,61 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { GLOBAL_CONFIG_DIRECTORY } from "./config.js";
5
+ export function SLOTS_FILE_PATH(home = homedir()) {
6
+ const configDir = process.env.GJI_CONFIG_DIR;
7
+ return configDir
8
+ ? join(resolve(configDir), "slots.json")
9
+ : join(home, GLOBAL_CONFIG_DIRECTORY, "slots.json");
10
+ }
11
+ export async function loadSlots(home = homedir()) {
12
+ try {
13
+ const parsed = JSON.parse(await readFile(SLOTS_FILE_PATH(home), "utf8"));
14
+ if (!isSlotStore(parsed))
15
+ return {};
16
+ return parsed;
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ }
22
+ 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;
34
+ }
35
+ export async function getWorktreeSlot(worktreePath, home = homedir()) {
36
+ const slot = (await loadSlots(home))[worktreePath];
37
+ return slot === undefined ? null : slot;
38
+ }
39
+ 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);
45
+ }
46
+ 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");
50
+ }
51
+ function nextAvailableSlot(used) {
52
+ let slot = 1;
53
+ while (used.has(slot))
54
+ slot += 1;
55
+ return slot;
56
+ }
57
+ function isSlotStore(value) {
58
+ return (typeof value === "object" &&
59
+ value !== null &&
60
+ Object.values(value).every((slot) => Number.isInteger(slot) && slot >= 0));
61
+ }
package/dist/status.d.ts CHANGED
@@ -7,7 +7,9 @@ interface WorktreeStatusRow {
7
7
  branch: string | null;
8
8
  current: boolean;
9
9
  path: string;
10
+ slot: number | null;
10
11
  status: "clean" | "dirty";
12
+ task: string | null;
11
13
  upstream: UpstreamState;
12
14
  }
13
15
  type UpstreamState = {
package/dist/status.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { readWorktreeHealth } from "./git.js";
2
2
  import { comparePaths } from "./paths.js";
3
3
  import { detectRepository, listWorktrees } from "./repo.js";
4
+ import { getWorktreeSlot } from "./slots.js";
5
+ import { readTask } from "./task.js";
4
6
  export async function runStatusCommand(options) {
5
7
  const repository = await detectRepository(options.cwd);
6
8
  const worktrees = sortWorktreesByPath(await listWorktrees(options.cwd));
@@ -36,12 +38,18 @@ export function formatStatusJson(repoRoot, currentRoot, rows) {
36
38
  };
37
39
  }
38
40
  async function buildStatusRow(worktree) {
39
- const health = await readWorktreeHealth(worktree.path);
41
+ const [health, slot, task] = await Promise.all([
42
+ readWorktreeHealth(worktree.path),
43
+ getWorktreeSlot(worktree.path),
44
+ readTask(worktree.path),
45
+ ]);
40
46
  return {
41
47
  branch: worktree.branch,
42
48
  current: worktree.isCurrent,
43
49
  path: worktree.path,
50
+ slot,
44
51
  status: health.status,
52
+ task: task?.task ?? null,
45
53
  upstream: buildUpstreamState(worktree.branch, health),
46
54
  };
47
55
  }
@@ -0,0 +1,33 @@
1
+ import { type CloneFailureStore } from "./clone-failure-store.js";
2
+ import type { CloneDirectory } from "./dir-clone.js";
3
+ import type { SyncDirectoryPlan } from "./sync-plan.js";
4
+ export interface ClonedDirectory {
5
+ bytes?: number;
6
+ dir: string;
7
+ ms: number;
8
+ }
9
+ export type SyncDirectoryOutcome = {
10
+ kind: "cloned";
11
+ directory: ClonedDirectory;
12
+ } | {
13
+ kind: "skipped";
14
+ dir: string;
15
+ reason: string;
16
+ };
17
+ export interface SyncDirectoryReporter {
18
+ readonly emitCachedFailureWarnings: boolean;
19
+ readonly measureCloneSize: boolean;
20
+ write(message: string): void;
21
+ cloned(directory: ClonedDirectory): void;
22
+ skipped?(directory: {
23
+ dir: string;
24
+ reason: string;
25
+ }): void;
26
+ }
27
+ export interface SyncDirectoryExecutionOptions {
28
+ cloneDirectory: CloneDirectory;
29
+ failureStore?: CloneFailureStore;
30
+ repoRoot: string;
31
+ reporter: SyncDirectoryReporter;
32
+ }
33
+ export declare function executeSyncDirectoryPlan(plan: readonly SyncDirectoryPlan[], options: SyncDirectoryExecutionOptions): Promise<SyncDirectoryOutcome[]>;
@@ -0,0 +1,97 @@
1
+ import { cloneFailureScope, defaultCloneFailureStore, } from "./clone-failure-store.js";
2
+ import { isCloneDestinationExistsError, isCloneInProgressError, isCloneUnsupportedError, } from "./dir-clone.js";
3
+ import { inspectDestination } from "./safe-destination.js";
4
+ export async function executeSyncDirectoryPlan(plan, options) {
5
+ const failureStore = options.failureStore ?? defaultCloneFailureStore;
6
+ const outcomes = [];
7
+ for (const entry of plan) {
8
+ if (entry.destinationWarning) {
9
+ recordSkipped(outcomes, options.reporter, entry.directory, entry.destinationWarning);
10
+ continue;
11
+ }
12
+ const destinationState = await inspectDestination(entry.worktreePath, entry.destination);
13
+ if (destinationState.kind === "exists") {
14
+ recordSkipped(outcomes, options.reporter, entry.directory, "destination already exists");
15
+ continue;
16
+ }
17
+ if (destinationState.kind === "unsafe") {
18
+ const reason = destinationState.reason;
19
+ recordSkipped(outcomes, options.reporter, entry.directory, reason);
20
+ continue;
21
+ }
22
+ if (entry.warning) {
23
+ recordSkipped(outcomes, options.reporter, entry.directory, entry.warning);
24
+ continue;
25
+ }
26
+ if (!entry.source) {
27
+ recordSkipped(outcomes, options.reporter, entry.directory, "source does not exist");
28
+ continue;
29
+ }
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
+ const refreshedDestinationState = await inspectDestination(entry.worktreePath, entry.destination);
43
+ if (refreshedDestinationState.kind !== "missing") {
44
+ recordSkipped(outcomes, options.reporter, entry.directory, refreshedDestinationState.kind === "unsafe"
45
+ ? refreshedDestinationState.reason
46
+ : "destination already exists");
47
+ continue;
48
+ }
49
+ let result;
50
+ try {
51
+ const cloneOptions = {
52
+ destinationRoot: entry.worktreePath,
53
+ measureBytes: options.reporter.measureCloneSize,
54
+ };
55
+ result = await options.cloneDirectory(entry.source, entry.destination, cloneOptions);
56
+ }
57
+ catch (error) {
58
+ if (isCloneDestinationExistsError(error)) {
59
+ recordSkipped(outcomes, options.reporter, entry.directory, "destination already exists");
60
+ continue;
61
+ }
62
+ if (isCloneInProgressError(error)) {
63
+ const reason = "copy-on-write clone already in progress";
64
+ recordSkipped(outcomes, options.reporter, entry.directory, reason);
65
+ continue;
66
+ }
67
+ const reason = toErrorMessage(error);
68
+ if (isCloneUnsupportedError(error)) {
69
+ await failureStore.cache(options.repoRoot, entry.directory, reason, failureScope);
70
+ }
71
+ recordSkipped(outcomes, options.reporter, entry.directory, reason);
72
+ continue;
73
+ }
74
+ await failureStore.clear(options.repoRoot, entry.directory, failureScope);
75
+ const clonedDirectory = {
76
+ bytes: result.bytes,
77
+ dir: entry.directory,
78
+ ms: result.ms,
79
+ };
80
+ const outcome = { kind: "cloned", directory: clonedDirectory };
81
+ outcomes.push(outcome);
82
+ options.reporter.cloned(clonedDirectory);
83
+ }
84
+ return outcomes;
85
+ }
86
+ function recordSkipped(outcomes, reporter, dir, reason, notify = true) {
87
+ 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
+ }
94
+ }
95
+ function toErrorMessage(error) {
96
+ return error instanceof Error ? error.message : String(error);
97
+ }
@@ -0,0 +1,16 @@
1
+ export interface SyncDirectoryPlan {
2
+ directory: string;
3
+ destination: string;
4
+ worktreePath: string;
5
+ destinationWasPresent: boolean;
6
+ destinationWarning?: string;
7
+ source?: string;
8
+ warning?: string;
9
+ }
10
+ export interface SyncDirectoryEstimate {
11
+ bytes: number;
12
+ dir: string;
13
+ }
14
+ export declare function prepareSyncDirectoryPlan(repoRoot: string, worktreePath: string, directories: readonly string[]): Promise<SyncDirectoryPlan[]>;
15
+ export declare function estimateSyncDirectoryPlan(plan: readonly SyncDirectoryPlan[]): Promise<SyncDirectoryEstimate[]>;
16
+ export declare function estimateSyncDirectories(repoRoot: string, worktreePath: string, directories: readonly string[]): Promise<SyncDirectoryEstimate[]>;