@solaqua/gji 0.9.0 → 0.11.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 (100) hide show
  1. package/README.md +93 -20
  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 +25 -3
  9. package/dist/cli.js +105 -19
  10. package/dist/clone-failure-store.d.ts +18 -0
  11. package/dist/clone-failure-store.js +230 -0
  12. package/dist/command-runner.d.ts +6 -0
  13. package/dist/command-runner.js +38 -0
  14. package/dist/completion.js +7 -2
  15. package/dist/config-command.d.ts +1 -0
  16. package/dist/config-command.js +7 -1
  17. package/dist/config.d.ts +26 -1
  18. package/dist/config.js +110 -6
  19. package/dist/dependency-bootstrap-prompt.d.ts +24 -0
  20. package/dist/dependency-bootstrap-prompt.js +103 -0
  21. package/dist/dependency-bootstrap.d.ts +129 -0
  22. package/dist/dependency-bootstrap.js +566 -0
  23. package/dist/dir-clone.d.ts +33 -0
  24. package/dist/dir-clone.js +505 -0
  25. package/dist/done.d.ts +10 -0
  26. package/dist/done.js +208 -0
  27. package/dist/file-sync.js +71 -22
  28. package/dist/format-bytes.d.ts +1 -0
  29. package/dist/format-bytes.js +14 -0
  30. package/dist/fs-utils.d.ts +4 -0
  31. package/dist/fs-utils.js +26 -0
  32. package/dist/git.d.ts +5 -0
  33. package/dist/git.js +50 -0
  34. package/dist/gji-bundle.mjs +5489 -1206
  35. package/dist/go-resolver.d.ts +35 -0
  36. package/dist/go-resolver.js +185 -0
  37. package/dist/go.d.ts +5 -2
  38. package/dist/go.js +262 -62
  39. package/dist/hooks.d.ts +1 -1
  40. package/dist/hooks.js +13 -7
  41. package/dist/init.js +30 -22
  42. package/dist/install-prompt.d.ts +1 -0
  43. package/dist/install-prompt.js +3 -25
  44. package/dist/navigation-output.d.ts +11 -0
  45. package/dist/navigation-output.js +6 -0
  46. package/dist/new.d.ts +14 -2
  47. package/dist/new.js +434 -36
  48. package/dist/open.d.ts +1 -0
  49. package/dist/open.js +14 -2
  50. package/dist/pr.d.ts +6 -2
  51. package/dist/pr.js +135 -30
  52. package/dist/remove.js +20 -0
  53. package/dist/repo.d.ts +3 -0
  54. package/dist/repo.js +57 -6
  55. package/dist/safe-destination.d.ts +15 -0
  56. package/dist/safe-destination.js +166 -0
  57. package/dist/shell-completion.js +47 -14
  58. package/dist/sync-directories.d.ts +33 -0
  59. package/dist/sync-directories.js +97 -0
  60. package/dist/sync-plan.d.ts +16 -0
  61. package/dist/sync-plan.js +128 -0
  62. package/dist/sync.js +7 -13
  63. package/dist/undo.d.ts +36 -0
  64. package/dist/undo.js +392 -0
  65. package/dist/warp.d.ts +3 -1
  66. package/dist/warp.js +32 -125
  67. package/dist/worktree-bootstrap.d.ts +35 -0
  68. package/dist/worktree-bootstrap.js +93 -0
  69. package/dist/worktree-management.d.ts +1 -0
  70. package/dist/worktree-management.js +23 -4
  71. package/dist/worktree-picker.d.ts +21 -8
  72. package/dist/worktree-picker.js +107 -15
  73. package/dist/worktree-prompts.js +1 -1
  74. package/dist/worktree-source.d.ts +6 -0
  75. package/dist/worktree-source.js +1 -0
  76. package/dist/worktree-sources.d.ts +3 -0
  77. package/dist/worktree-sources.js +31 -0
  78. package/man/man1/gji-back.1 +1 -1
  79. package/man/man1/gji-clean.1 +1 -1
  80. package/man/man1/gji-completion.1 +1 -1
  81. package/man/man1/gji-config.1 +1 -1
  82. package/man/man1/gji-doctor.1 +1 -1
  83. package/man/man1/gji-done.1 +19 -0
  84. package/man/man1/gji-go.1 +9 -3
  85. package/man/man1/gji-history.1 +1 -1
  86. package/man/man1/gji-init.1 +1 -1
  87. package/man/man1/gji-ls.1 +1 -1
  88. package/man/man1/gji-new.1 +21 -3
  89. package/man/man1/gji-open.1 +4 -1
  90. package/man/man1/gji-pr.1 +7 -1
  91. package/man/man1/gji-remove.1 +3 -3
  92. package/man/man1/gji-root.1 +1 -1
  93. package/man/man1/gji-run-hook.1 +1 -1
  94. package/man/man1/gji-status.1 +1 -1
  95. package/man/man1/gji-sync-files.1 +1 -1
  96. package/man/man1/gji-sync.1 +1 -1
  97. package/man/man1/gji-undo.1 +16 -0
  98. package/man/man1/gji-warp.1 +3 -6
  99. package/man/man1/gji.1 +13 -5
  100. package/package.json +3 -1
@@ -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,7 +25,7 @@ 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" },
@@ -30,14 +38,17 @@ const TOP_LEVEL_COMMANDS = [
30
38
  { name: "clean", description: "interactively prune linked worktrees" },
31
39
  {
32
40
  name: "remove",
33
- description: "remove a linked worktree and delete its branch when present",
41
+ description: "deprecated: use done for one worktree or clean for bulk cleanup",
34
42
  },
35
- { name: "rm", description: "alias of remove" },
43
+ { name: "rm", description: "deprecated alias of remove" },
36
44
  {
37
45
  name: "run-hook",
38
46
  description: "run a named hook in the current worktree",
39
47
  },
40
- { name: "warp", description: "jump to any worktree across all known repos" },
48
+ {
49
+ name: "warp",
50
+ description: "deprecated: use go (press Tab for all known repos)",
51
+ },
41
52
  { name: "config", description: "manage global config defaults" },
42
53
  ];
43
54
  const SHELL_NAMES = ["bash", "fish", "zsh"];
@@ -137,7 +148,13 @@ _gji_completion() {
137
148
 
138
149
  case "$command_name" in
139
150
  new)
140
- COMPREPLY=( $(compgen -W "--detached --force --open --editor --dry-run --json --help" -- "$cur") )
151
+ COMPREPLY=( $(compgen -W "--detached --from-current --no-fetch --take --copy --force --open --editor --dry-run --json --help" -- "$cur") )
152
+ ;;
153
+ done)
154
+ COMPREPLY=( $(compgen -W "--force --keep-branch --json --help" -- "$cur") )
155
+ ;;
156
+ undo)
157
+ COMPREPLY=( $(compgen -W "--list --json --help" -- "$cur") )
141
158
  ;;
142
159
  init)
143
160
  COMPREPLY=( $(compgen -W "${shells} --write --json --help" -- "$cur") )
@@ -179,10 +196,10 @@ _gji_completion() {
179
196
  COMPREPLY=( $(compgen -W "--json --help" -- "$cur") )
180
197
  ;;
181
198
  open)
182
- COMPREPLY=( $(compgen -W "$(__gji_worktree_branches) --editor --save --workspace --help" -- "$cur") )
199
+ COMPREPLY=( $(compgen -W "$(__gji_worktree_branches) --editor --select --save --workspace --help" -- "$cur") )
183
200
  ;;
184
201
  go|jump)
185
- COMPREPLY=( $(compgen -W "$(__gji_worktree_branches) --print --help" -- "$cur") )
202
+ COMPREPLY=( $(compgen -W "$(__gji_worktree_branches) --root --print --json --help" -- "$cur") )
186
203
  ;;
187
204
  root)
188
205
  COMPREPLY=( $(compgen -W "--print --help" -- "$cur") )
@@ -212,7 +229,7 @@ _gji_completion() {
212
229
  COMPREPLY=( $(compgen -W "${hooks} --help" -- "$cur") )
213
230
  ;;
214
231
  warp)
215
- COMPREPLY=( $(compgen -W "-n --new --print --json --help" -- "$cur") )
232
+ COMPREPLY=( $(compgen -W "--print --json --help" -- "$cur") )
216
233
  ;;
217
234
  config)
218
235
  if [ "$COMP_CWORD" -eq 2 ]; then
@@ -361,9 +378,17 @@ function __gji_should_complete_config_key
361
378
  end
362
379
 
363
380
  complete -c gji -f
381
+ complete -c gji -n '__fish_seen_subcommand_from done' -l force -d 'remove dirty or unmerged worktrees without prompting'
382
+ complete -c gji -n '__fish_seen_subcommand_from done' -l keep-branch -d 'remove the worktree but preserve its branch'
383
+ complete -c gji -n '__fish_seen_subcommand_from done' -l json -d 'emit JSON on success or error instead of human-readable output'
384
+ complete -c gji -n '__fish_seen_subcommand_from undo' -l list -d 'list recoverable operations'
385
+ complete -c gji -n '__fish_seen_subcommand_from undo' -l json -d 'emit JSON on success or error instead of human-readable output'
364
386
  ${commandLines}
365
387
 
366
388
  complete -c gji -n '__fish_seen_subcommand_from new' -l detached -d 'create a detached worktree without a branch'
389
+ 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'
390
+ complete -c gji -n '__fish_seen_subcommand_from new' -l take -d 'move current uncommitted changes into the new worktree'
391
+ complete -c gji -n '__fish_seen_subcommand_from new' -l copy -d 'copy current uncommitted changes instead of moving them (requires --take)'
367
392
  complete -c gji -n '__fish_seen_subcommand_from new' -l force -d 'remove and recreate the worktree if the target path already exists'
368
393
  complete -c gji -n '__fish_seen_subcommand_from new' -l open -d 'open the new worktree in an editor after creation'
369
394
  complete -c gji -n '__fish_seen_subcommand_from new' -l editor -r -d 'editor CLI to use with --open (code, cursor, zed, …)'
@@ -393,11 +418,14 @@ complete -c gji -n '__fish_seen_subcommand_from back' -l print -d 'print the res
393
418
  complete -c gji -n '__fish_seen_subcommand_from history' -l json -d 'print history as JSON'
394
419
 
395
420
  complete -c gji -n '__fish_seen_subcommand_from open' -l editor -r -d 'editor CLI to use (code, cursor, zed, windsurf, subl, …)'
421
+ complete -c gji -n '__fish_seen_subcommand_from open' -l select -d 'choose a worktree with the interactive selector'
396
422
  complete -c gji -n '__fish_seen_subcommand_from open' -l save -d 'save the chosen editor to global config'
397
423
  complete -c gji -n '__fish_seen_subcommand_from open' -l workspace -d 'generate a .code-workspace file before opening (VS Code / Cursor / Windsurf)'
398
424
  complete -c gji -n '__fish_seen_subcommand_from open' -a '(__gji_worktree_branches)' -d 'worktree branch'
399
425
 
400
426
  complete -c gji -n '__fish_seen_subcommand_from go jump' -l print -d 'print the resolved worktree path explicitly'
427
+ complete -c gji -n '__fish_seen_subcommand_from go jump' -l root -d 'navigate to the main repository root'
428
+ complete -c gji -n '__fish_seen_subcommand_from go jump' -l json -d 'emit JSON for an existing worktree destination'
401
429
  complete -c gji -n '__fish_seen_subcommand_from go jump' -a '(__gji_worktree_branches)' -d 'worktree branch'
402
430
 
403
431
  complete -c gji -n '__fish_seen_subcommand_from root' -l print -d 'print the resolved repository root path explicitly'
@@ -428,7 +456,6 @@ complete -c gji -n '__fish_seen_subcommand_from remove rm' -a '(__gji_worktree_b
428
456
 
429
457
  ${hookLines}
430
458
 
431
- complete -c gji -n '__fish_seen_subcommand_from warp' -s n -l new -d 'create a new worktree in a registered repo'
432
459
  complete -c gji -n '__fish_seen_subcommand_from warp' -l print -d 'print the resolved worktree path without changing directory'
433
460
  complete -c gji -n '__fish_seen_subcommand_from warp' -l json -d 'emit JSON on success or error instead of human-readable output'
434
461
 
@@ -516,7 +543,13 @@ fi
516
543
 
517
544
  case "\${words[2]}" in
518
545
  new)
519
- _arguments '--detached[create a detached worktree without a branch]' '--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: '
546
+ _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)]' '--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: '
547
+ ;;
548
+ done)
549
+ _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: '
550
+ ;;
551
+ undo)
552
+ _arguments '--list[list recoverable operations]' '--json[emit JSON on success or error instead of human-readable output]' '2:id: '
520
553
  ;;
521
554
  init)
522
555
  _arguments '--write[write the integration to the shell config file]' '--json[emit a JSON error in non-interactive mode]' '2:shell:(${shells})'
@@ -547,10 +580,10 @@ case "\${words[2]}" in
547
580
  _arguments '--json[print history as JSON]'
548
581
  ;;
549
582
  open)
550
- _arguments '--editor[editor CLI to use (code, cursor, zed, windsurf, subl, …)]:editor:' '--save[save the chosen editor to global config]' '--workspace[generate a .code-workspace file before opening (VS Code / Cursor / Windsurf)]' '2:branch:->worktrees'
583
+ _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'
551
584
  ;;
552
585
  go|jump)
553
- _arguments '--print[print the resolved worktree path explicitly]' '2:branch:->worktrees'
586
+ _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'
554
587
  ;;
555
588
  root)
556
589
  _arguments '--print[print the resolved repository root path explicitly]'
@@ -577,7 +610,7 @@ case "\${words[2]}" in
577
610
  _arguments "2:hook:(${hooks})"
578
611
  ;;
579
612
  warp)
580
- _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: '
613
+ _arguments '--print[print the resolved worktree path without changing directory]' '--json[emit JSON on success or error instead of human-readable output]' '2:branch: '
581
614
  ;;
582
615
  config)
583
616
  if (( CURRENT == 3 )); then
@@ -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[]>;
@@ -0,0 +1,128 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { validateSyncDirPattern } from "./config.js";
4
+ import { directorySize } from "./dir-clone.js";
5
+ import { isNotFoundError } from "./fs-utils.js";
6
+ import { inspectDestination } from "./safe-destination.js";
7
+ export async function prepareSyncDirectoryPlan(repoRoot, worktreePath, directories) {
8
+ const normalizedDirectories = directories
9
+ .map(validateSyncDirPattern)
10
+ .sort(directoryDepthAscending);
11
+ const plan = [];
12
+ for (const directory of normalizedDirectories) {
13
+ const destination = join(worktreePath, directory);
14
+ const destinationState = await inspectDestination(worktreePath, destination);
15
+ const destinationWasPresent = destinationState.kind === "exists";
16
+ const destinationWarning = destinationState.kind === "unsafe" ? destinationState.reason : undefined;
17
+ try {
18
+ const source = await resolveSyncDirectorySource(repoRoot, directory);
19
+ if (!source) {
20
+ plan.push({
21
+ directory,
22
+ destination,
23
+ worktreePath,
24
+ destinationWasPresent,
25
+ destinationWarning,
26
+ });
27
+ }
28
+ else if ("warning" in source) {
29
+ plan.push({
30
+ directory,
31
+ destination,
32
+ worktreePath,
33
+ destinationWasPresent,
34
+ destinationWarning,
35
+ warning: source.warning,
36
+ });
37
+ }
38
+ else {
39
+ plan.push({
40
+ directory,
41
+ destination,
42
+ worktreePath,
43
+ destinationWasPresent,
44
+ destinationWarning,
45
+ source: source.path,
46
+ });
47
+ }
48
+ }
49
+ catch (error) {
50
+ plan.push({
51
+ directory,
52
+ destination,
53
+ worktreePath,
54
+ destinationWasPresent,
55
+ destinationWarning,
56
+ warning: `could not inspect ${directory}: ${toErrorMessage(error)}`,
57
+ });
58
+ }
59
+ }
60
+ return plan;
61
+ }
62
+ export async function estimateSyncDirectoryPlan(plan) {
63
+ const estimates = [];
64
+ for (const entry of plan) {
65
+ if (entry.destinationWasPresent ||
66
+ entry.destinationWarning ||
67
+ !entry.source ||
68
+ entry.warning ||
69
+ isCoveredByCloneAncestor(entry, plan)) {
70
+ continue;
71
+ }
72
+ try {
73
+ estimates.push({
74
+ bytes: await directorySize(entry.source),
75
+ dir: entry.directory,
76
+ });
77
+ }
78
+ catch {
79
+ // A dry-run is informational; an unreadable source is omitted.
80
+ }
81
+ }
82
+ return estimates;
83
+ }
84
+ export async function estimateSyncDirectories(repoRoot, worktreePath, directories) {
85
+ const plan = await prepareSyncDirectoryPlan(repoRoot, worktreePath, directories);
86
+ return estimateSyncDirectoryPlan(plan);
87
+ }
88
+ async function resolveSyncDirectorySource(repoRoot, directory) {
89
+ const source = join(repoRoot, directory);
90
+ let resolvedSource;
91
+ try {
92
+ resolvedSource = await realpath(source);
93
+ }
94
+ catch (error) {
95
+ if (isNotFoundError(error))
96
+ return null;
97
+ throw error;
98
+ }
99
+ if (!isPathInside(repoRoot, resolvedSource)) {
100
+ return {
101
+ warning: `source symlink resolves outside the repository (${resolvedSource})`,
102
+ };
103
+ }
104
+ const stats = await lstat(resolvedSource);
105
+ if (!stats.isDirectory())
106
+ return { warning: "source is not a directory" };
107
+ return { path: resolvedSource };
108
+ }
109
+ function isCoveredByCloneAncestor(entry, plan) {
110
+ return plan.some((candidate) => candidate !== entry &&
111
+ !candidate.destinationWasPresent &&
112
+ !candidate.destinationWarning &&
113
+ !!candidate.source &&
114
+ isPathInside(candidate.directory, entry.directory));
115
+ }
116
+ function directoryDepthAscending(left, right) {
117
+ return left.split(/[\\/]+/u).length - right.split(/[\\/]+/u).length;
118
+ }
119
+ function isPathInside(parent, child) {
120
+ const relativePath = relative(resolve(parent), resolve(child));
121
+ return (relativePath !== "" &&
122
+ !isAbsolute(relativePath) &&
123
+ relativePath !== ".." &&
124
+ !relativePath.startsWith(`..${sep}`));
125
+ }
126
+ function toErrorMessage(error) {
127
+ return error instanceof Error ? error.message : String(error);
128
+ }
package/dist/sync.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { loadEffectiveConfig } from "./config.js";
2
- import { isDirtyWorktree, resolveRemoteDefaultBranch, runGit } from "./git.js";
2
+ import { isDirtyWorktree, resolveRemoteBase, runGit, } from "./git.js";
3
3
  import { comparePaths } from "./paths.js";
4
4
  import { detectRepository, listWorktrees } from "./repo.js";
5
5
  export async function runSyncCommand(options) {
@@ -7,11 +7,9 @@ export async function runSyncCommand(options) {
7
7
  const config = await loadEffectiveConfig(repository.repoRoot, undefined, options.stderr);
8
8
  const worktrees = await listWorktrees(options.cwd);
9
9
  const remote = resolveConfiguredString(config.syncRemote) ?? "origin";
10
- let defaultBranch;
10
+ let remoteBase;
11
11
  try {
12
- defaultBranch =
13
- resolveConfiguredString(config.syncDefaultBranch) ??
14
- (await resolveRemoteDefaultBranch(repository.repoRoot, remote));
12
+ remoteBase = await resolveRemoteBase(repository.repoRoot, remote, resolveConfiguredString(config.syncDefaultBranch) ?? undefined);
15
13
  }
16
14
  catch {
17
15
  emitError(options, `Unable to reach remote '${remote}'`);
@@ -20,7 +18,7 @@ export async function runSyncCommand(options) {
20
18
  }
21
19
  return 1;
22
20
  }
23
- if (!defaultBranch) {
21
+ if (!remoteBase) {
24
22
  emitError(options, "Unable to determine the default branch for sync.");
25
23
  if (!options.json) {
26
24
  options.stderr(`Hint: Add the remote with: git remote add ${remote} <url>\n`);
@@ -50,15 +48,11 @@ export async function runSyncCommand(options) {
50
48
  }
51
49
  const updatedWorktrees = [];
52
50
  for (const worktree of targetWorktrees) {
53
- if (worktree.branch === defaultBranch) {
54
- await runGit(worktree.path, [
55
- "merge",
56
- "--ff-only",
57
- `${remote}/${defaultBranch}`,
58
- ]);
51
+ if (worktree.branch === remoteBase.branch) {
52
+ await runGit(worktree.path, ["merge", "--ff-only", remoteBase.ref]);
59
53
  }
60
54
  else {
61
- await runGit(worktree.path, ["rebase", `${remote}/${defaultBranch}`]);
55
+ await runGit(worktree.path, ["rebase", remoteBase.ref]);
62
56
  }
63
57
  updatedWorktrees.push(worktree);
64
58
  if (!options.json) {
package/dist/undo.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { type WorktreeEntry } from "./repo.js";
2
+ export type UndoOperation = "remove" | "clean" | "done";
3
+ export interface UndoEntry {
4
+ branch: string | null;
5
+ headSha: string;
6
+ path: string;
7
+ upstream: string | null;
8
+ wasDirty: boolean;
9
+ }
10
+ export interface UndoRecord {
11
+ id: string;
12
+ op: UndoOperation;
13
+ repoRoot: string;
14
+ timestamp: number;
15
+ entries: UndoEntry[];
16
+ }
17
+ export interface UndoRestoreResult {
18
+ restored: UndoEntry[];
19
+ failed: Array<UndoEntry & {
20
+ message: string;
21
+ }>;
22
+ }
23
+ export interface UndoCommandOptions {
24
+ cwd: string;
25
+ id?: string;
26
+ list?: boolean;
27
+ json?: boolean;
28
+ stderr: (chunk: string) => void;
29
+ stdout: (chunk: string) => void;
30
+ }
31
+ export declare function undoLogPath(home?: string): string;
32
+ export declare function loadUndoRecords(home?: string): Promise<UndoRecord[]>;
33
+ export declare function recordUndoOperation(op: UndoOperation, repoRoot: string, worktrees: WorktreeEntry[], home?: string): Promise<UndoRecord | null>;
34
+ export declare function finalizeUndoOperation(record: UndoRecord, removedWorktrees: WorktreeEntry[], home?: string): Promise<void>;
35
+ export declare function restoreUndoRecord(record: UndoRecord, home?: string): Promise<UndoRestoreResult>;
36
+ export declare function runUndoCommand(options: UndoCommandOptions): Promise<number>;